mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-31 22:16:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00daa5ce52 |
@@ -54,7 +54,6 @@
|
||||
"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",
|
||||
@@ -1643,10 +1642,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -2881,8 +2876,6 @@
|
||||
|
||||
"@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=="],
|
||||
@@ -5309,10 +5302,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -5327,8 +5316,6 @@
|
||||
|
||||
"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=="],
|
||||
@@ -6643,8 +6630,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
|
||||
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
|
||||
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
|
||||
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
|
||||
"x86_64-linux": "sha256-fG6VYtNC0pce4VM9po7vVucPuJul42yuuijTjNSr7rk=",
|
||||
"aarch64-linux": "sha256-3TznrmNqdt25cOxia6vcdi/5qKaeyLPIsNXGYBSJNrs=",
|
||||
"aarch64-darwin": "sha256-8Kmagb5tfECSWZNsIJgrRP1d3X5tuEoWLEWkV3UENZo=",
|
||||
"x86_64-darwin": "sha256-mIV+mDwIGD02BNYZVi37sY4ls1T01N6z76eBtH0sKiA="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-108
@@ -241,121 +241,14 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. The client returns that replacement window directly, including retained user messages and usage, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Provider compaction
|
||||
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex and Bedrock Messages), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider.
|
||||
|
||||
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
|
||||
|
||||
### Automatic compaction
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
|
||||
messages,
|
||||
providerOptions: {
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
```
|
||||
|
||||
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
|
||||
|
||||
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
|
||||
|
||||
```ts
|
||||
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
|
||||
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
|
||||
```
|
||||
|
||||
For Anthropic, use:
|
||||
|
||||
```ts
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 150_000 },
|
||||
pauseAfterCompaction: true,
|
||||
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
|
||||
}],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
|
||||
- Custom instructions replace Anthropic's default summarization instructions.
|
||||
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
|
||||
- A pause is exposed as `response.finishReason.raw === "compaction"`. The caller explicitly issues the next request; the package never automatically resumes.
|
||||
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
|
||||
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
|
||||
|
||||
Bedrock's Converse API does not support this feature. Select the native Claude Messages route explicitly; the default `.model(...)` remains Converse:
|
||||
|
||||
```ts
|
||||
import { AmazonBedrock } from "@opencode-ai/ai/providers"
|
||||
|
||||
const model = AmazonBedrock.configure({ region: "us-east-1", credentials }).messages("us.anthropic.claude-opus-4-6-v1")
|
||||
```
|
||||
|
||||
The corresponding package entrypoint is `@opencode-ai/ai/providers/amazon-bedrock/messages`. It uses InvokeModelWithResponseStream, AWS event-stream framing, bearer or SigV4 auth, and `anthropic_beta` in the request body.
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` containing replacement `messages` and usage, not a normal generation response.
|
||||
|
||||
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
|
||||
|
||||
```ts
|
||||
const compacted = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...compacted.messages, Message.user("Continue")],
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
Replace the prior window with `compacted.messages`. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly. Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint.
|
||||
|
||||
Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. xAI supports this explicit path, not the automatic OpenAI option. Unsupported routes, including Bedrock Mantle, do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Ownership and verification
|
||||
|
||||
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
|
||||
|
||||
Tests cover serialized round trips, real local HTTP plus a tool loop, AWS binary frames and signing, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
|
||||
|
||||
```sh
|
||||
# Run from packages/ai. Only records the selected new cassette group.
|
||||
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
```
|
||||
|
||||
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-compaction.html), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
@@ -40,7 +40,6 @@ const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"bedrock-messages",
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
|
||||
@@ -6,12 +6,8 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
@@ -19,6 +15,7 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -64,7 +61,6 @@ export type ThinkingInput =
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
@@ -93,23 +89,6 @@ export interface OptionsInput {
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("input_tokens"),
|
||||
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
|
||||
}),
|
||||
),
|
||||
pauseAfterCompaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
@@ -257,12 +236,7 @@ const AnthropicUserBlock = Schema.Union([
|
||||
AnthropicToolResultBlock,
|
||||
])
|
||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicCompactionBlock = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
})
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicCompactionBlock,
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
@@ -338,18 +312,6 @@ const AnthropicContainer = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: ContextManagement.fields.edits.value.fields.trigger,
|
||||
pause_after_compaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
model: Schema.String,
|
||||
system: optionalArray(AnthropicTextBlock),
|
||||
messages: Schema.Array(AnthropicMessage),
|
||||
@@ -373,7 +335,7 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: optionalNull(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
@@ -392,13 +354,6 @@ const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...AnthropicIterationUsage.schema.fields,
|
||||
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -422,7 +377,6 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
@@ -452,8 +406,6 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly provider: LLMRequest["model"]["provider"]
|
||||
readonly compactions: Readonly<Record<number, string | null>>
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -896,12 +848,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (message.role === "assistant") {
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
if (part.provider !== request.model.provider || part.text === undefined)
|
||||
return yield* invalid("Compaction state must be replayed to its originating provider and API")
|
||||
content.push({ type: "compaction", content: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
@@ -1057,9 +1003,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1094,7 +1037,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
const body = {
|
||||
return {
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -1115,18 +1058,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
...body,
|
||||
context_management: {
|
||||
edits: management.edits.map((edit) => ({
|
||||
type: edit.type,
|
||||
trigger: edit.trigger,
|
||||
pause_after_compaction: edit.pauseAfterCompaction,
|
||||
instructions: edit.instructions,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -1148,31 +1079,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const iterations = usage.iterations?.length ? usage.iterations : [usage]
|
||||
const last = usage.iterations?.at(-1)
|
||||
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
|
||||
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
|
||||
const cacheWrite = ProviderShared.sumTokens(
|
||||
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
|
||||
)
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens:
|
||||
last?.type === "message"
|
||||
? ProviderShared.sumTokens(
|
||||
last.input_tokens ?? undefined,
|
||||
last.cache_read_input_tokens ?? undefined,
|
||||
last.cache_creation_input_tokens ?? undefined,
|
||||
)
|
||||
: undefined,
|
||||
outputTokens: usage.output_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1194,7 +1112,6 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens: right.contextTokens ?? left.contextTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
@@ -1253,6 +1170,7 @@ const onContentBlockStart = (
|
||||
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
|
||||
): StepResult => {
|
||||
const block = event.content_block
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
|
||||
@@ -1347,16 +1265,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
) {
|
||||
const delta = event.delta
|
||||
|
||||
if (delta.type === "compaction_delta") {
|
||||
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
|
||||
return [
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "text_delta" && delta.text) {
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1365,7 +1274,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "thinking_delta" && delta.thinking) {
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1377,7 +1286,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "signature_delta" && delta.signature) {
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const index = event.index ?? 0
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
return [
|
||||
@@ -1389,7 +1298,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
@@ -1414,18 +1323,6 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
event: AnthropicEvent,
|
||||
) {
|
||||
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (event.index in state.compactions) {
|
||||
const { [event.index]: content, ...compactions } = state.compactions
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.compaction({
|
||||
provider: state.provider,
|
||||
text: content,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
@@ -1477,8 +1374,6 @@ const onMessageDelta = (
|
||||
}
|
||||
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
if (Object.keys(state.compactions).length)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
@@ -1523,21 +1418,16 @@ const onError = (event: AnthropicEvent) => {
|
||||
)
|
||||
}
|
||||
|
||||
const STREAM_BLOCK_TYPES = new Set([
|
||||
"compaction",
|
||||
"text",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"tool_use",
|
||||
"server_tool_use",
|
||||
])
|
||||
const STREAM_DELTA_TYPES = new Set([
|
||||
"compaction_delta",
|
||||
"text_delta",
|
||||
"thinking_delta",
|
||||
"signature_delta",
|
||||
"input_json_delta",
|
||||
])
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
type === "thinking" ||
|
||||
type === "redacted_thinking" ||
|
||||
type === "tool_use" ||
|
||||
type === "server_tool_use" ||
|
||||
isServerToolResultType(type)
|
||||
|
||||
const isKnownStreamDeltaType = (type: string) =>
|
||||
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
|
||||
|
||||
const invalidStreamEvent = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
@@ -1566,16 +1456,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_start") {
|
||||
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
|
||||
return invalidStreamEvent(event)
|
||||
if (event.content_block.type === "compaction") {
|
||||
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
|
||||
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
return Effect.succeed<StepResult>([
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
|
||||
NO_EVENTS,
|
||||
])
|
||||
}
|
||||
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamBlock(event.content_block)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
const block = decoded.value
|
||||
@@ -1589,7 +1470,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
}
|
||||
if (event.type === "content_block_delta") {
|
||||
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
|
||||
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
|
||||
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
@@ -1623,8 +1504,6 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: (request) => ({
|
||||
provider: request.model.provider,
|
||||
compactions: {},
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
@@ -1634,37 +1513,6 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
|
||||
const http = HttpTransport.httpJson<Body, string>({ framing })
|
||||
return {
|
||||
...http,
|
||||
prepare: (input: Parameters<typeof http.prepare>[0]) => {
|
||||
if (
|
||||
!input.body.context_management?.edits.length &&
|
||||
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
return http.prepare(input)
|
||||
const headers = Headers.fromInput(input.request.http?.headers)
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
return http.prepare({
|
||||
...input,
|
||||
request: LLMRequest.update(input.request, {
|
||||
http: new HttpOptions({
|
||||
...input.request.http,
|
||||
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
@@ -1674,7 +1522,7 @@ export const route = Route.make({
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
transport: transport<AnthropicMessagesBody>(),
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Effect, Encoding, Schema, Struct } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AIError } from "../schema/index.js"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { AnthropicMessages } from "./anthropic-messages.js"
|
||||
import { BedrockEventStream } from "./bedrock-event-stream.js"
|
||||
import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { JsonObject, ProviderShared } from "./shared.js"
|
||||
|
||||
const ID = "bedrock-messages"
|
||||
const VERSION = "bedrock-2023-05-31"
|
||||
const Body = Schema.Struct({
|
||||
...Struct.omit(AnthropicMessages.AnthropicMessagesBody.fields, ["model", "stream"]),
|
||||
anthropic_version: Schema.Literal(VERSION),
|
||||
anthropic_beta: Schema.optional(Schema.Array(Schema.String)),
|
||||
}).check(
|
||||
Schema.makeFilter(
|
||||
(body) =>
|
||||
body.messages.flatMap((message) => message.content.map(mediaIssue)).find((issue) => issue !== undefined) ?? true,
|
||||
),
|
||||
)
|
||||
const Event = Schema.Struct({
|
||||
chunk: Schema.optional(Schema.Struct({ bytes: Schema.String })),
|
||||
exception: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
details: Schema.StructWithRest(
|
||||
Schema.Struct({ message: Schema.optional(Schema.String), originalMessage: Schema.optional(Schema.String) }),
|
||||
[JsonObject],
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ID,
|
||||
body: {
|
||||
schema: Body,
|
||||
from: Effect.fn("BedrockMessages.fromRequest")(function* (request) {
|
||||
const body = yield* AnthropicMessages.protocol.body.from(request)
|
||||
const headers = Headers.fromInput(request.http?.headers)
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
if (
|
||||
body.context_management?.edits.length ||
|
||||
body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
return {
|
||||
...Struct.omit(body, ["model", "stream"]),
|
||||
anthropic_version: VERSION,
|
||||
anthropic_beta: betas.size ? [...betas] : undefined,
|
||||
} satisfies typeof Body.Type
|
||||
}),
|
||||
},
|
||||
stream: {
|
||||
event: Event,
|
||||
initial: AnthropicMessages.protocol.stream.initial,
|
||||
step: Effect.fn("BedrockMessages.step")(function* (state, event) {
|
||||
if (event.exception)
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: event.exception.details.message ?? event.exception.details.originalMessage ?? event.exception.type,
|
||||
rawBody: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
if (!event.chunk) return yield* ProviderShared.eventError(ID, "Bedrock Messages event is missing its chunk")
|
||||
const text = yield* Effect.fromResult(Encoding.decodeBase64String(event.chunk.bytes)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(ID, "Invalid Bedrock Messages chunk encoding", undefined, cause),
|
||||
),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(AnthropicMessages.protocol.stream.event)(text).pipe(
|
||||
Effect.mapError((cause) => ProviderShared.eventError(ID, "Invalid Bedrock Messages event", undefined, cause)),
|
||||
)
|
||||
return yield* AnthropicMessages.protocol.stream.step(state, decoded)
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
function mediaIssue(
|
||||
block: AnthropicMessages.AnthropicMessagesBody["messages"][number]["content"][number],
|
||||
): string | undefined {
|
||||
if (block.type === "tool_result")
|
||||
return typeof block.content === "string"
|
||||
? undefined
|
||||
: block.content.map(mediaIssue).find((issue) => issue !== undefined)
|
||||
if (block.type !== "image" && block.type !== "document") return undefined
|
||||
if (block.source.type === "url" || block.source.type === "file")
|
||||
return "Bedrock Messages does not support URL or file-ID media sources"
|
||||
if (
|
||||
block.type === "image" &&
|
||||
!["image/jpeg", "image/png", "image/webp", "image/gif"].includes(block.source.media_type)
|
||||
)
|
||||
return "Bedrock Messages requires a JPEG, PNG, WebP, or GIF image"
|
||||
if (block.source.type === "base64" && Encoding.decodeBase64(block.source.data)._tag === "Failure")
|
||||
return "Bedrock Messages media data must be valid base64"
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ID,
|
||||
provider: "amazon-bedrock",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(
|
||||
({ request }) => `/model/${encodeURIComponent(request.model.id)}/invoke-with-response-stream`,
|
||||
{ baseURL: "https://bedrock-runtime.us-east-1.amazonaws.com" },
|
||||
),
|
||||
auth: BedrockAuth.auth,
|
||||
framing: BedrockEventStream.framing(ID),
|
||||
})
|
||||
|
||||
export * as BedrockMessages from "./bedrock-messages.js"
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as AnthropicMessages from "./anthropic-messages.js"
|
||||
export * as BedrockConverse from "./bedrock-converse.js"
|
||||
export { BedrockMessages } from "./bedrock-messages.js"
|
||||
export * as Gemini from "./gemini.js"
|
||||
export * as MistralChat from "./mistral-chat.js"
|
||||
export * as OpenAIChat from "./openai-chat.js"
|
||||
|
||||
@@ -32,19 +32,17 @@ export const PATH = "/responses"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
export const OpenResponsesInputText = Schema.Struct({
|
||||
const OpenResponsesInputText = Schema.Struct({
|
||||
type: Schema.tag("input_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
export const OpenResponsesInputImage = Schema.Struct({
|
||||
const OpenResponsesInputImage = Schema.Struct({
|
||||
type: Schema.tag("input_image"),
|
||||
image_url: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
})
|
||||
export const OpenResponsesInputFile = Schema.Struct({
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
file_data: Schema.optional(Schema.String),
|
||||
file_url: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -56,7 +54,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
|
||||
|
||||
export const OpenResponsesOutputText = Schema.Struct({
|
||||
const OpenResponsesOutputText = Schema.Struct({
|
||||
type: Schema.tag("output_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
@@ -64,13 +62,6 @@ export const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
export const MessageMetadata = Schema.Struct({
|
||||
itemId: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(MessagePhase),
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
@@ -81,7 +72,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
const OpenResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
@@ -158,30 +149,16 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
export const CompactionItem = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
id: optionalNull(Schema.String),
|
||||
encrypted_content: Schema.String,
|
||||
})
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("user"),
|
||||
content: Schema.Array(OpenResponsesInputContent),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
@@ -290,7 +267,7 @@ const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
@@ -410,8 +387,6 @@ 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
|
||||
@@ -513,9 +488,6 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[metadataKey(request.model)]?.detail,
|
||||
)
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
@@ -526,15 +498,10 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
detail,
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "input_image" as const,
|
||||
image_url: url ?? media.dataUrl,
|
||||
detail,
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -598,12 +565,9 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = metadataKey(request.model)
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
const metadata = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
if (message.role === "system") {
|
||||
input.push({
|
||||
role: "developer",
|
||||
@@ -614,8 +578,7 @@ 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, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -628,10 +591,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const groups = content.reduce<
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const partMetadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
|
||||
const partPhase = messagePhase(partMetadata?.phase)
|
||||
const phase = partPhase === undefined ? metadata?.phase : partPhase
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -642,7 +604,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -650,15 +611,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
flushText()
|
||||
if (part.provider !== request.model.provider || part.encrypted === undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Compaction state must be replayed to its originating provider and API",
|
||||
)
|
||||
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
content.push(part)
|
||||
continue
|
||||
@@ -737,30 +689,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
return input
|
||||
})
|
||||
|
||||
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, extension),
|
||||
...(instructions ? { instructions } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerGeneration = (request: LLMRequest) => {
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const generation = request.generation
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
@@ -788,7 +723,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
export const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
@@ -802,10 +737,11 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
...(yield* lowerConversation(request, extension)),
|
||||
...lowerGeneration(request),
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, extension),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -819,6 +755,13 @@ 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),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -835,7 +778,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
|
||||
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||
@@ -865,8 +808,6 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
|
||||
return hasFunctionCall ? "tool-calls" : "unknown"
|
||||
}
|
||||
|
||||
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
|
||||
[state.providerMetadataKey]: metadata,
|
||||
})
|
||||
@@ -1143,25 +1084,6 @@ 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)
|
||||
@@ -1321,34 +1243,26 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
if (item.type !== "compaction" && item.type !== "function_call") continue
|
||||
if (item.type === "compaction") {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
}
|
||||
if (item.type === "function_call" && !current.tools[item.id ?? item.call_id ?? ""]) continue
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending = yield* ToolStream.finishAll(current.id, current.tools)
|
||||
current = {
|
||||
...current,
|
||||
tools: pending.tools,
|
||||
hasFunctionCall:
|
||||
current.hasFunctionCall ||
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
|
||||
}
|
||||
events.push(...pending.events)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, current.hasFunctionCall),
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
@@ -1360,7 +1274,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1495,11 +1409,9 @@ 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: metadataKey(request.model),
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -21,14 +20,6 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = OpenResponses.PATH
|
||||
|
||||
export const ContextManagement = Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
)
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
type: Schema.tag("image_generation"),
|
||||
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
|
||||
@@ -87,14 +78,6 @@ const OpenAIResponsesCoreFields = {
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
@@ -142,14 +125,15 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(request, extension)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -157,8 +141,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,7 +223,6 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(extension),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { MediaPart } from "../../schema/index.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
@@ -57,16 +57,6 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause),
|
||||
),
|
||||
)
|
||||
return Encoding.encodeBase64(bytes)
|
||||
})
|
||||
|
||||
// Route by MIME. Known image/document formats lower into a typed block; anything
|
||||
// else fails with a clear error instead of silently degrading to a malformed
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
@@ -76,7 +66,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
@@ -84,7 +75,8 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
import { Endpoint } from "../../route/endpoint.js"
|
||||
import { RequestExecutor } from "../../route/executor.js"
|
||||
import { HttpTransport } from "../../route/transport/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
|
||||
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(Schema.Unknown),
|
||||
instructions: optionalNull(Schema.String),
|
||||
previous_response_id: optionalNull(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
prompt_cache_key: optionalNull(Schema.String),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
|
||||
const File = Schema.Union([
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_url: Schema.String,
|
||||
file_data: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_data: Schema.String,
|
||||
file_url: Schema.optional(Schema.Never),
|
||||
}),
|
||||
])
|
||||
const MessageFields = {
|
||||
type: Schema.Literal("message"),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(OpenResponses.MessagePhase),
|
||||
}
|
||||
const Response = Schema.Struct({
|
||||
object: Schema.Literal("response.compaction"),
|
||||
output: Schema.Array(
|
||||
Schema.Union([
|
||||
OpenResponses.CompactionItem,
|
||||
OpenResponses.OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
|
||||
Schema.isMinLength(1),
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Array(Text).check(Schema.isMinLength(1)),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
|
||||
})
|
||||
|
||||
export const make = (extension: OpenResponses.Extension): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
const native = yield* OpenResponses.lowerConversation(request, extension)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
...native,
|
||||
service_tier: request.providerOptions?.serviceTier,
|
||||
prompt_cache_key: ProviderShared.promptCacheKey(request),
|
||||
},
|
||||
request.http?.body,
|
||||
),
|
||||
)
|
||||
const url = Endpoint.render(route.endpoint, { request, body: native })
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
request: LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
}),
|
||||
body,
|
||||
endpoint: Endpoint.path(url.toString()),
|
||||
auth: route.auth,
|
||||
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
|
||||
})
|
||||
const response = yield* executor.execute(
|
||||
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
options?.http,
|
||||
)
|
||||
const text = yield* RequestExecutor.responseStream(response).pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runFold(
|
||||
() => "",
|
||||
(text, chunk) => text + chunk,
|
||||
),
|
||||
)
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: route.id,
|
||||
message,
|
||||
body: text,
|
||||
cause,
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
}),
|
||||
})
|
||||
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
|
||||
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
|
||||
)
|
||||
if (!result.output.some((item) => item.type === "compaction"))
|
||||
return yield* invalid("Compaction response did not contain a checkpoint")
|
||||
return new CompactionResponse({
|
||||
messages: result.output.map((item) => toMessage(item, request.model)),
|
||||
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
})
|
||||
|
||||
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
|
||||
if (item.type === "compaction")
|
||||
return Message.assistant(
|
||||
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
|
||||
)
|
||||
|
||||
const key = OpenResponses.metadataKey(model)
|
||||
if (item.type === "reasoning") {
|
||||
const summary = item.summary.length ? item.summary : [{ text: "" }]
|
||||
return Message.assistant(
|
||||
summary.map((part) => ({
|
||||
type: "reasoning" as const,
|
||||
text: part.text,
|
||||
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return Message.make({
|
||||
role: item.role,
|
||||
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
|
||||
content: item.content.map((part): ContentPart => {
|
||||
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
|
||||
if (part.type === "input_image")
|
||||
return {
|
||||
type: "media",
|
||||
data: part.image_url,
|
||||
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
const data = part.file_url === undefined ? part.file_data : part.file_url
|
||||
return {
|
||||
type: "media",
|
||||
data,
|
||||
filename: part.filename,
|
||||
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export * as ResponsesCompaction from "./responses-compaction.js"
|
||||
@@ -4,7 +4,6 @@ import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
@@ -45,10 +44,6 @@ 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))
|
||||
})
|
||||
|
||||
@@ -89,6 +84,4 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const compact = ResponsesCompaction.make(extension)
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,9 +9,8 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package.js"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockMessages } from "../protocols/bedrock-messages.js"
|
||||
import type { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
|
||||
@@ -27,40 +25,38 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
export const routes = [BedrockConverse.route, BedrockMessages.route]
|
||||
export const routes = [BedrockConverse.route]
|
||||
|
||||
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
|
||||
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) => {
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, credentials, region, baseURL, ...rest } = input
|
||||
const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
|
||||
return route.with({
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
provider: id,
|
||||
providerMetadataKey: route.providerMetadataKey,
|
||||
providerMetadataKey: "bedrock",
|
||||
endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
|
||||
auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(BedrockConverse.route, input)
|
||||
const messages = configuredRoute(BedrockMessages.route, input)
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
messages: (modelID: string | ModelID) => messages.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
const config = (settings: Settings): Config => {
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
if (settings.auth === "bearer" && settings.apiKey === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
return {
|
||||
return configure({
|
||||
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
@@ -68,13 +64,5 @@ const config = (settings: Settings): Config => {
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
region: settings.region,
|
||||
}
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).model(modelID)
|
||||
export const messagesModel: ProviderPackage.Definition<
|
||||
Settings & { readonly providerOptions?: AnthropicMessages.ProviderOptionsInput },
|
||||
AnthropicMessages.ProviderOptionsInput
|
||||
>["model"] = (modelID, settings) =>
|
||||
configure({ ...config(settings), providerOptions: settings.providerOptions }).messages(modelID)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { messagesModel as model } from "../amazon-bedrock.js"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -102,11 +102,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
) =>
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: endpoint(input, modelID),
|
||||
@@ -165,11 +161,10 @@ const config = (settings: Settings): Config => {
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
|
||||
@@ -57,9 +57,7 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
transport: AnthropicMessages.transport<
|
||||
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
|
||||
>(),
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ContextManagement } from "../protocols/openai-responses.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,10 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: { baseURL: input.baseURL, query: input.queryParams },
|
||||
@@ -132,10 +129,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -32,7 +32,6 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,10 +102,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -13,7 +13,6 @@ import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -35,12 +34,7 @@ export interface RouteBody<Body> {
|
||||
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
|
||||
}
|
||||
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
export interface Route<Body, Prepared = unknown> {
|
||||
readonly id: string
|
||||
readonly provider?: ProviderID
|
||||
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||
@@ -48,15 +42,13 @@ export interface Route<
|
||||
readonly protocol: ProtocolID
|
||||
readonly endpoint: Endpoint.Definition<Body>
|
||||
readonly auth: Auth.Definition
|
||||
/** Deployment headers resolved once for every operation, before transport authentication. */
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
) => LanguageModel<Options>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
@@ -74,11 +66,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
>
|
||||
export type AnyRoute = Route<any, any>
|
||||
|
||||
export type HttpOptionsInput = HttpOptions.Input
|
||||
|
||||
@@ -111,15 +99,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||
route: AnyRoute,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
return LanguageModel.make<Options>({
|
||||
...mapped,
|
||||
provider,
|
||||
route,
|
||||
@@ -162,10 +150,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -183,17 +167,6 @@ export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
|
||||
}
|
||||
|
||||
export type CompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type CompactionRequest = LLMRequest<LanguageModel<ProviderOptions, CompactOperation>>
|
||||
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
@@ -214,7 +187,6 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -236,7 +208,6 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -312,14 +283,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
|
||||
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
|
||||
const route: Route<Body, Prepared> = {
|
||||
compact: routeInput.compact,
|
||||
id: routeInput.id,
|
||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||
providerMetadataKey: routeInput.providerMetadataKey,
|
||||
protocol: protocol.id,
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
headers: routeInput.headers,
|
||||
transport: routeInput.transport,
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
@@ -341,7 +310,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -349,6 +318,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
middleware: options?.http,
|
||||
webSocket: options?.webSocket,
|
||||
}),
|
||||
@@ -438,12 +408,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -471,7 +435,6 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
if ("transport" in input) return makeFromTransport(input)
|
||||
const protocol = input.protocol
|
||||
return makeFromTransport({
|
||||
compact: input.compact,
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
providerMetadataKey: input.providerMetadataKey,
|
||||
@@ -484,19 +447,11 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const prepareRequest = (request: LLMRequest) => {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
|
||||
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
? resolved
|
||||
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const resolved = prepareRequest(request)
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
@@ -555,15 +510,6 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -574,29 +520,16 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.invalidRequest(
|
||||
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
)
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const Route = { make } as const
|
||||
|
||||
export const LLMClient = {
|
||||
canCompact,
|
||||
compact,
|
||||
Service,
|
||||
layer,
|
||||
stream,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { LLM } from "@opencode-ai/schema/llm"
|
||||
import { ContentBlockID, ToolCallID } from "./ids.js"
|
||||
import {
|
||||
Message,
|
||||
CompactionPart,
|
||||
ProviderMetadata,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
@@ -63,8 +62,6 @@ export { ProviderMetadata } from "./messages.js"
|
||||
* Matches the same escape-hatch field on `LLMEvent`.
|
||||
*/
|
||||
export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
/** Effective input size of the final message iteration, when reported; not billed totals. */
|
||||
contextTokens: Schema.optional(Schema.Number),
|
||||
inputTokens: Schema.optional(Schema.Number),
|
||||
outputTokens: Schema.optional(Schema.Number),
|
||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||
@@ -75,7 +72,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* to zero. The one place subtraction happens in this contract; the clamp
|
||||
* means a provider reporting `reasoningTokens > outputTokens` produces a
|
||||
* harmless zero rather than a negative that crashes downstream schemas.
|
||||
@@ -91,12 +88,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
|
||||
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
|
||||
|
||||
/** A replacement context window. Replace prior history with these messages. */
|
||||
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
|
||||
messages: Schema.Array(Message),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
@@ -250,7 +241,6 @@ export const ProviderErrorEvent = Schema.Struct({
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
CompactionPart,
|
||||
StepStart,
|
||||
TextStart,
|
||||
TextDelta,
|
||||
@@ -284,7 +274,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
compaction: CompactionPart.make,
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
@@ -322,7 +311,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
compaction: llmEventTagged.guards.compaction,
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
@@ -345,10 +333,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
|
||||
const joinFragments = (
|
||||
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
|
||||
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
@@ -575,8 +563,6 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
|
||||
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
const next = appendEvent(state, event)
|
||||
switch (event.type) {
|
||||
case "compaction":
|
||||
return appendContent(next, event)
|
||||
case "text-start":
|
||||
return ensureText(next, event.id, event.providerMetadata)
|
||||
case "text-delta":
|
||||
|
||||
@@ -7,10 +7,9 @@ import {
|
||||
HttpOptions,
|
||||
JsonSchema,
|
||||
LanguageModelSchema,
|
||||
type LanguageModel,
|
||||
ProviderOptions,
|
||||
} from "./options.js"
|
||||
import { ProviderID } from "./ids.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
@@ -54,10 +53,14 @@ 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"),
|
||||
@@ -77,7 +80,6 @@ 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,
|
||||
@@ -188,40 +190,9 @@ export const ReasoningPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
/** A provider-generated context checkpoint, distinct from visible assistant text. */
|
||||
type CompactionContent =
|
||||
| { readonly encrypted: string; readonly text?: never }
|
||||
| { readonly text: string | null; readonly encrypted?: never }
|
||||
|
||||
const compactionPartSchema = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
provider: ProviderID,
|
||||
id: Schema.optional(Schema.String),
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
/** Null means the provider failed to produce a summary; prior history must be retained. */
|
||||
text: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
.pipe(
|
||||
Schema.refine(
|
||||
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
|
||||
{ message: "Compaction requires either encrypted content or a summary" },
|
||||
),
|
||||
)
|
||||
.annotate({ identifier: "LLM.Content.Compaction" })
|
||||
export type CompactionPart = typeof compactionPartSchema.Type
|
||||
export const CompactionPart = Object.assign(compactionPartSchema, {
|
||||
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
|
||||
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
|
||||
})
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
MediaPart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
ReasoningPart,
|
||||
CompactionPart,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
@@ -229,7 +200,6 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
role: MessageRole,
|
||||
content: Schema.Array(ContentPart),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -307,7 +277,7 @@ export namespace ToolChoice {
|
||||
}
|
||||
}
|
||||
|
||||
const requestSchema = Schema.Struct({
|
||||
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
id: Schema.optional(Schema.String),
|
||||
model: LanguageModelSchema,
|
||||
system: Schema.Array(SystemPart),
|
||||
@@ -321,26 +291,12 @@ const requestSchema = Schema.Struct({
|
||||
// Stable cache affinity for protocols that support provider-managed prompt caching.
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
|
||||
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
|
||||
requestSchema.fields,
|
||||
) {
|
||||
declare readonly model: Model
|
||||
|
||||
// Preserve model inference instead of inheriting the schema's erased constructor signature.
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(input: LLMRequest.Input<Model>) {
|
||||
super(input)
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
|
||||
export namespace LLMRequest {
|
||||
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
|
||||
readonly model: Model
|
||||
}
|
||||
export type Input = ConstructorParameters<typeof LLMRequest>[0]
|
||||
|
||||
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
|
||||
export const input = (request: LLMRequest): Input => ({
|
||||
id: request.id,
|
||||
model: request.model,
|
||||
system: request.system,
|
||||
@@ -355,16 +311,7 @@ export namespace LLMRequest {
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest,
|
||||
patch: Partial<Input<Model>> & { readonly model: Model },
|
||||
): LLMRequest<Model>
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest<Model>,
|
||||
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
|
||||
): LLMRequest<Model>
|
||||
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
|
||||
export function update(request: LLMRequest, patch: Partial<Input>) {
|
||||
export const update = (request: LLMRequest, patch: Partial<Input>) => {
|
||||
if (Object.keys(patch).length === 0) return request
|
||||
return new LLMRequest({
|
||||
...input(request),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import type { AnyRoute } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -173,18 +173,15 @@ export namespace LanguageModelCompatibility {
|
||||
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
|
||||
}
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly route: AnyRoute
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
|
||||
constructor(input: LanguageModel.ConstructorInput<Compact>) {
|
||||
constructor(input: LanguageModel.ConstructorInput) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
@@ -192,11 +189,8 @@ export class LanguageModel<
|
||||
this.compatibility = input.compatibility
|
||||
}
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
|
||||
return new LanguageModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
@@ -206,9 +200,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
|
||||
return {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
@@ -218,41 +210,25 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input>,
|
||||
): LanguageModel<Options>
|
||||
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
|
||||
if (Object.keys(patch).length === 0) return model
|
||||
return LanguageModel.make<Options>({
|
||||
...LanguageModel.input(model),
|
||||
...patch,
|
||||
route: patch.route ?? model.route,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
export type ConstructorInput = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly route: AnyRoute
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
readonly defaults?: LanguageModelDefaults.Input
|
||||
@@ -291,7 +267,7 @@ export const CachePolicyObject = Schema.Struct({
|
||||
Schema.Union([
|
||||
Schema.Literal("latest-user-message"),
|
||||
Schema.Literal("latest-assistant"),
|
||||
Schema.Struct({ tail: Schema.Natural }),
|
||||
Schema.Struct({ tail: Schema.Number }),
|
||||
]),
|
||||
),
|
||||
ttlSeconds: Schema.optional(Schema.Number),
|
||||
|
||||
+11
-25
@@ -4,7 +4,6 @@ import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -100,6 +99,8 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
|
||||
|
||||
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
@@ -112,41 +113,26 @@ const make = (options: LayerOptions) =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const take = (request: LLMRequest) =>
|
||||
Effect.suspend(() => {
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
const gate = activeGate
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
if (!gate) return Effect.succeed(response)
|
||||
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -56,7 +56,6 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
|
||||
role: message.role,
|
||||
content,
|
||||
metadata: message.metadata,
|
||||
providerMetadata: message.providerMetadata,
|
||||
native: message.native,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,38 +126,6 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const fixture of [
|
||||
{ name: "default", cache: undefined, control: { type: "ephemeral" } },
|
||||
{ name: "auto", cache: "auto", control: { type: "ephemeral" } },
|
||||
{
|
||||
name: "explicit one-hour",
|
||||
cache: { tools: true, system: true, messages: { tail: 1 }, ttlSeconds: 3600 },
|
||||
control: { type: "ephemeral", ttl: "1h" },
|
||||
},
|
||||
{ name: "disabled", cache: "none", control: undefined },
|
||||
] as const) {
|
||||
it.effect(`Bedrock Messages respects ${fixture.name} caching`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("anthropic.claude-opus-4-6-v1"),
|
||||
system: "Stable instructions",
|
||||
tools: [
|
||||
{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } },
|
||||
],
|
||||
prompt: "hello",
|
||||
cache: fixture.cache,
|
||||
}),
|
||||
)
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "lookup", cache_control: fixture.control }],
|
||||
system: [{ type: "text", text: "Stable instructions", cache_control: fixture.control }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hello", cache_control: fixture.control }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route/client.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
import { runtimeLayer } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
|
||||
const calls: string[] = []
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
calls.push(path)
|
||||
const body = await request.json()
|
||||
expect(request.headers.get("authorization")).toBe("Bearer fixture")
|
||||
if (path === "/v1/responses/compact") {
|
||||
expect(body.stream).toBeUndefined()
|
||||
return Response.json({
|
||||
object: "response.compaction",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
|
||||
})
|
||||
}
|
||||
expect(body.input[0]).toEqual(checkpoint)
|
||||
expect(body.stream).toBe(true)
|
||||
if (calls.length === 2)
|
||||
return new Response(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
|
||||
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
|
||||
const output = sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_2" } },
|
||||
)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(37)))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => server.stop(true)),
|
||||
)
|
||||
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
|
||||
"fixture",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "original",
|
||||
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const messages = [...compacted.messages, Message.user("Look up the answer")]
|
||||
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
const call = first.toolCalls[0]!
|
||||
const last = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(last.text).toBe("The answer is 42.")
|
||||
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
|
||||
}),
|
||||
)
|
||||
@@ -1,64 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CompactionPart, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
|
||||
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
|
||||
import { OpenAI, Anthropic } from "../src/providers.js"
|
||||
|
||||
test("runtime capability checks follow model and route updates", () => {
|
||||
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
|
||||
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
|
||||
const request = LLM.request({ model: supported, prompt: "hello" })
|
||||
expect(LLMClient.canCompact(request)).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
|
||||
).toBe(false)
|
||||
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
|
||||
})
|
||||
|
||||
test("compaction survives event assembly and message serialization without becoming text", () => {
|
||||
const part = CompactionPart.make({
|
||||
provider: ProviderID.make("openai"),
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "before" }),
|
||||
LLMEvent.textDelta({ id: "before", text: "Before" }),
|
||||
LLMEvent.textEnd({ id: "before" }),
|
||||
part,
|
||||
LLMEvent.textStart({ id: "after" }),
|
||||
LLMEvent.textDelta({ id: "after", text: "After" }),
|
||||
LLMEvent.textEnd({ id: "after" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])!
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
|
||||
expect(response.text).toBe("BeforeAfter")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
|
||||
})
|
||||
|
||||
test("compaction requires exactly one typed representation", () => {
|
||||
const provider = ProviderID.make("anthropic")
|
||||
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
|
||||
const decode = Schema.decodeUnknownSync(CompactionPart)
|
||||
expect(() => decode({ type: "compaction", provider })).toThrow()
|
||||
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
|
||||
})
|
||||
|
||||
test("tagged content and event guards accept both checkpoint representations", () => {
|
||||
for (const part of [
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
|
||||
]) {
|
||||
expect(LLMEvent.is.compaction(part)).toBe(true)
|
||||
expect(LLMEvent.guards.compaction(part)).toBe(true)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Message.assistant(part)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
|
||||
}
|
||||
})
|
||||
@@ -1,154 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionPart,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
OpenAI,
|
||||
Azure,
|
||||
XAI,
|
||||
Anthropic,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAICompatibleResponses,
|
||||
} from "../../src/providers.js"
|
||||
|
||||
const openai = OpenAI.configure({
|
||||
apiKey: "test",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
}).responses("gpt-5.3-codex")
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure().responses("fixture"),
|
||||
Azure.configure({ resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
OpenAI.model("fixture", {}),
|
||||
Azure.responsesModel("fixture", { resourceName: "test" }),
|
||||
XAI.model("fixture", {}),
|
||||
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
LanguageModel.make(LanguageModel.input(openai)),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
|
||||
}
|
||||
|
||||
const unsupported = {
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
|
||||
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
converse: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
bedrockMessages: LLM.request({ model: AmazonBedrock.configure().messages("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Anthropic has no standalone compact endpoint.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.openaiChat)
|
||||
// @ts-expect-error Azure Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.azureChat)
|
||||
// @ts-expect-error xAI Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.xaiChat)
|
||||
// @ts-expect-error Converse has no standalone compact endpoint.
|
||||
LLMClient.compact(unsupported.converse)
|
||||
// @ts-expect-error Bedrock Messages has no standalone compact endpoint.
|
||||
LLMClient.compact(unsupported.bedrockMessages)
|
||||
// @ts-expect-error Mantle does not inherit the OpenAI compact endpoint.
|
||||
LLMClient.compact(unsupported.mantle)
|
||||
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
LLMClient.Service.use((client) => {
|
||||
// @ts-expect-error The service enforces the same capability as the convenience function.
|
||||
return client.compact(unsupported.anthropic)
|
||||
})
|
||||
|
||||
const request = LLM.request({ model: openai, prompt: "hello" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
|
||||
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
|
||||
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
|
||||
LLMClient.compact(switched)
|
||||
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
|
||||
LLMClient.compact(
|
||||
// @ts-expect-error Replacing the route also replaces compaction capability.
|
||||
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
|
||||
)
|
||||
|
||||
declare const dynamicModel: LanguageModel
|
||||
declare const dynamicPatch: Partial<LLMRequest.Input>
|
||||
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
|
||||
// @ts-expect-error A dynamically selected model must be narrowed first.
|
||||
LLMClient.compact(dynamicRequest)
|
||||
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
|
||||
// @ts-expect-error An optional model override cannot retain the old capability statically.
|
||||
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
|
||||
|
||||
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
|
||||
const provider = ProviderID.make("anthropic")
|
||||
CompactionPart.make({ provider, text: "summary" })
|
||||
CompactionPart.make({ provider, text: null })
|
||||
// @ts-expect-error A checkpoint must have a representation.
|
||||
CompactionPart.make({ provider })
|
||||
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
|
||||
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
|
||||
// @ts-expect-error A failed summary cannot also carry encrypted content.
|
||||
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
|
||||
// @ts-expect-error The canonical message type also enforces the invariant.
|
||||
Message.assistant({ type: "compaction", provider })
|
||||
if (checkpoint.encrypted !== undefined) {
|
||||
checkpoint.encrypted satisfies string
|
||||
checkpoint.text satisfies undefined
|
||||
}
|
||||
if (checkpoint.text !== undefined) {
|
||||
checkpoint.text satisfies string | null
|
||||
checkpoint.encrypted satisfies undefined
|
||||
}
|
||||
checkpoint.encrypted
|
||||
// @ts-expect-error Compaction parts do not contain a generic provider payload.
|
||||
checkpoint.value
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
|
||||
Effect.map((result) => {
|
||||
result.messages
|
||||
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
|
||||
result.message
|
||||
}),
|
||||
)
|
||||
LLM.request({
|
||||
model: openai,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A token threshold is numeric.
|
||||
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
|
||||
},
|
||||
})
|
||||
for (const model of [
|
||||
Anthropic.configure().model("claude-opus-4-6"),
|
||||
AmazonBedrock.configure().messages("anthropic.claude-opus-4-6-v1"),
|
||||
]) {
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A pause setting is boolean.
|
||||
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "empty iterations fall back to top-level usage",
|
||||
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
|
||||
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "compaction-only usage has no post-compaction context size",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
|
||||
},
|
||||
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "partially reported iterations preserve known totals",
|
||||
usage: {
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
|
||||
{ type: "message", output_tokens: 3 },
|
||||
],
|
||||
},
|
||||
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "missing counters remain unknown rather than zero",
|
||||
usage: { iterations: [{ type: "message" }] },
|
||||
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
|
||||
},
|
||||
]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: fixture.usage } },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
).effect(fixture.name, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
prompt: "hello",
|
||||
}),
|
||||
)
|
||||
expect(result.usage).toMatchObject(fixture.expected)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
|
||||
]) {
|
||||
for (const summary of ["Summary of the conversation", null]) {
|
||||
const block = { type: "compaction", content: summary }
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
|
||||
if (body.messages.length === 1) {
|
||||
expect(body.context_management.edits).toEqual([
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pause_after_compaction: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (body.messages.length > 1) {
|
||||
expect(body.messages[1].content).toEqual([block])
|
||||
expect(body.context_management).toBeUndefined()
|
||||
}
|
||||
return respond(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "compaction" },
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 5,
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
|
||||
{ type: "message", input_tokens: 1000, output_tokens: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect(
|
||||
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
http: { headers: { "anthropic-beta": "existing-beta" } },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pauseAfterCompaction: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
|
||||
expect(first.text).toBe("")
|
||||
expect(first.usage?.inputTokens).toBe(51010)
|
||||
expect(first.usage?.outputTokens).toBe(1005)
|
||||
expect(first.usage?.totalTokens).toBe(52015)
|
||||
expect(first.usage?.contextTokens).toBe(1000)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {},
|
||||
messages: [...request.messages, message, Message.user("continue")],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const events of [
|
||||
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
|
||||
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
|
||||
[
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "message_stop" },
|
||||
],
|
||||
]) {
|
||||
testEffect(fixedResponse(sseEvents(...events))).effect(
|
||||
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
|
||||
const codec = new EventStreamCodec(toUtf8, fromUtf8)
|
||||
const frame = (event: object) =>
|
||||
codec.encode({
|
||||
headers: { ":message-type": { type: "string", value: "event" }, ":event-type": { type: "string", value: "chunk" } },
|
||||
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
|
||||
})
|
||||
const response = Buffer.concat(
|
||||
[
|
||||
{ type: "message_start", message: { usage: { input_tokens: 60000 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "Summary" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 10 } },
|
||||
{ type: "message_stop" },
|
||||
].map(frame),
|
||||
)
|
||||
|
||||
for (const auth of [
|
||||
{ apiKey: "test" },
|
||||
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.url).toBe(
|
||||
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
|
||||
)
|
||||
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
|
||||
const body = JSON.parse(text)
|
||||
expect(body.model).toBeUndefined()
|
||||
expect(body.stream).toBeUndefined()
|
||||
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
|
||||
expect(body.anthropic_beta).toEqual(["compact-2026-01-12"])
|
||||
expect(body.context_management.edits).toEqual([{ type: "compact_20260112" }])
|
||||
if (body.messages.length > 1)
|
||||
expect(body.messages[1].content[0]).toEqual({ type: "compaction", content: "Summary" })
|
||||
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
|
||||
}),
|
||||
),
|
||||
).effect(`Bedrock Messages compaction round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
|
||||
Effect.gen(function* () {
|
||||
const model = AmazonBedrock.configure({ ...auth, region: "us-west-2" }).messages("anthropic.claude-opus-4-6-v1:0")
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: { edits: [{ type: "compact_20260112" }] } },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.text).toBe("Hello")
|
||||
expect(first.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1429,20 +1429,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects image media that is not valid base64", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "https://example.test/image.png" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1566,37 +1552,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects remote media URLs in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.test/report.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "InvalidRequest" } })
|
||||
expect(error.message).toContain("Bedrock Converse media data must be valid base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, compileRequest } from "../../src/route/client.js"
|
||||
import { AmazonBedrock } from "../../src/providers/index.js"
|
||||
import { it, testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
|
||||
const codec = new EventStreamCodec(toUtf8, fromUtf8)
|
||||
const response = Buffer.concat(
|
||||
[
|
||||
{ type: "message_start", message: { usage: { input_tokens: 10 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
].map((event) =>
|
||||
codec.encode({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "event" },
|
||||
":event-type": { type: "string", value: "chunk" },
|
||||
},
|
||||
body: new TextEncoder().encode(JSON.stringify({ bytes: Buffer.from(JSON.stringify(event)).toString("base64") })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
for (const auth of [
|
||||
{ apiKey: "test" },
|
||||
{ credentials: { accessKeyId: "test", secretAccessKey: "test", region: "us-west-2" } },
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.url).toBe(
|
||||
"https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-opus-4-6-v1%3A0/invoke-with-response-stream",
|
||||
)
|
||||
expect(request.headers.authorization).toStartWith(auth.apiKey ? "Bearer test" : "AWS4-HMAC-SHA256")
|
||||
const body = JSON.parse(text)
|
||||
expect(body.model).toBeUndefined()
|
||||
expect(body.stream).toBeUndefined()
|
||||
expect(body.anthropic_version).toBe("bedrock-2023-05-31")
|
||||
expect(body.anthropic_beta).toEqual(["existing-beta"])
|
||||
if (body.messages.length > 1) expect(body.messages[1].content).toEqual([{ type: "text", text: "Hello" }])
|
||||
return respond(response, { headers: { "content-type": "application/vnd.amazon.eventstream" } })
|
||||
}),
|
||||
),
|
||||
).effect(`Bedrock Messages text round trip with ${auth.apiKey ? "bearer" : "SigV4"} authentication`, () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = AmazonBedrock.configure({ ...auth, region: "us-west-2" })
|
||||
expect(provider.model("fixture").route.id).toBe("bedrock-converse")
|
||||
const request = LLM.request({
|
||||
model: provider.messages("anthropic.claude-opus-4-6-v1:0"),
|
||||
prompt: "hello",
|
||||
http: { headers: { "anthropic-beta": "existing-beta, existing-beta" } },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.text).toBe("Hello")
|
||||
expect(first.usage?.totalTokens).toBe(12)
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [...request.messages, first.message, Message.user("continue")],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
codec.encode({
|
||||
headers: {
|
||||
":message-type": { type: "string", value: "exception" },
|
||||
":exception-type": { type: "string", value: "throttlingException" },
|
||||
},
|
||||
body: new TextEncoder().encode(JSON.stringify({ message: "Too many requests", trace: "keep-original" })),
|
||||
}),
|
||||
),
|
||||
).effect("Bedrock Messages retains the original exception frame", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
|
||||
prompt: "hello",
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason.body).toContain("keep-original")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const mediaType of ["image/png", "application/pdf"]) {
|
||||
for (const data of ["https://example.com/media", "invalid base64!"]) {
|
||||
for (const role of ["user", "tool"] as const) {
|
||||
it.effect(`rejects ${role} ${mediaType} with ${data}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
|
||||
messages:
|
||||
role === "user"
|
||||
? [Message.user({ type: "media", mediaType, data })]
|
||||
: [
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
result: { type: "content", value: [{ type: "file", mime: mediaType, uri: data }] },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("Bedrock Messages")
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it.effect("accepts inline image and document sources", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,AQID" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "data:application/pdf;base64,AQID" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[0].content.map((block: { source: { type: string } }) => block.source.type)).toEqual([
|
||||
"base64",
|
||||
"base64",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects Anthropic file IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
|
||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "", metadata: { file_id: "file_1" } })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toContain("file-ID")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported image formats", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ apiKey: "test" }).messages("claude"),
|
||||
messages: [Message.user({ type: "media", mediaType: "image/svg+xml", data: "AQID" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("JPEG, PNG, WebP, or GIF")
|
||||
}),
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, WebSocketTransport } from "../../src/route.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
|
||||
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
|
||||
"WebSocket responses preserve compaction options and replay state",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
|
||||
const sent: unknown[] = []
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(message)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.stream).toBeUndefined()
|
||||
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
|
||||
sent.push(body)
|
||||
}),
|
||||
messages: Stream.fromIterable(
|
||||
[
|
||||
{ type: "response.created", response: { id: "resp_ws" } },
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
|
||||
].map((event) => JSON.stringify(event)),
|
||||
),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket })
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
|
||||
{ webSocket },
|
||||
)
|
||||
expect(sent).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const history = [
|
||||
Message.user("Remember the project codename COPPER-ORBIT-42."),
|
||||
Message.assistant(
|
||||
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
|
||||
),
|
||||
]
|
||||
|
||||
for (const provider of [
|
||||
{
|
||||
id: "openai",
|
||||
key: "OPENAI_API_KEY",
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
key: "XAI_API_KEY",
|
||||
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
|
||||
},
|
||||
]) {
|
||||
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
|
||||
"compacts and continues with the provider checkpoint",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...compacted.messages,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
}
|
||||
|
||||
recordedTests({
|
||||
prefix: "anthropic-compaction",
|
||||
provider: "anthropic",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
|
||||
}).effect(
|
||||
"automatically compacts and continues after a pause",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user(
|
||||
"Remember the project codename COPPER-ORBIT-42. " +
|
||||
"The implementation and tests were reviewed. ".repeat(10000),
|
||||
),
|
||||
],
|
||||
generation: { maxTokens: 4096 },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const response = sseEvents(
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
|
||||
},
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.store).toBe(false)
|
||||
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
|
||||
return respond(response, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
expect(first.text).toBe("")
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
|
||||
)
|
||||
const rejected = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
providerOptions: {},
|
||||
messages: [message],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(rejected.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
|
||||
"recovers compaction from the terminal output when item completion is absent",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
)
|
||||
expect(result.message.content).toHaveLength(1)
|
||||
expect(result.message.content[0]?.type).toBe("compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
|
||||
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("cmp_bad")
|
||||
}),
|
||||
)
|
||||
|
||||
const textItem = {
|
||||
type: "message",
|
||||
id: "msg_after",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "After checkpoint" }],
|
||||
}
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
|
||||
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
|
||||
{ type: "response.output_item.added", output_index: 1, item: textItem },
|
||||
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
|
||||
{ type: "response.output_item.done", output_index: 1, item: textItem },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
|
||||
),
|
||||
),
|
||||
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
if (completed) {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
|
||||
return
|
||||
}
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Cannot recover a compaction checkpoint")
|
||||
expect(error.reason.body).toContain("response.completed")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
|
||||
}),
|
||||
),
|
||||
).effect("rejects terminal checkpoints missing an id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("missing its id")
|
||||
}),
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* OpenResponses.lowerConversation(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
system: "Keep the context",
|
||||
messages: [Message.user("hello"), Message.assistant("hi")],
|
||||
generation: { maxTokens: 100, temperature: 0.5 },
|
||||
providerOptions: { store: false },
|
||||
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
|
||||
}),
|
||||
{ id: "open-responses", name: "Open Responses" },
|
||||
)
|
||||
expect(body).toEqual({
|
||||
model: "fixture",
|
||||
instructions: "Keep the context",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,447 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, Route } from "../../src/route/client.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { OpenAI, Azure, XAI, Anthropic, AmazonBedrockMantle } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const retained = {
|
||||
type: "message",
|
||||
role: "user",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
content: [{ type: "input_text", text: "retained" }],
|
||||
}
|
||||
const output = [retained, checkpoint]
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers["x-deployment"]).toBe("fixture")
|
||||
expect(request.headers["x-override"]).toBe("request")
|
||||
expect(request.headers["x-default"]).toBe("configured")
|
||||
expect(request.headers.authorization).toBe("Bearer test")
|
||||
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
if (new URL(request.url).pathname.endsWith("/compact")) {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "overlaid",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output }))
|
||||
}
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: string[] = []
|
||||
const middleware: string[] = []
|
||||
const route = Route.make({
|
||||
id: "compaction-headers",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
|
||||
baseURL: "https://example.com",
|
||||
query: { "api-version": "fixture" },
|
||||
}),
|
||||
auth: Auth.bearer("test"),
|
||||
headers: ({ request }) => {
|
||||
expect(request.providerOptions?.store).toBe(false)
|
||||
headers.push(String(request.model.id))
|
||||
return { "x-deployment": "fixture", "x-override": "route" }
|
||||
},
|
||||
defaults: {
|
||||
headers: { "x-default": "configured", "x-override": "configured" },
|
||||
providerOptions: { store: false },
|
||||
http: { body: { instructions: "default instructions" } },
|
||||
},
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
prompt: "hello",
|
||||
system: "system instructions",
|
||||
http: {
|
||||
headers: { "x-override": "request" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
model: "overlaid",
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const options: Parameters<typeof LLMClient.compact>[1] = {
|
||||
http: (request, next) => {
|
||||
middleware.push(new URL(request.url).pathname)
|
||||
return next(request)
|
||||
},
|
||||
}
|
||||
yield* LLMClient.generate(request, options)
|
||||
yield* LLMClient.compact(request, options)
|
||||
expect(headers).toEqual(["fixture", "fixture"])
|
||||
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const item = {
|
||||
type: model.provider === "xai" ? "x_search_call" : "computer_call",
|
||||
id: "hosted_1",
|
||||
status: "completed",
|
||||
}
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
|
||||
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Keep the context",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
|
||||
}),
|
||||
],
|
||||
})
|
||||
for (const candidate of [
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
|
||||
],
|
||||
}),
|
||||
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
const response = yield* LLMClient.compact(candidate)
|
||||
expect(response.messages[0]?.content[0]?.type).toBe("compaction")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const retainedItems = [
|
||||
retained,
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
content: [
|
||||
{ type: "output_text", text: "First" },
|
||||
{ type: "output_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Thinking" },
|
||||
{ type: "summary_text", text: "More thinking" },
|
||||
],
|
||||
encrypted_content: "reasoning-state",
|
||||
},
|
||||
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_media",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
|
||||
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
|
||||
],
|
||||
},
|
||||
checkpoint,
|
||||
]
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
...[undefined, "custom"].map((providerMetadataKey) =>
|
||||
Route.make({
|
||||
id: providerMetadataKey ?? "default-metadata",
|
||||
provider: "openai",
|
||||
providerMetadataKey,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
}).model({ id: "fixture" }),
|
||||
),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
|
||||
expect(JSON.parse(text).input).toEqual(retainedItems)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.messages.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(compacted.messages[1]?.content).toEqual([
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
])
|
||||
expect(compacted.messages[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
|
||||
expect(compacted.messages[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "fixture",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
service_tier: overlay?.service_tier ?? "flex",
|
||||
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
prompt: "hello",
|
||||
promptCacheKey: "affinity",
|
||||
providerOptions: { serviceTier: "flex" },
|
||||
generation: { maxTokens: 100 },
|
||||
http: {
|
||||
body: {
|
||||
stream: true,
|
||||
store: false,
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
...overlay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of [
|
||||
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [] },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "both.pdf",
|
||||
file_url: "https://example.com/report.pdf",
|
||||
file_data: "data:application/pdf;base64,cGRm",
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
|
||||
`rejects unsupported compact output: ${JSON.stringify(item)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain(JSON.stringify(item))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const images = [undefined, "low", "high", "auto"].map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
}))
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output: [{ type: "message", role: "user", content: images }, checkpoint],
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(text).input[0].content).toEqual(images)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLMRequest.update(
|
||||
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
|
||||
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
|
||||
)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("LLMClient.compact")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
|
||||
model.provider === "azure" ? "test" : "Bearer test",
|
||||
)
|
||||
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
|
||||
expect(body).toEqual({
|
||||
model: model.id,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
|
||||
instructions: "system",
|
||||
})
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output,
|
||||
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}
|
||||
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
|
||||
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.usage?.totalTokens).toBe(1010)
|
||||
expect(compacted.messages.map((message) => message.role)).toEqual(["user", "assistant"])
|
||||
expect(compacted.messages[0]?.content).toEqual([{ type: "text", text: "retained" }])
|
||||
expect(compacted.messages[1]?.content).toEqual([
|
||||
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
|
||||
])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.messages))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
AmazonBedrockMantle.configure({ apiKey: "test" }).responses("model"),
|
||||
]) {
|
||||
testEffect(fixedResponse("must not execute")).effect(
|
||||
`${model.route.id} does not inherit an unsupported compact endpoint`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// @ts-expect-error Untyped callers must still receive the runtime capability error.
|
||||
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
|
||||
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("original payload")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [undefined, "low", "high", "auto"]
|
||||
const message = Message.user(
|
||||
details.map((detail) => ({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata:
|
||||
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
|
||||
})),
|
||||
)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input[0].content).toEqual(
|
||||
details.map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
detail,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects malformed image detail instead of silently discarding it", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata: { openai: { detail: 42 } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
QuotaExceededError,
|
||||
RateLimitError,
|
||||
RouteID,
|
||||
ToolResultValue,
|
||||
TransportError,
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
@@ -84,37 +83,6 @@ 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
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
RateLimitError,
|
||||
} from "../src/index.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
@@ -79,54 +66,6 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
yield* client.push(TestLLM.stop(), new CompactionResponse({ messages: [] }))
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
const compacted = new CompactionResponse({
|
||||
messages: [
|
||||
Message.user("retained input"),
|
||||
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
|
||||
Message.user("retained tail"),
|
||||
],
|
||||
})
|
||||
yield* client.push(compacted, TestLLM.text("continued", "answer"))
|
||||
const operation = LLMClient.compact(request)
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
const gate = yield* client.gate()
|
||||
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* client.wait(1)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect(yield* Fiber.join(fiber)).toBe(compacted)
|
||||
const next = LLMRequest.update(request, { messages: compacted.messages })
|
||||
expect((yield* LLMClient.generate(next)).text).toBe("continued")
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(next)
|
||||
return compacted
|
||||
})
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
yield* client.always(compacted)
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
expect(yield* client.requests()).toEqual([request, next, next, next])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
|
||||
@@ -442,33 +442,6 @@ 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([
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
|
||||
const component = await mount(`opencode-composer-flow--${draft}`)
|
||||
const input = component.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const text = await input.textContent()
|
||||
|
||||
for (let count = 0; count < 2; count++) {
|
||||
await input.press("ControlOrMeta+a")
|
||||
expect(
|
||||
await input.evaluate((editor) => {
|
||||
const selection = window.getSelection()
|
||||
return {
|
||||
text: selection?.toString(),
|
||||
inside: editor.contains(selection?.anchorNode ?? null) && editor.contains(selection?.focusNode ?? null),
|
||||
}
|
||||
}),
|
||||
).toEqual({ text, inside: true })
|
||||
}
|
||||
|
||||
await page.keyboard.type("Replacement draft")
|
||||
await expect(input).toHaveText("Replacement draft")
|
||||
await expect(component.getByRole("status")).toHaveText("Ready")
|
||||
if (draft === "mixed-attachments") {
|
||||
await expect(component.getByAltText("layout.png")).toBeVisible()
|
||||
await expect(component.getByText("Keep the normal flow flat", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
|
||||
@@ -8,30 +8,6 @@ story.beforeEach(async ({ mount }) => {
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("spaces the first mobile message without changing desktop spacing", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
mountTimelineVirtualizer({ count: 1, rowHeight: 60, immediate: true })
|
||||
}, fixture)
|
||||
const root = page.getByTestId("timeline-virtualizer-fixture")
|
||||
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
|
||||
const content = root.locator("[data-timeline-virtual-content]")
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
const gap = () =>
|
||||
root.locator('[data-timeline-key="user-message:message-0"]').evaluate((element) => {
|
||||
const viewport = element.closest("[data-scrollable]")!
|
||||
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
|
||||
})
|
||||
await expect.poll(gap).toBe(16)
|
||||
await root.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect.poll(gap).toBe(16)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect.poll(gap).toBe(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect.poll(gap).toBe(16)
|
||||
})
|
||||
|
||||
story("bounds the cheap suffix and reveals only ready measured rows", async ({ page }) => {
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountTimelineVirtualizer } = await import(fixture)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
@@ -65,64 +65,11 @@ for (const lines of [6000, 25000]) {
|
||||
await expect.poll(async () => (await input.innerText()) === text).toBe(true)
|
||||
expect(await events.evaluate((events) => events.count)).toBe(1)
|
||||
await expect(input).toBeFocused()
|
||||
await expectCaretVisible(input)
|
||||
const scroll = page.locator('[data-component="composer-scroll"]')
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveCSS("scrollbar-width", "none")
|
||||
await expect(scroll.locator(".scroll-view__thumb")).toBeVisible()
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(async () => (await input.innerText()) === text + "!").toBe(true)
|
||||
await expectCaretVisible(input)
|
||||
const thumb = await scroll.locator(".scroll-view__thumb").boundingBox()
|
||||
const bounds = await scroll.boundingBox()
|
||||
if (!thumb || !bounds) throw new Error("Missing composer scrollbar bounds")
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, thumb.y + thumb.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(thumb.x + thumb.width / 2, bounds.y + 8 + thumb.height / 2)
|
||||
await page.mouse.up()
|
||||
await expect(scroll.locator(".scroll-view__viewport")).toHaveJSProperty("scrollTop", 0)
|
||||
await expect(input).toBeFocused()
|
||||
await page.keyboard.press("ControlOrMeta+Home")
|
||||
await page.keyboard.press("ControlOrMeta+End")
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCaretVisible(input: Locator) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
input.evaluate((element) => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.isCollapsed || !selection.rangeCount || !element.contains(selection.anchorNode)) return false
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
const viewport = (element.closest("[data-scrollable]") ?? element).getBoundingClientRect()
|
||||
return caret.height > 0 && caret.top >= viewport.top - 1 && caret.bottom <= viewport.bottom + 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
for (const width of [390, 1280]) {
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`reveals a multiline paste in the middle at ${width}px in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 800 })
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
const input = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
const suffix = "\nExisting trailing content".repeat(100)
|
||||
await input.fill("Before " + suffix)
|
||||
await input.press("ControlOrMeta+Home")
|
||||
await input.press("ArrowRight")
|
||||
const text = "Pasted line /tmp/example.ts 123 \u0645\u0631\u062d\u0628\u0627\n".repeat(100) + "End of paste"
|
||||
await page.evaluate((text) => navigator.clipboard.writeText(text), text)
|
||||
await page.keyboard.press("ControlOrMeta+V")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
await page.keyboard.type("!")
|
||||
await expect.poll(() => input.innerText()).toBe("B" + text + "!efore " + suffix)
|
||||
await expectCaretVisible(input)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const text of [
|
||||
"single line <b> &",
|
||||
"first\nsecond",
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile files browse, search, switch, and close shared file tabs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: (path) =>
|
||||
path
|
||||
? []
|
||||
: ["first.ts", "second.ts"].map((name) => ({
|
||||
name,
|
||||
path: name,
|
||||
absolute: `${fixture.directory}/${name}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})),
|
||||
fileContent: (path) => `contents:${path}`,
|
||||
findFiles: ({ query }) => ["first.ts", "second.ts"].filter((path) => path.includes(query)),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("button", { name: "first.ts", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expect(files.locator('[data-slot="session-mobile-files-header"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
files.getByRole("tablist", { name: "Open files", exact: true }).evaluate((element) => {
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
const separator = getComputedStyle(element, "::before")
|
||||
return {
|
||||
height: separator.height,
|
||||
fullWidth: parseFloat(separator.width) === header.clientWidth,
|
||||
start: separator.insetInlineStart,
|
||||
bottom: separator.bottom,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({ height: "1px", fullWidth: true, start: "0px", bottom: "0px" })
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files.locator('[data-column-number="1"]').click()
|
||||
const editor = files.locator('[data-component="line-comment-v2"][data-variant="editor"]')
|
||||
await expect(editor.getByRole("textbox")).toBeVisible()
|
||||
for (const width of [390, 700]) {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const comment = await editor.boundingBox()
|
||||
if (!panel || !comment) return false
|
||||
return Math.abs(comment.x - panel.x - 12) < 2 && Math.abs(comment.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await editor.getByRole("textbox").fill("Full-width file comment")
|
||||
await editor.getByRole("button", { name: "Comment", exact: true }).click()
|
||||
const comment = files.locator('[data-component="line-comment-v2"][data-variant="display"]')
|
||||
await expect(comment).toContainText("Full-width file comment")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const panel = await files.boundingBox()
|
||||
const card = await comment.boundingBox()
|
||||
if (!panel || !card) return false
|
||||
return Math.abs(card.x - panel.x - 12) < 2 && Math.abs(card.width - panel.width + 24) < 2
|
||||
})
|
||||
.toBe(true)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
await files.getByRole("combobox", { name: "Filter files", exact: true }).fill("second")
|
||||
await files.getByRole("option", { name: "second.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
const openTabs = files.getByRole("tablist", { name: "Open files", exact: true })
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["first.ts", "second.ts"])
|
||||
await expect
|
||||
.poll(() =>
|
||||
openTabs.getByRole("tab", { name: "second.ts", exact: true }).evaluate((element) => {
|
||||
const tab = element.closest('[data-slot="tabs-v2-trigger-wrapper"]')!
|
||||
const header = element.closest('[data-slot="session-mobile-files-header"]')!
|
||||
return Math.abs(tab.getBoundingClientRect().bottom - header.getBoundingClientRect().bottom) < 1
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
.filter({ has: page.getByRole("tab", { name: "first.ts", exact: true }) })
|
||||
.getByRole("button", { name: "Close tab", exact: true })
|
||||
.click()
|
||||
await expect(openTabs.getByRole("tab")).toHaveText(["second.ts"])
|
||||
await expect(files.getByText("contents:second.ts", { exact: true })).toBeVisible()
|
||||
await files.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(files.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(openTabs.getByRole("tab")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("opening changed files selects the requested tab before file loading completes", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const loading = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
fileContent: async (path) => {
|
||||
if (path === "second.ts") await loading.promise
|
||||
return `contents:${path}`
|
||||
},
|
||||
vcsDiff: ["first.ts", "second.ts"].map((file) => ({
|
||||
file,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-before\n+after\n`,
|
||||
})),
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
for (const file of ["first.ts", "second.ts", "first.ts", "second.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const diff = page.locator(`[data-component="session-review"] [data-file="${file}"]`)
|
||||
const header = diff.getByRole("button", { name: file, exact: true })
|
||||
await expect(header).toBeEnabled()
|
||||
if ((await header.getAttribute("aria-expanded")) === "false") await header.click()
|
||||
await diff.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
if (file === "second.ts") loading.resolve()
|
||||
await expect(files.getByText(`contents:${file}`, { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
@@ -1,138 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile change summaries appear only for expanded diffs in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, {
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "added.ts",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch:
|
||||
"diff --git a/added.ts b/added.ts\n--- /dev/null\n+++ b/added.ts\n@@ -0,0 +1 @@\n+export const added = 1\n",
|
||||
},
|
||||
{
|
||||
file: "removed.ts",
|
||||
status: "deleted",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
patch:
|
||||
"diff --git a/removed.ts b/removed.ts\n--- a/removed.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-export const removed = 1\n",
|
||||
},
|
||||
{
|
||||
file: "modified.ts",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `diff --git a/modified.ts b/modified.ts\n--- a/modified.ts\n+++ b/modified.ts\n@@ -1 +1 @@\n-export const value = 1\n+export const value = "${"long content ".repeat(30)}"\n`,
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expect(review.getByRole("button", { name: "Expand all", exact: true })).toBeVisible()
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
|
||||
await expect(review.locator('[data-file="added.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
)
|
||||
await expect(review.locator('[data-file="modified.ts"] [data-slot="accordion-trigger"]')).toHaveCSS(
|
||||
"border-bottom-width",
|
||||
"0px",
|
||||
)
|
||||
|
||||
for (const change of [
|
||||
{ file: "added.ts", status: "Added", additions: "+1", deletions: "-0" },
|
||||
{ file: "removed.ts", status: "Removed", additions: "+0", deletions: "-1" },
|
||||
{ file: "modified.ts", status: undefined, additions: "+1", deletions: "-1" },
|
||||
]) {
|
||||
const item = review.locator(`[data-file="${change.file}"]`)
|
||||
const trigger = item.getByRole("button", { name: change.file, exact: true })
|
||||
const summary = item.locator('[data-slot="session-review-change-summary"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(trigger.locator('[data-component="diff-changes"]')).toHaveCount(0)
|
||||
await expect(trigger.locator('[data-slot="session-review-change"]')).toHaveCount(0)
|
||||
await expect(summary).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
if (change.file === "modified.ts") {
|
||||
await expect(item.locator('[data-slot="accordion-content"]')).toHaveCSS("border-bottom-width", "0px")
|
||||
await expect(trigger).not.toHaveCSS("border-bottom-width", "0px")
|
||||
}
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Open file", exact: true })).toBeVisible()
|
||||
await expect(summary.locator('[data-slot="diff-changes-additions"]')).toHaveText(change.additions)
|
||||
await expect(summary.locator('[data-slot="diff-changes-deletions"]')).toHaveText(change.deletions)
|
||||
if (change.status) await expect(summary.getByText(change.status, { exact: true })).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCount(0)
|
||||
}
|
||||
await expect(review.locator('[data-slot="session-review-view-button"]')).toHaveCount(0)
|
||||
const modified = review.locator('[data-file="modified.ts"]')
|
||||
await modified.getByRole("button", { name: "modified.ts", exact: true }).click()
|
||||
await expect(modified.locator("[data-line-number-content]")).toHaveText(["1", "1"])
|
||||
await expect(modified.locator("[data-diff]")).not.toHaveAttribute("data-disable-line-numbers")
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
await expect(review.getByRole("button", { name: "Diff options", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const wrap = settings.getByRole("switch", { name: "Wrap lines", exact: true })
|
||||
const wrapControl = settings.locator('[data-action="settings-mobile-diff-wrap"] [data-slot="switch-control"]')
|
||||
await expect(wrap).toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(false)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true)
|
||||
await modified.locator("[data-code]").evaluate((element) => {
|
||||
element.scrollLeft = 100
|
||||
})
|
||||
await expect
|
||||
.poll(() => modified.locator("[data-code]").evaluate((element) => Math.abs(element.scrollLeft)))
|
||||
.toBeGreaterThan(0)
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(wrap).not.toBeChecked()
|
||||
await wrapControl.click()
|
||||
await expect(wrap).toBeChecked()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
|
||||
.toBe(true)
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
|
||||
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
|
||||
await expect(openFile).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await openFile.boundingBox()
|
||||
const summary = await modified.locator('[data-slot="session-review-change-summary"]').boundingBox()
|
||||
if (!button || !summary) return false
|
||||
return direction === "ltr"
|
||||
? button.x > summary.x + summary.width / 2
|
||||
: button.x + button.width < summary.x + summary.width / 2
|
||||
})
|
||||
.toBe(true)
|
||||
await openFile.click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Session view", exact: true }).getByRole("tab", { name: "Files", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await expect(files.getByRole("tab", { name: "modified.ts", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
})
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const position of ["top", "bottom"] as const) {
|
||||
test(`mobile session tabs switch views and keep the terminal cached with ${position} navigation`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(position) =>
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: position } })),
|
||||
position,
|
||||
)
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: {
|
||||
id: "pty_mobile_views",
|
||||
title: "Terminal 1",
|
||||
command: "sh",
|
||||
args: [],
|
||||
cwd: fixture.directory,
|
||||
status: "running",
|
||||
pid: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_mobile_views/connect", () => undefined)
|
||||
await page.route("**/api/pty/pty_mobile_views/connect-token*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory, project: { id: fixture.project.id, directory: fixture.directory } },
|
||||
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.targetTitle)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const drawer = page.getByRole("dialog", { name: "Tabs", exact: true })
|
||||
await expect(drawer).toHaveAttribute("data-open", "")
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await drawer.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await page.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.getByRole("button", { name: "Settings", exact: true })).toBeInViewport()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-start", "16px")
|
||||
await expect(usage).toHaveCSS("padding-inline-end", "16px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("height", "40px")
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
await expect(panel.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expect(panel.locator("textarea")).toBeEditable()
|
||||
await expect(panel).toHaveCount(1)
|
||||
await panel.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await expect(message).toBeVisible()
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(panel).toHaveAttribute("inert", "")
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(page.locator('[data-slot="mobile-tabs-trigger"]')).toContainText(fixture.expected.sourceTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(picker).toBeHidden()
|
||||
await expect(page.locator("[data-session-title]")).toBeVisible()
|
||||
})
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
if (dismissal === "drag") {
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 + 1)
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
await expect(drawer, `dismissal: ${dismissal}`).toBeHidden()
|
||||
await expect(overlay).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
@@ -89,7 +89,7 @@ async function openReview(page: Page) {
|
||||
)
|
||||
await changes.click()
|
||||
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||
await expect(changes).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
|
||||
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
await expectAppVisible(review)
|
||||
|
||||
@@ -32,13 +32,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
await expect(editor.getByPlaceholder("username")).toHaveCount(0)
|
||||
const name = editor.getByPlaceholder("Localhost", { exact: true })
|
||||
const username = editor.getByPlaceholder("username")
|
||||
const password = editor.getByPlaceholder("password")
|
||||
await name.click()
|
||||
await expect(name).toBeFocused()
|
||||
await name.fill("Remote")
|
||||
await expect(name).toHaveValue("Remote")
|
||||
await username.click()
|
||||
await expect(username).toBeFocused()
|
||||
await username.fill("kit")
|
||||
await expect(username).toHaveValue("kit")
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(password).toBeFocused()
|
||||
await password.fill("secret")
|
||||
|
||||
@@ -126,10 +126,6 @@ for (const create of [false, true]) {
|
||||
json: [
|
||||
{ directory: canonical, strategy: null },
|
||||
{ directory: "/projects/existing-worktree", strategy: "git" },
|
||||
...Array.from({ length: 20 }, (_, index) => ({
|
||||
directory: `/projects/worktree-${index}`,
|
||||
strategy: "git",
|
||||
})),
|
||||
],
|
||||
headers,
|
||||
})
|
||||
@@ -165,9 +161,7 @@ for (const create of [false, true]) {
|
||||
await expect(page.getByText("Recover my worktree", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Session location unavailable", { exact: true })).toBeVisible()
|
||||
listing.resolve()
|
||||
const existing = page.getByRole("menuitem", { name: "existing-worktree", exact: true })
|
||||
await expect(existing).toBeVisible()
|
||||
await expect(existing).toHaveCSS("height", "28px")
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menu")).toHaveCount(0)
|
||||
listing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*/rename", async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const id = new URL(route.request().url()).pathname.split("/").at(-2)
|
||||
const session = sessions.find((item) => item.id === id)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (
|
||||
!session ||
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
!("title" in payload) ||
|
||||
typeof payload.title !== "string"
|
||||
)
|
||||
throw new Error("Invalid rename request")
|
||||
session.title = payload.title
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.goto("/")
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const commit of ["Enter", "blur", "click outside"]) {
|
||||
test(`saves the session heading on ${commit}`, async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed session")
|
||||
if (commit === "Enter") await input.press("Enter")
|
||||
if (commit === "blur") await input.press("Tab")
|
||||
if (commit === "click outside") await page.locator('[data-component="composer-editor"]').click()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed session" })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed session", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("cancels the session heading with Escape", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Discard this title")
|
||||
await input.press("Escape")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps the draft when saving the session heading fails", async ({ page }) => {
|
||||
await page.route("**/api/session/*/rename", (route) =>
|
||||
route.fulfill({ status: 500, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill("Retry this title")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(input).toBeEnabled()
|
||||
await expect(input).toHaveValue("Retry this title")
|
||||
await expect(
|
||||
page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("does not save an empty session heading", async ({ page }) => {
|
||||
await page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true }).click()
|
||||
const input = page.locator('input[data-slot="session-title-child"]')
|
||||
await input.fill(" ")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.targetTitle, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames and closes the session tab from its context menu", async ({ page }) => {
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeHidden()
|
||||
await expect(tab).toBeFocused()
|
||||
await tab.press("Shift+F10")
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Renamed from tab")
|
||||
await input.press("Enter")
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Renamed from tab", exact: true })).toBeVisible()
|
||||
const renamed = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from tab" })
|
||||
await renamed.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Close tab", exact: true }).click()
|
||||
await expect(renamed).toBeHidden()
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(
|
||||
page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from tab" }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("renames an inactive tab without switching sessions", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.sourceTitle }).click()
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
const tab = page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: fixture.expected.targetTitle })
|
||||
await tab.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const input = page.locator('[data-slot="tab-title"][contenteditable="true"]')
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill("Inactive tab renamed")
|
||||
await input.press("Tab")
|
||||
await expect(page.getByRole("heading", { name: fixture.expected.sourceTitle, exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.sourceID}$`))
|
||||
await page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Inactive tab renamed" }).click()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(page.getByRole("heading", { name: "Inactive tab renamed", exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -141,7 +141,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
test("mobile drawer exposes close controls and navigates between tabs", async ({ page }) => {
|
||||
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 360, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -89,31 +89,26 @@ test("mobile drawer exposes close controls and navigates between tabs", async ({
|
||||
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"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
|
||||
await tabB.locator(`a[href="${hrefB}"]`).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
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(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
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).toBeVisible()
|
||||
await expect(tabA.locator("[data-titlebar-tab]")).toHaveAttribute("data-title-overflow", "true")
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -207,28 +202,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")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
|
||||
await page.setViewportSize({ width: 800, height: 720 })
|
||||
await expect(settings.getByRole("tablist")).toBeHidden()
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "160px")
|
||||
await expect(version).toBeInViewport()
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeVisible()
|
||||
await expect(version).toBeInViewport()
|
||||
await settings.evaluate((element) => element.setAttribute("dir", "rtl"))
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await expect(version).toBeInViewport()
|
||||
await expect(version).toHaveCSS("direction", "ltr")
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 360 })
|
||||
await expect(settings.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport()
|
||||
await version.scrollIntoViewIfNeeded()
|
||||
await expect(version).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="mobile-tabs-drawer"]')
|
||||
.locator('[data-slot="titlebar-tabs"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${href}"]`)
|
||||
.getByText(sessionA.title, { exact: true }),
|
||||
).toBeVisible()
|
||||
@@ -247,7 +242,7 @@ test("appearance experimental setting switches tab orientation", async ({ page }
|
||||
await expect(layout).toContainText("Vertical")
|
||||
})
|
||||
|
||||
test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
|
||||
test("vertical tab preference falls back to horizontal on mobile", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 720 })
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
@@ -264,8 +259,7 @@ test("vertical tab preference uses the drawer on mobile", async ({ page }) => {
|
||||
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
await page.goto(href)
|
||||
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
const tabs = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabs = page.locator('[data-slot="titlebar-tabs"]')
|
||||
await expect(tabs.locator(`[data-titlebar-tab-link][href="${href}"]`)).toContainText(sessionA.title)
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@ export async function expectAppVisible(locator: Locator) {
|
||||
}
|
||||
|
||||
export async function expectSessionTitle(page: Page, title: string) {
|
||||
if ((page.viewportSize()?.width ?? 1280) < 768) {
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
await expectAppVisible(trigger)
|
||||
await expect(trigger.locator('span[dir="auto"]')).toHaveText(title, { timeout: APP_READY_TIMEOUT })
|
||||
return
|
||||
}
|
||||
await expectAppVisible(page.getByRole("heading", { name: title }))
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"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",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { AttachmentCard } from "@opencode-ai/session-ui/attachment-card"
|
||||
import { CommentCard } from "@opencode-ai/session-ui/comment-card"
|
||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
||||
@@ -54,7 +53,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
const state = props.controller.state
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
@@ -114,10 +112,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
<form
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
"bg-v2-background-bg-layer-01": props.borderUnderlay,
|
||||
"bg-v2-background-bg-base": !props.borderUnderlay,
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
|
||||
}}
|
||||
@@ -149,14 +145,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<ScrollView
|
||||
data-component="composer-scroll"
|
||||
class="min-h-[60px] max-h-[180px]"
|
||||
viewportRef={(element) => {
|
||||
viewport = element
|
||||
element.tabIndex = -1
|
||||
}}
|
||||
>
|
||||
<div class="relative min-h-[60px]">
|
||||
<div
|
||||
ref={(element) => {
|
||||
editor = element
|
||||
@@ -173,7 +162,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
spellcheck={state.mode === "normal"}
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
style={{
|
||||
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
|
||||
@@ -201,20 +190,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
}}
|
||||
onKeyUp={updateCursor}
|
||||
onPointerUp={updateCursor}
|
||||
onPaste={(event) => {
|
||||
props.controller.onPaste(event)
|
||||
// Programmatic multiline insertion does not reliably reveal the caret.
|
||||
requestAnimationFrame(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return
|
||||
if (!editor.contains(selection.anchorNode)) return
|
||||
const caret = selection.getRangeAt(0).getBoundingClientRect()
|
||||
if (!caret.height) return
|
||||
const bounds = viewport.getBoundingClientRect()
|
||||
if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8
|
||||
if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8
|
||||
})
|
||||
}}
|
||||
onPaste={props.controller.onPaste}
|
||||
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
@@ -230,7 +206,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div
|
||||
|
||||
@@ -2,14 +2,9 @@ import type { HomeProjectsController } from "./controller"
|
||||
import { HomeProjectsView } from "./view"
|
||||
import type { HomeScrollController } from "../scroll"
|
||||
|
||||
export function HomeProjects(props: {
|
||||
projects: HomeProjectsController
|
||||
scroll: HomeScrollController
|
||||
dropdown?: boolean
|
||||
}) {
|
||||
export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) {
|
||||
return (
|
||||
<HomeProjectsView
|
||||
dropdown={props.dropdown}
|
||||
language={props.projects.copy.language}
|
||||
servers={props.projects.server.list()}
|
||||
projects={props.projects.project.list()}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -29,7 +28,6 @@ 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[]
|
||||
@@ -66,81 +64,6 @@ 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,
|
||||
@@ -148,77 +71,48 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
|
||||
}
|
||||
return (
|
||||
<aside
|
||||
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"
|
||||
: `
|
||||
class={`
|
||||
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)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={props.servers.length > 1}
|
||||
fallback={
|
||||
<div class={props.dropdown ? "" : "pr-3"}>
|
||||
<div class="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 ${props.dropdown ? "gap-1" : "gap-4 pr-3"}`}>
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={props.servers}>
|
||||
{(item) => {
|
||||
const projects = () => props.projectsForServer(item)
|
||||
@@ -263,9 +157,7 @@ export function HomeUtilityNav(props: {
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
return (
|
||||
<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`}
|
||||
>
|
||||
<div class={`${props.class ?? ""} min-w-0 flex-col gap-1 pr-3`}>
|
||||
<HomeProjectNavButton
|
||||
type="button"
|
||||
class="text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted"
|
||||
@@ -619,7 +511,6 @@ 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,6 +1,4 @@
|
||||
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"
|
||||
@@ -11,7 +9,6 @@ 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)
|
||||
@@ -20,17 +17,12 @@ export function Home() {
|
||||
return (
|
||||
<div
|
||||
class={`
|
||||
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]
|
||||
mx-2 mb-2 mt-[var(--shell-top-inset,8px)] min-h-0 flex-1 self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
<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="min-h-0 flex-1 [container-type:size]"
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={scroll.viewport.thumbTrack()}
|
||||
thumbHoverTarget={scroll.viewport.hoverTarget()}
|
||||
viewportRef={scroll.viewport.setViewport}
|
||||
@@ -39,24 +31,20 @@ export function Home() {
|
||||
>
|
||||
<div
|
||||
class={`
|
||||
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
|
||||
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
|
||||
`}
|
||||
>
|
||||
<Show when={!mobile()}>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
</Show>
|
||||
<HomeProjects projects={projects} scroll={scroll} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
@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,7 +15,6 @@ 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,
|
||||
@@ -97,13 +96,10 @@ 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-1 pt-6 md:pb-3 lg:pt-12"
|
||||
onWheel={props.onWheel}
|
||||
>
|
||||
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 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-[68px] z-20 flex md:top-[84px] lg:top-[108px]">
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<Button
|
||||
data-action="home-new-session"
|
||||
variant="ghost-muted"
|
||||
@@ -117,18 +113,18 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="pointer-events-none sticky top-[68px] z-40 h-0 -mr-3 md:top-[84px] lg:top-[108px]">
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={props.onSetThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-68px)] w-3 md:h-[calc(100cqh-84px)] lg:h-[calc(100cqh-108px)]"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mr-3 min-h-[calc(100cqh-64px)] md:min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
|
||||
<Show
|
||||
when={!props.loading}
|
||||
fallback={
|
||||
<div class="pt-1 md:pt-3">
|
||||
<div class="pt-3">
|
||||
<HomeSessionSkeleton label={props.language.t("common.loading")} />
|
||||
</div>
|
||||
}
|
||||
@@ -142,7 +138,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-1 pr-3 pb-16 md:pt-3">
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
{/* Index keeps group subtrees mounted when the group arrays are
|
||||
rebuilt, so store updates cannot recreate rows mid-gesture. */}
|
||||
<Index each={props.groups}>
|
||||
@@ -154,9 +150,7 @@ 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-2 md:pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<div class={`flex min-w-0 flex-col gap-px 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
|
||||
@@ -241,11 +235,7 @@ 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",
|
||||
"inset-inline-start": "-6px",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
style={{ top: "-6px", left: "-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">
|
||||
@@ -279,7 +269,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
>
|
||||
{props.language.t("home.sessions.search.sessions")}
|
||||
</p>
|
||||
<ScrollView class="max-h-[min(20rem,40dvh)]" viewportRef={props.onSetSearchList}>
|
||||
<ScrollView class="max-h-80" viewportRef={props.onSetSearchList}>
|
||||
<div class="flex flex-col gap-px pb-2">
|
||||
<For each={props.searchResults}>
|
||||
{(record) => (
|
||||
@@ -301,7 +291,7 @@ function HomeSessionSearch(props: HomeSessionsViewProps) {
|
||||
</Show>
|
||||
<label
|
||||
class={`
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 ps-3 pe-2
|
||||
relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] py-1 pl-3 pr-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
|
||||
`}
|
||||
@@ -385,7 +375,6 @@ 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={`
|
||||
@@ -414,7 +403,7 @@ function HomeSessionSearchResultRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div data-slot="home-session-labels" class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div 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 />
|
||||
@@ -434,8 +423,8 @@ function HomeSessionGroupHeader(props: {
|
||||
<div
|
||||
ref={props.onSetRef}
|
||||
class={`
|
||||
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]
|
||||
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]
|
||||
`}
|
||||
classList={{ "home-session-group-header z-[5]": !!props.elevated, "z-10": !props.elevated }}
|
||||
>
|
||||
@@ -521,7 +510,6 @@ 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() }}
|
||||
@@ -535,56 +523,51 @@ function HomeSessionRow(
|
||||
<Show
|
||||
when={!editor()}
|
||||
fallback={
|
||||
<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"
|
||||
>
|
||||
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
<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={`
|
||||
<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") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
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()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
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>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -595,7 +578,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 ps-1.5 pe-3 md:ps-3 md:pe-10 text-start text-v2-text-text-muted [font-weight:530]
|
||||
bg-transparent py-3 pl-3 pr-10 text-left 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
|
||||
`}
|
||||
@@ -658,12 +641,10 @@ function HomeSessionRow(
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div data-slot="home-session-labels" class="contents">
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
@@ -742,7 +723,6 @@ 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,
|
||||
@@ -758,8 +738,6 @@ 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 }}
|
||||
>
|
||||
@@ -801,7 +779,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 ps-1.5 pe-4 md:ps-4">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between px-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-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-2 pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,6 +414,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "አገልጋይ አክል",
|
||||
"dialog.server.add.name": "የአገልጋይ ስም (አማራጭ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "የተጠቃሚ ስም (አማራጭ)",
|
||||
"dialog.server.add.usernamePlaceholder": "የተጠቃሚ ስም",
|
||||
"dialog.server.add.password": "የይለፍ ቃል (አማራጭ)",
|
||||
"dialog.server.add.passwordPlaceholder": "የይለፍ ቃል",
|
||||
"dialog.server.edit.title": "አገልጋይ አርትዕ",
|
||||
@@ -496,6 +498,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ዴስክቶፕ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "የተጫነው ሥሪት ከዴስክቶፕ መተግበሪያ ሥሪት ጋር አይዛመድም።",
|
||||
"wsl.onboarding.adding": "በማከል ላይ...",
|
||||
"server.row.noUsername": "የተጠቃሚ ስም የለም",
|
||||
"dialog.project.edit.title": "ፕሮጀክት አርትዕ",
|
||||
"dialog.project.edit.name": "ስም",
|
||||
"dialog.project.edit.icon": "አዶ",
|
||||
|
||||
@@ -420,6 +420,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "إضافة خادم",
|
||||
"dialog.server.add.name": "اسم الخادم (اختياري)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "اسم المستخدم (اختياري)",
|
||||
"dialog.server.add.password": "كلمة المرور (اختياري)",
|
||||
"dialog.server.edit.title": "تحرير الخادم",
|
||||
"dialog.server.default.title": "الخادم الافتراضي",
|
||||
@@ -1023,7 +1024,9 @@ export const dict = {
|
||||
"app.server.unreachable": "تعذر الوصول إلى {{server}}",
|
||||
"app.server.retrying": "جارٍ إعادة المحاولة تلقائيًا...",
|
||||
"app.server.otherServers": "خوادم أخرى",
|
||||
"dialog.server.add.usernamePlaceholder": "اسم المستخدم",
|
||||
"dialog.server.add.passwordPlaceholder": "كلمة المرور",
|
||||
"server.row.noUsername": "لا يوجد اسم مستخدم",
|
||||
"session.review.noVcs.createGit.title": "إنشاء مستودع Git",
|
||||
"session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع",
|
||||
"session.review.noVcs.createGit.actionLoading": "جارٍ إنشاء مستودع Git...",
|
||||
|
||||
@@ -421,6 +421,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Server əlavə et",
|
||||
"dialog.server.add.name": "Server adı (istəyə bağlı)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "İstifadəçi adı (istəyə bağlı)",
|
||||
"dialog.server.add.usernamePlaceholder": "istifadəçi adı",
|
||||
"dialog.server.add.password": "Şifrə (istəyə bağlı)",
|
||||
"dialog.server.add.passwordPlaceholder": "parol",
|
||||
"dialog.server.edit.title": "Serveri redaktə et",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "masaüstü {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.",
|
||||
"wsl.onboarding.adding": "Əlavə edilir...",
|
||||
"server.row.noUsername": "istifadəçi adı yoxdur",
|
||||
"dialog.project.edit.title": "Layihəni redaktə et",
|
||||
"dialog.project.edit.name": "Ad",
|
||||
"dialog.project.edit.icon": "İkon",
|
||||
|
||||
@@ -421,6 +421,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Добавете сървър",
|
||||
"dialog.server.add.name": "Име на сървъра (по избор)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "потребителско име (по избор)",
|
||||
"dialog.server.add.usernamePlaceholder": "потребителско име",
|
||||
"dialog.server.add.password": "Парола (по избор)",
|
||||
"dialog.server.add.passwordPlaceholder": "парола",
|
||||
"dialog.server.edit.title": "Редактиране на сървъра",
|
||||
@@ -506,6 +508,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "работен плот {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Инсталираната версия не съответства на версията на настолното приложение.",
|
||||
"wsl.onboarding.adding": "Добавяне...",
|
||||
"server.row.noUsername": "няма потребителско име",
|
||||
"dialog.project.edit.title": "Редактиране на проекта",
|
||||
"dialog.project.edit.name": "Име",
|
||||
"dialog.project.edit.icon": "Икона",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict: Record<string, string> = {
|
||||
"dialog.server.add.button": "সার্ভার যোগ করুন",
|
||||
"dialog.server.add.name": "সার্ভারের নাম (ঐচ্ছিক)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ব্যবহারকারীর নাম (ঐচ্ছিক)",
|
||||
"dialog.server.add.usernamePlaceholder": "ব্যবহারকারীর নাম",
|
||||
"dialog.server.add.password": "পাসওয়ার্ড (ঐচ্ছিক)",
|
||||
"dialog.server.add.passwordPlaceholder": "পাসওয়ার্ড",
|
||||
"dialog.server.edit.title": "সার্ভার সম্পাদনা করুন",
|
||||
@@ -503,6 +505,7 @@ export const dict: Record<string, string> = {
|
||||
"wsl.onboarding.desktopVersion": "ডেস্কটপ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "ইনস্টল করা সংস্করণ ডেস্কটপ অ্যাপ সংস্করণের সাথে মেলে না।",
|
||||
"wsl.onboarding.adding": "যোগ করা হচ্ছে...",
|
||||
"server.row.noUsername": "ব্যবহারকারীর নাম নেই",
|
||||
"dialog.project.edit.title": "প্রকল্প সম্পাদনা করুন",
|
||||
"dialog.project.edit.name": "নাম",
|
||||
"dialog.project.edit.icon": "আইকন",
|
||||
|
||||
@@ -422,6 +422,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Adicionar servidor",
|
||||
"dialog.server.add.name": "Nome do servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nome de usuário (opcional)",
|
||||
"dialog.server.add.password": "Senha (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor padrão",
|
||||
@@ -1034,7 +1035,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Não foi possível conectar a {{server}}",
|
||||
"app.server.retrying": "Tentando novamente automaticamente...",
|
||||
"app.server.otherServers": "Outros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "nome de usuário",
|
||||
"dialog.server.add.passwordPlaceholder": "senha",
|
||||
"server.row.noUsername": "sem nome de usuário",
|
||||
"session.review.noVcs.createGit.title": "Criar um repositório Git",
|
||||
"session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Criando repositório Git...",
|
||||
|
||||
@@ -449,6 +449,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Dodaj server",
|
||||
"dialog.server.add.name": "Ime servera (opcionalno)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Korisničko ime (opcionalno)",
|
||||
"dialog.server.add.password": "Lozinka (opcionalno)",
|
||||
"dialog.server.edit.title": "Uredi server",
|
||||
"dialog.server.default.title": "Podrazumijevani server",
|
||||
@@ -1112,7 +1113,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Nije moguće pristupiti {{server}}",
|
||||
"app.server.retrying": "Automatski ponovni pokušaj...",
|
||||
"app.server.otherServers": "Drugi serveri",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
"server.row.noUsername": "nema korisničkog imena",
|
||||
"session.review.noVcs.createGit.title": "Kreiraj Git repozitorij",
|
||||
"session.review.noVcs.createGit.description": "Prati, pregledaj i poništi promjene u ovom projektu",
|
||||
"session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Afegeix servidor",
|
||||
"dialog.server.add.name": "Nom del servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nom d'usuari (opcional)",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'usuari",
|
||||
"dialog.server.add.password": "Contrasenya (opcional)",
|
||||
"dialog.server.add.passwordPlaceholder": "contrasenya",
|
||||
"dialog.server.edit.title": "Edita el servidor",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "escriptori {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La versió instal·lada no coincideix amb la versió de l'aplicació d'escriptori.",
|
||||
"wsl.onboarding.adding": "S'està afegint...",
|
||||
"server.row.noUsername": "sense nom d'usuari",
|
||||
"dialog.project.edit.title": "Edita el projecte",
|
||||
"dialog.project.edit.name": "Nom",
|
||||
"dialog.project.edit.icon": "Icona",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Přidat server",
|
||||
"dialog.server.add.name": "Název serveru (volitelné)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "uživatelské jméno (volitelné)",
|
||||
"dialog.server.add.usernamePlaceholder": "uživatelské jméno",
|
||||
"dialog.server.add.password": "Heslo (volitelné)",
|
||||
"dialog.server.add.passwordPlaceholder": "heslo",
|
||||
"dialog.server.edit.title": "Upravit server",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Nainstalovaná verze neodpovídá verzi aplikace pro stolní počítače.",
|
||||
"wsl.onboarding.adding": "Přidávání...",
|
||||
"server.row.noUsername": "žádné uživatelské jméno",
|
||||
"dialog.project.edit.title": "Upravit projekt",
|
||||
"dialog.project.edit.name": "Jméno",
|
||||
"dialog.project.edit.icon": "ikona",
|
||||
|
||||
@@ -346,6 +346,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Tilføj server",
|
||||
"dialog.server.add.name": "Servernavn (valgfrit)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brugernavn (valgfrit)",
|
||||
"dialog.server.add.password": "Adgangskode (valgfrit)",
|
||||
"dialog.server.edit.title": "Rediger server",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
@@ -1002,7 +1003,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Kunne ikke nå {{server}}",
|
||||
"app.server.retrying": "Prøver igen automatisk...",
|
||||
"app.server.otherServers": "Andre servere",
|
||||
"dialog.server.add.usernamePlaceholder": "brugernavn",
|
||||
"dialog.server.add.passwordPlaceholder": "adgangskode",
|
||||
"server.row.noUsername": "intet brugernavn",
|
||||
"session.review.noVcs.createGit.title": "Opret et Git-repository",
|
||||
"session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt",
|
||||
"session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...",
|
||||
|
||||
@@ -327,6 +327,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Server hinzufügen",
|
||||
"dialog.server.add.name": "Servername (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Benutzername (optional)",
|
||||
"dialog.server.add.password": "Passwort (optional)",
|
||||
"dialog.server.edit.title": "Server bearbeiten",
|
||||
"dialog.server.default.title": "Standardserver",
|
||||
@@ -944,7 +945,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Konnte {{server}} nicht erreichen",
|
||||
"app.server.retrying": "Verbindung wird automatisch erneut hergestellt…",
|
||||
"app.server.otherServers": "Andere Server",
|
||||
"dialog.server.add.usernamePlaceholder": "Benutzername",
|
||||
"dialog.server.add.passwordPlaceholder": "Passwort",
|
||||
"server.row.noUsername": "Kein Benutzername",
|
||||
"session.review.noVcs.createGit.title": "Git-Repository erstellen",
|
||||
"session.review.noVcs.createGit.description":
|
||||
"Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen",
|
||||
|
||||
@@ -424,6 +424,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "ސަރވަރ އިތުރުކުރުން",
|
||||
"dialog.server.add.name": "ސަރވަރ ނަން (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost އެވެ",
|
||||
"dialog.server.add.username": "ޔޫޒަރނޭމް (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.usernamePlaceholder": "ޔޫޒަރނޭމް",
|
||||
"dialog.server.add.password": "ޕާސްވޯޑް (އިޚްތިޔާރީ)",
|
||||
"dialog.server.add.passwordPlaceholder": "ޕާސްވަރޑް",
|
||||
"dialog.server.edit.title": "އެޑިޓް ސަރވަރ",
|
||||
@@ -509,6 +511,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ޑެސްކްޓޮޕް {{version}} އެވެ",
|
||||
"wsl.onboarding.versionMismatch": "އިންސްޓޯލް ކޮށްފައިވާ ވަރޝަން ޑެސްކްޓޮޕް އެޕް ވަރޝަން އާއި އެއްގޮތެއް ނުވެއެވެ.",
|
||||
"wsl.onboarding.adding": "އިތުރުކުރަނީ...",
|
||||
"server.row.noUsername": "ޔޫޒަރނޭމް އެއް ނެތެވެ",
|
||||
"dialog.project.edit.title": "އެޑިޓް ޕްރޮޖެކްޓް",
|
||||
"dialog.project.edit.name": "ނަން",
|
||||
"dialog.project.edit.icon": "އައިކޮން",
|
||||
|
||||
@@ -423,6 +423,8 @@ export const dict: Record<string, string> = {
|
||||
"dialog.server.add.button": "སར་བར་ཁ་སྐོང་འབད།",
|
||||
"dialog.server.add.name": "སར་བར་གྱི་མིང་ (གདམ་ཁ་ཅན།)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost།",
|
||||
"dialog.server.add.username": "ལག་ལེན་པའི་མིང་ (གདམ་ཁ་ཅན་)།",
|
||||
"dialog.server.add.usernamePlaceholder": "ལག་ལེན་པའི་མིང་།",
|
||||
"dialog.server.add.password": "ཆོག་ཡིག་ (གདམ་ཁ་ཅན།)",
|
||||
"dialog.server.add.passwordPlaceholder": "གསང༌ཨང",
|
||||
"dialog.server.edit.title": "སར་བར་ཞུན་དག་འབད།",
|
||||
@@ -508,6 +510,7 @@ export const dict: Record<string, string> = {
|
||||
"wsl.onboarding.desktopVersion": "ཌེཀསི་ཊོཔ་ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "གཞི་བཙུགས་འབད་ཡོད་པའི་ཐོན་རིམ་འདི་ ཌེཀསི་ཊོཔ་ཨེཔ་ཐོན་རིམ་དང་མཐུན་སྒྲིག་མི་འབད།",
|
||||
"wsl.onboarding.adding": "ཁ་སྐོང་བརྐྱབ་དོ།",
|
||||
"server.row.noUsername": "སྤྱོད་མིང་མེད།",
|
||||
"dialog.project.edit.title": "ཞུན་དག་ལས་གཞི།",
|
||||
"dialog.project.edit.name": "མིང",
|
||||
"dialog.project.edit.icon": "ངོས་དཔར།",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Προσθήκη διακομιστή",
|
||||
"dialog.server.add.name": "Όνομα διακομιστή (προαιρετικό)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Όνομα χρήστη (προαιρετικό)",
|
||||
"dialog.server.add.usernamePlaceholder": "όνομα χρήστη",
|
||||
"dialog.server.add.password": "Κωδικός πρόσβασης (προαιρετικό)",
|
||||
"dialog.server.add.passwordPlaceholder": "κωδικός πρόσβασης",
|
||||
"dialog.server.edit.title": "Επεξεργασία διακομιστή",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "επιτραπέζιος υπολογιστής {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Η εγκατεστημένη έκδοση δεν ταιριάζει με την έκδοση της εφαρμογής για υπολογιστές.",
|
||||
"wsl.onboarding.adding": "Προσθήκη...",
|
||||
"server.row.noUsername": "χωρίς όνομα χρήστη",
|
||||
"dialog.project.edit.title": "Επεξεργασία έργου",
|
||||
"dialog.project.edit.name": "Όνομα",
|
||||
"dialog.project.edit.icon": "Εικονίδιο",
|
||||
|
||||
@@ -363,7 +363,9 @@ export const dict = {
|
||||
"dialog.server.add.button": "Add server",
|
||||
"dialog.server.add.name": "Server name (optional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.password": "Password",
|
||||
"dialog.server.add.username": "Username (optional)",
|
||||
"dialog.server.add.usernamePlaceholder": "username",
|
||||
"dialog.server.add.password": "Password (optional)",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
@@ -451,6 +453,7 @@ export const dict = {
|
||||
"wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.",
|
||||
"wsl.onboarding.adding": "Adding...",
|
||||
|
||||
"server.row.noUsername": "no username",
|
||||
"server.row.incompatible":
|
||||
"This server is running OpenCode {{version}}, which isn't compatible with this app. Upgrade it to OpenCode V2 to continue.",
|
||||
|
||||
@@ -643,7 +646,6 @@ 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",
|
||||
@@ -660,10 +662,6 @@ export const dict = {
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
"session.files.openTabs": "Open files",
|
||||
"session.tab.usage": "Usage",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
@@ -702,7 +700,6 @@ export const dict = {
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
"session.review.loadingChanges": "Loading changes...",
|
||||
"session.review.wrapLines": "Wrap lines",
|
||||
"session.review.empty": "No changes in this session yet",
|
||||
"session.review.noVcs": "No Git Version Control System detected, changes not displayed",
|
||||
"session.review.noVcs.createGit.title": "Create a Git repository",
|
||||
@@ -805,7 +802,6 @@ 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",
|
||||
@@ -983,9 +979,7 @@ export const dict = {
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar at the bottom of the screen on mobile",
|
||||
"settings.general.row.mobileDiffWrap.description":
|
||||
"Wrap long lines in mobile diffs instead of scrolling horizontally",
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
"settings.general.row.showCustomAgents.title": "Show agent",
|
||||
"settings.general.row.showCustomAgents.description":
|
||||
"Switch between agents in the composer. When hidden, defaults to Build agent.",
|
||||
|
||||
@@ -450,6 +450,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Añadir servidor",
|
||||
"dialog.server.add.name": "Nombre del servidor (opcional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nombre de usuario (opcional)",
|
||||
"dialog.server.add.password": "Contraseña (opcional)",
|
||||
"dialog.server.edit.title": "Editar servidor",
|
||||
"dialog.server.default.title": "Servidor predeterminado",
|
||||
@@ -1117,7 +1118,9 @@ export const dict = {
|
||||
"app.server.unreachable": "No se pudo conectar con {{server}}",
|
||||
"app.server.retrying": "Reintentando automáticamente...",
|
||||
"app.server.otherServers": "Otros servidores",
|
||||
"dialog.server.add.usernamePlaceholder": "usuario",
|
||||
"dialog.server.add.passwordPlaceholder": "contraseña",
|
||||
"server.row.noUsername": "sin usuario",
|
||||
"session.review.noVcs.createGit.title": "Crear repositorio Git",
|
||||
"session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto",
|
||||
"session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Lisa server",
|
||||
"dialog.server.add.name": "Serveri nimi (valikuline)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Kasutajanimi (valikuline)",
|
||||
"dialog.server.add.usernamePlaceholder": "kasutajanimi",
|
||||
"dialog.server.add.password": "Parool (valikuline)",
|
||||
"dialog.server.add.passwordPlaceholder": "parool",
|
||||
"dialog.server.edit.title": "Muuda serverit",
|
||||
@@ -501,6 +503,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "töölaud {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Installitud versioon ei ühti töölauarakenduse versiooniga.",
|
||||
"wsl.onboarding.adding": "Lisamine...",
|
||||
"server.row.noUsername": "kasutajanime pole",
|
||||
"dialog.project.edit.title": "Redigeeri projekti",
|
||||
"dialog.project.edit.name": "Nimi",
|
||||
"dialog.project.edit.icon": "Ikoon",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "سرور اضافه کنید",
|
||||
"dialog.server.add.name": "نام سرور (اختیاری)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "نام کاربری (اختیاری)",
|
||||
"dialog.server.add.usernamePlaceholder": "نام کاربری",
|
||||
"dialog.server.add.password": "رمز عبور (اختیاری)",
|
||||
"dialog.server.add.passwordPlaceholder": "رمز عبور",
|
||||
"dialog.server.edit.title": "ویرایش سرور",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "دسکتاپ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "نسخه نصب شده با نسخه برنامه دسکتاپ مطابقت ندارد.",
|
||||
"wsl.onboarding.adding": "در حال افزودن...",
|
||||
"server.row.noUsername": "بدون نام کاربری",
|
||||
"dialog.project.edit.title": "ویرایش پروژه",
|
||||
"dialog.project.edit.name": "نام",
|
||||
"dialog.project.edit.icon": "نماد",
|
||||
|
||||
@@ -325,6 +325,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Lisää palvelin",
|
||||
"dialog.server.add.name": "Palvelimen nimi (valinnainen)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Käyttäjätunnus (valinnainen)",
|
||||
"dialog.server.add.usernamePlaceholder": "käyttäjätunnus",
|
||||
"dialog.server.add.password": "Salasana (valinnainen)",
|
||||
"dialog.server.add.passwordPlaceholder": "salasana",
|
||||
"dialog.server.edit.title": "Muokkaa palvelinta",
|
||||
@@ -410,6 +412,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "Desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Asennettu versio ei vastaa työpöytäsovelluksen versiota.",
|
||||
"wsl.onboarding.adding": "Lisätään...",
|
||||
"server.row.noUsername": "ei käyttäjätunnusta",
|
||||
"dialog.project.edit.title": "Muokkaa projektia",
|
||||
"dialog.project.edit.name": "Nimi",
|
||||
"dialog.project.edit.icon": "Kuvake",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Legg ambætara til",
|
||||
"dialog.server.add.name": "Ambætaranavn (valfrítt)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Brúkaranavn (valfrítt)",
|
||||
"dialog.server.add.usernamePlaceholder": "brúkaranavn",
|
||||
"dialog.server.add.password": "Loyniorð (valfrítt)",
|
||||
"dialog.server.add.passwordPlaceholder": "loyniorð",
|
||||
"dialog.server.edit.title": "Rætta ambætara",
|
||||
@@ -502,6 +504,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "skriviborð {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Innsetta útgávan passar ikki til skriviborðsappiútgávuna.",
|
||||
"wsl.onboarding.adding": "Leggi til...",
|
||||
"server.row.noUsername": "einki brúkaranavn",
|
||||
"dialog.project.edit.title": "Rætta verkætlan",
|
||||
"dialog.project.edit.name": "Navn",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -426,6 +426,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "Ajouter un serveur",
|
||||
"dialog.server.add.name": "Nom du serveur (optionnel)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nom d'utilisateur (optionnel)",
|
||||
"dialog.server.add.password": "Mot de passe (optionnel)",
|
||||
"dialog.server.edit.title": "Modifier le serveur",
|
||||
"dialog.server.default.title": "Serveur par défaut",
|
||||
@@ -1043,7 +1044,9 @@ export const dict = {
|
||||
"app.server.unreachable": "Impossible de joindre {{server}}",
|
||||
"app.server.retrying": "Nouvelle tentative automatique...",
|
||||
"app.server.otherServers": "Autres serveurs",
|
||||
"dialog.server.add.usernamePlaceholder": "nom d'utilisateur",
|
||||
"dialog.server.add.passwordPlaceholder": "mot de passe",
|
||||
"server.row.noUsername": "aucun nom d'utilisateur",
|
||||
"session.review.noVcs.createGit.title": "Créer un dépôt Git",
|
||||
"session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet",
|
||||
"session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...",
|
||||
|
||||
@@ -416,6 +416,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "הוסף שרת",
|
||||
"dialog.server.add.name": "שם שרת (אופציונלי)",
|
||||
"dialog.server.add.namePlaceholder": "מארח מקומי",
|
||||
"dialog.server.add.username": "שם משתמש (אופציונלי)",
|
||||
"dialog.server.add.usernamePlaceholder": "שם משתמש",
|
||||
"dialog.server.add.password": "סיסמה (אופציונלי)",
|
||||
"dialog.server.add.passwordPlaceholder": "סיסמה",
|
||||
"dialog.server.edit.title": "ערוך שרת",
|
||||
@@ -499,6 +501,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "גרסת שולחן העבודה: {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "הגרסה המותקנת אינה תואמת את גרסת האפליקציה לשולחן העבודה.",
|
||||
"wsl.onboarding.adding": "מוסיף...",
|
||||
"server.row.noUsername": "ללא שם משתמש",
|
||||
"dialog.project.edit.title": "ערוך פרויקט",
|
||||
"dialog.project.edit.name": "שם",
|
||||
"dialog.project.edit.icon": "סמל",
|
||||
|
||||
@@ -425,6 +425,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "सर्वर जोड़ें",
|
||||
"dialog.server.add.name": "सर्वर नाम (वैकल्पिक)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "उपयोगकर्ता नाम (वैकल्पिक)",
|
||||
"dialog.server.add.usernamePlaceholder": "उपयोक्तानाम",
|
||||
"dialog.server.add.password": "पासवर्ड (वैकल्पिक)",
|
||||
"dialog.server.add.passwordPlaceholder": "पासवर्ड",
|
||||
"dialog.server.edit.title": "सर्वर संपादित करें",
|
||||
@@ -510,6 +512,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "डेस्कटॉप {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "इंस्टॉल किया गया संस्करण डेस्कटॉप ऐप संस्करण से मेल नहीं खाता।",
|
||||
"wsl.onboarding.adding": "जोड़ा जा रहा है...",
|
||||
"server.row.noUsername": "कोई उपयोगकर्ता नाम नहीं",
|
||||
"dialog.project.edit.title": "प्रोजेक्ट संपादित करें",
|
||||
"dialog.project.edit.name": "नाम",
|
||||
"dialog.project.edit.icon": "आइकन",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Dodaj poslužitelj",
|
||||
"dialog.server.add.name": "Naziv poslužitelja (neobavezno)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "korisničko ime (nije obavezno)",
|
||||
"dialog.server.add.usernamePlaceholder": "korisničko ime",
|
||||
"dialog.server.add.password": "Lozinka (nije obavezno)",
|
||||
"dialog.server.add.passwordPlaceholder": "lozinka",
|
||||
"dialog.server.edit.title": "Uredi poslužitelj",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "radna površina {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji aplikacije za stolna računala.",
|
||||
"wsl.onboarding.adding": "Dodavanje...",
|
||||
"server.row.noUsername": "nema korisničkog imena",
|
||||
"dialog.project.edit.title": "Uredi projekt",
|
||||
"dialog.project.edit.name": "Ime",
|
||||
"dialog.project.edit.icon": "Ikona",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Szerver hozzáadása",
|
||||
"dialog.server.add.name": "Szerver neve (nem kötelező)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Felhasználónév (nem kötelező)",
|
||||
"dialog.server.add.usernamePlaceholder": "felhasználónév",
|
||||
"dialog.server.add.password": "Jelszó (nem kötelező)",
|
||||
"dialog.server.add.passwordPlaceholder": "jelszó",
|
||||
"dialog.server.edit.title": "Szerver szerkesztése",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "asztali {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "A telepített verzió nem egyezik az asztali alkalmazás verziójával.",
|
||||
"wsl.onboarding.adding": "Hozzáadás...",
|
||||
"server.row.noUsername": "nincs felhasználónév",
|
||||
"dialog.project.edit.title": "Projekt szerkesztése",
|
||||
"dialog.project.edit.name": "Név",
|
||||
"dialog.project.edit.icon": "Ikon",
|
||||
|
||||
@@ -420,6 +420,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Ավելացնել սերվեր",
|
||||
"dialog.server.add.name": "Սերվերի անունը (ըստ ցանկության)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Օգտագործողի անուն (ըստ ցանկության)",
|
||||
"dialog.server.add.usernamePlaceholder": "օգտվողի անուն",
|
||||
"dialog.server.add.password": "Գաղտնաբառ (ըստ ցանկության)",
|
||||
"dialog.server.add.passwordPlaceholder": "գաղտնաբառ",
|
||||
"dialog.server.edit.title": "Խմբագրել սերվերը",
|
||||
@@ -505,6 +507,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "աշխատասեղանի {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Տեղադրված տարբերակը չի համապատասխանում աշխատասեղանի հավելվածի տարբերակին։",
|
||||
"wsl.onboarding.adding": "Ավելացվում է...",
|
||||
"server.row.noUsername": "առանց օգտվողի անուն",
|
||||
"dialog.project.edit.title": "Խմբագրել նախագիծը",
|
||||
"dialog.project.edit.name": "Անուն",
|
||||
"dialog.project.edit.icon": "Պատկերակ",
|
||||
|
||||
@@ -453,6 +453,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Tambah server",
|
||||
"dialog.server.add.name": "Nama server (opsional)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Nama pengguna (opsional)",
|
||||
"dialog.server.add.usernamePlaceholder": "nama pengguna",
|
||||
"dialog.server.add.password": "Kata sandi (opsional)",
|
||||
"dialog.server.add.passwordPlaceholder": "kata sandi",
|
||||
"dialog.server.edit.title": "Sunting server",
|
||||
@@ -540,6 +542,7 @@ export const dict = {
|
||||
"wsl.onboarding.versionMismatch": "Versi terinstal tidak cocok dengan versi aplikasi desktop.",
|
||||
"wsl.onboarding.adding": "Menambahkan...",
|
||||
|
||||
"server.row.noUsername": "tanpa nama pengguna",
|
||||
|
||||
"dialog.project.edit.title": "Sunting proyek",
|
||||
"dialog.project.edit.name": "Nama",
|
||||
|
||||
@@ -422,6 +422,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Bæta við netþjóni",
|
||||
"dialog.server.add.name": "Nafn netþjóns (valfrjálst)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "Notandanafn (valfrjálst)",
|
||||
"dialog.server.add.usernamePlaceholder": "notendanafn",
|
||||
"dialog.server.add.password": "Lykilorð (valfrjálst)",
|
||||
"dialog.server.add.passwordPlaceholder": "lykilorð",
|
||||
"dialog.server.edit.title": "Breyta miðlara",
|
||||
@@ -507,6 +509,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "skrifborð {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "Uppsett útgáfa passar ekki við útgáfu skrifborðsforritsins.",
|
||||
"wsl.onboarding.adding": "Bætir við...",
|
||||
"server.row.noUsername": "ekkert notendanafn",
|
||||
"dialog.project.edit.title": "Breyta verkefni",
|
||||
"dialog.project.edit.name": "Nafn",
|
||||
"dialog.project.edit.icon": "Táknmynd",
|
||||
|
||||
@@ -327,6 +327,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "Aggiungi server",
|
||||
"dialog.server.add.name": "Nome del server (facoltativo)",
|
||||
"dialog.server.add.namePlaceholder": "Host locale",
|
||||
"dialog.server.add.username": "Nome utente (facoltativo)",
|
||||
"dialog.server.add.usernamePlaceholder": "nomeutente",
|
||||
"dialog.server.add.password": "Password (facoltativa)",
|
||||
"dialog.server.add.passwordPlaceholder": "password",
|
||||
"dialog.server.edit.title": "Modifica server",
|
||||
@@ -412,6 +414,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "desktop {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "La versione installata non corrisponde alla versione dell'app desktop.",
|
||||
"wsl.onboarding.adding": "Aggiunta...",
|
||||
"server.row.noUsername": "nessun nome utente",
|
||||
"dialog.project.edit.title": "Modifica progetto",
|
||||
"dialog.project.edit.name": "Nome",
|
||||
"dialog.project.edit.icon": "Icona",
|
||||
|
||||
@@ -419,6 +419,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "サーバーを追加",
|
||||
"dialog.server.add.name": "サーバー名 (オプション)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ユーザー名 (オプション)",
|
||||
"dialog.server.add.password": "パスワード (オプション)",
|
||||
"dialog.server.edit.title": "サーバーを編集",
|
||||
"dialog.server.default.title": "デフォルトサーバー",
|
||||
@@ -1018,7 +1019,9 @@ export const dict = {
|
||||
"app.server.unreachable": "{{server}} に到達できませんでした",
|
||||
"app.server.retrying": "自動的に再試行中...",
|
||||
"app.server.otherServers": "その他のサーバー",
|
||||
"dialog.server.add.usernamePlaceholder": "ユーザー名",
|
||||
"dialog.server.add.passwordPlaceholder": "パスワード",
|
||||
"server.row.noUsername": "ユーザー名なし",
|
||||
"session.review.noVcs.createGit.title": "Git リポジトリを作成",
|
||||
"session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...",
|
||||
|
||||
@@ -418,6 +418,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "სერვერის დამატება",
|
||||
"dialog.server.add.name": "სერვერის სახელი (არასავალდებულო)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "მომხმარებლის სახელი (არასავალდებულო)",
|
||||
"dialog.server.add.usernamePlaceholder": "მომხმარებლის სახელი",
|
||||
"dialog.server.add.password": "პაროლი (არასავალდებულო)",
|
||||
"dialog.server.add.passwordPlaceholder": "პაროლი",
|
||||
"dialog.server.edit.title": "სერვერის რედაქტირება",
|
||||
@@ -503,6 +505,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "დესკტოპის {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "დაინსტალირებული ვერსია არ ემთხვევა დესკტოპის აპის ვერსიას.",
|
||||
"wsl.onboarding.adding": "დამატება...",
|
||||
"server.row.noUsername": "მომხმარებლის სახელის გარეშე",
|
||||
"dialog.project.edit.title": "პროექტის რედაქტირება",
|
||||
"dialog.project.edit.name": "სახელი",
|
||||
"dialog.project.edit.icon": "ხატულა",
|
||||
|
||||
@@ -417,6 +417,8 @@ export const dict = {
|
||||
"dialog.server.add.button": "បន្ថែមម៉ាស៊ីនមេ",
|
||||
"dialog.server.add.name": "ឈ្មោះម៉ាស៊ីនមេ (ជាជម្រើស)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "ឈ្មោះអ្នកប្រើប្រាស់ (ជាជម្រើស)",
|
||||
"dialog.server.add.usernamePlaceholder": "ឈ្មោះអ្នកប្រើ",
|
||||
"dialog.server.add.password": "ពាក្យសម្ងាត់ (ជាជម្រើស)\nពាក្យសម្ងាត់",
|
||||
"dialog.server.add.passwordPlaceholder": "ពាក្យសម្ងាត់",
|
||||
"dialog.server.edit.title": "កែសម្រួលម៉ាស៊ីនមេ",
|
||||
@@ -502,6 +504,7 @@ export const dict = {
|
||||
"wsl.onboarding.desktopVersion": "ផ្ទៃតុ {{version}}",
|
||||
"wsl.onboarding.versionMismatch": "កំណែដែលបានដំឡើងមិនត្រូវគ្នានឹងកំណែកម្មវិធីកុំព្យូទ័រលើតុទេ។",
|
||||
"wsl.onboarding.adding": "កំពុងបន្ថែម...",
|
||||
"server.row.noUsername": "គ្មានឈ្មោះអ្នកប្រើប្រាស់",
|
||||
"dialog.project.edit.title": "កែសម្រួលគម្រោង",
|
||||
"dialog.project.edit.name": "ឈ្មោះ",
|
||||
"dialog.project.edit.icon": "រូបតំណាង",
|
||||
|
||||
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"dialog.server.add.button": "서버 추가",
|
||||
"dialog.server.add.name": "서버 이름 (선택 사항)",
|
||||
"dialog.server.add.namePlaceholder": "Localhost",
|
||||
"dialog.server.add.username": "사용자 이름 (선택 사항)",
|
||||
"dialog.server.add.password": "비밀번호 (선택 사항)",
|
||||
"dialog.server.edit.title": "서버 편집",
|
||||
"dialog.server.default.title": "기본 서버",
|
||||
@@ -767,7 +768,9 @@ export const dict = {
|
||||
"app.server.unreachable": "{{server}}에 연결할 수 없습니다",
|
||||
"app.server.retrying": "자동으로 재시도 중...",
|
||||
"app.server.otherServers": "다른 서버",
|
||||
"dialog.server.add.usernamePlaceholder": "사용자 이름",
|
||||
"dialog.server.add.passwordPlaceholder": "비밀번호",
|
||||
"server.row.noUsername": "사용자 이름 없음",
|
||||
"session.review.noVcs.createGit.title": "Git 저장소 생성",
|
||||
"session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소",
|
||||
"session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user