Compare commits

..
Author SHA1 Message Date
rekram1-node 6948a3f5f5 refactor(core): localize read tool input schema 2026-09-02 13:22:41 +00:00
573 changed files with 5644 additions and 18708 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2'
if: github.ref_name == 'v2' || github.ref_name == 'beta'
working-directory: packages/updates
run: bun run deploy
env:
+4 -3
View File
@@ -575,7 +575,6 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -586,7 +585,6 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -973,6 +971,7 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1046,7 +1045,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -1125,6 +1123,7 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -5172,6 +5171,8 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
}
}
+2 -1
View File
@@ -27,6 +27,7 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -92,6 +93,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
@@ -177,7 +179,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+1 -102
View File
@@ -241,115 +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`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, 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 explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
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.
### Explicit compaction
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
```ts
const result = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
})
const response = yield * LLMClient.generate(next)
```
`replacement` replaces the complete input window. 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.
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.
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. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
### Advanced: in-band compaction
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
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"`. It occurs only if the threshold triggers compaction: `pauseAfterCompaction` does not mean "compact now". 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`.
### 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, WebSocket recovery, 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), [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).
+27 -180
View File
@@ -6,12 +6,8 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { Protocol } from "../route/protocol.js"
import { Headers } from "effect/unstable/http"
import { HttpTransport } from "../route/transport/index.js"
import {
AIError,
HttpOptions,
LLMRequest,
LLMEvent,
mergeJsonRecords,
Usage,
@@ -19,6 +15,7 @@ import {
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ToolCallPart,
@@ -64,8 +61,6 @@ export type ThinkingInput =
))
export interface OptionsInput {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly [key: string]: unknown
readonly thinking?: ThinkingInput
readonly effort?: string
@@ -94,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
// =============================================================================
@@ -258,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,
@@ -339,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),
@@ -374,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),
@@ -393,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({
@@ -423,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),
@@ -453,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>>
@@ -897,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) })
@@ -1058,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 →
@@ -1095,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,
@@ -1116,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,
})),
},
}
})
// =============================================================================
@@ -1149,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 },
})
}
@@ -1195,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,
@@ -1254,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]
@@ -1348,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 [
@@ -1366,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 [
@@ -1378,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 [
@@ -1390,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(
@@ -1415,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 ?? []
@@ -1478,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
@@ -1524,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(
@@ -1567,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
@@ -1590,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)
@@ -1624,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: {},
@@ -1635,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",
@@ -1675,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" }),
})
+132 -200
View File
@@ -32,19 +32,17 @@ export const PATH = "/responses"
// =============================================================================
// Request Body Schema
// =============================================================================
export const OpenResponsesInputText = Schema.Struct({
const OpenResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"),
text: Schema.String,
})
export const OpenResponsesInputImage = Schema.Struct({
const OpenResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
detail: Schema.optional(Schema.String),
})
export const OpenResponsesInputFile = Schema.Struct({
const OpenResponsesInputFile = Schema.Struct({
type: Schema.tag("input_file"),
filename: Schema.String,
detail: Schema.optional(Schema.String),
file_data: Schema.optional(Schema.String),
file_url: Schema.optional(Schema.String),
})
@@ -56,7 +54,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
export const OpenResponsesOutputText = Schema.Struct({
const OpenResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"),
text: Schema.String,
})
@@ -64,13 +62,6 @@ export const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
export const MessageMetadata = Schema.Struct({
itemId: Schema.optional(Schema.String),
type: Schema.optional(Schema.Literal("message")),
status: Schema.optional(Schema.String),
phase: Schema.optional(MessagePhase),
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
@@ -81,7 +72,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
text: Schema.String,
})
export const OpenResponsesReasoningItem = Schema.Struct({
const OpenResponsesReasoningItem = Schema.Struct({
type: Schema.tag("reasoning"),
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
@@ -158,30 +149,16 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
Schema.Array(OpenResponsesFunctionCallOutputContent),
])
export const CompactionItem = Schema.Struct({
type: Schema.Literal("compaction"),
id: optionalNull(Schema.String),
encrypted_content: Schema.String,
})
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
Schema.Struct({
role: Schema.tag("user"),
content: Schema.Array(OpenResponsesInputContent),
type: Schema.optional(Schema.Literal("message")),
id: Schema.optional(Schema.String),
status: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
Schema.Struct({
type: Schema.tag("message"),
id: Schema.optionalKey(Schema.String),
role: Schema.tag("assistant"),
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -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({
@@ -304,11 +281,6 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -321,7 +293,6 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -401,7 +372,6 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -417,16 +387,19 @@ export interface ProviderAdapter {
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly provider: LLMRequest["model"]["provider"]
readonly completedCompactions: ReadonlySet<string>
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -518,9 +491,6 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
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
@@ -531,15 +501,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* (
@@ -606,12 +571,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = metadataKey(request.model)
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
const metadata = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
input.push({
role: "developer",
@@ -622,8 +584,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
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
}
@@ -636,10 +597,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
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] })
@@ -650,7 +610,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
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 }),
})),
@@ -658,15 +617,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
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
@@ -743,30 +693,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
return input
})
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const instructions = ProviderShared.joinText(request.system)
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
...(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 } : {}),
@@ -794,7 +727,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
return disabled === undefined ? undefined : !disabled
}
export const allowedToolChoice = (request: LLMRequest) => {
const allowedToolChoice = (request: LLMRequest) => {
const allowed = OpenResponsesOptions.resolve(request).allowedTools
if (!allowed) return undefined
return {
@@ -808,10 +741,11 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
request: LLMRequest,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
...(yield* lowerConversation(request, adapter)),
...lowerGeneration(request),
model: request.model.id,
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
@@ -825,6 +759,13 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
presence_penalty: generation?.presencePenalty,
frequency_penalty: generation?.frequencyPenalty,
...lowerOptions(request),
}
})
@@ -841,7 +782,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
// with a `reasoning_tokens` subset. Pass the totals through and derive the
// non-cached breakdown.
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
if (!usage) return undefined
const cached = usage.input_tokens_details?.cached_tokens
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
@@ -871,12 +812,13 @@ 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,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -920,34 +862,9 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -1021,7 +938,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1034,16 +951,20 @@ const reasoningMetadata = (state: ParserState, item: OutputItem) =>
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1056,15 +977,16 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
{
...state,
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
events,
]
}
if (item.type === "reasoning") {
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1084,16 +1006,18 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
events,
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1165,33 +1089,19 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its 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") {
const active = state.message?.id === item.id
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1207,7 +1117,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: active ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1215,22 +1126,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered || finished.length === 0
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1241,12 +1166,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (item.type === "reasoning") {
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1330,35 +1256,30 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
for (const item of event.response?.output ?? []) {
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending = yield* ToolStream.finishAll(current.id, current.tools)
current = {
...current,
tools: pending.tools,
hasFunctionCall:
current.hasFunctionCall ||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
}
events.push(...pending.events)
}
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
events.push(...pending.events)
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, current.hasFunctionCall),
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
@@ -1370,7 +1291,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.
@@ -1407,9 +1328,12 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1449,16 +1373,20 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item?.type === "reasoning" &&
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
@@ -1468,7 +1396,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1494,16 +1426,16 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
provider: request.model.provider,
completedCompactions: new Set<string>(),
id: adapter.id,
name: adapter.name,
providerMetadataKey: metadataKey(request.model),
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -1516,7 +1448,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
step,
terminal,
},
})
+13 -30
View File
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
@@ -21,14 +20,6 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH
export const ContextManagement = Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
)
export type ContextManagement = typeof ContextManagement.Type
const OpenAIResponsesImageGenerationTool = Schema.Struct({
type: Schema.tag("image_generation"),
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
@@ -87,14 +78,6 @@ const OpenAIResponsesCoreFields = {
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
Schema.Array(
Schema.Struct({
type: Schema.Literal("compaction"),
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
}),
),
),
}
const OpenAIResponsesBody = Schema.Struct({
@@ -142,14 +125,15 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(request, adapter)),
...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),
})
})
@@ -201,11 +184,12 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -239,7 +223,6 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: ResponsesCompaction.make(adapter),
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
-25
View File
@@ -6,13 +6,11 @@ import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/
import {
InvalidProviderOutputError,
InvalidRequestError,
UnsupportedOperationError,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderID,
type TextPart,
type ToolResultPart,
} from "../schema/index.js"
@@ -256,29 +254,6 @@ export const invalidRequest = (message: string, cause?: unknown) =>
reason: new InvalidRequestError({ message, cause }),
})
/**
* Canonical constructor for operations the selected route does not implement.
* Prefer this over `invalidRequest` when the failure is a missing route
* capability rather than a malformed caller input, so consumers can branch on
* `reason._tag` plus `reason.operation` instead of matching message text.
*/
export const unsupportedOperation = (input: {
readonly operation: string
readonly message: string
readonly provider?: ProviderID
readonly route?: string
readonly cause?: unknown
}) =>
new AIError({
reason: new UnsupportedOperationError({
operation: input.operation,
message: input.message,
provider: input.provider,
route: input.route,
cause: input.cause,
}),
})
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
@@ -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 = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
const native = yield* OpenResponses.lowerConversation(request, adapter)
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({
replacement: 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"
@@ -3,7 +3,8 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.OutputItem & {
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -26,8 +27,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
+1 -12
View File
@@ -4,7 +4,6 @@ import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
@@ -45,13 +44,6 @@ const adapter = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.unsupportedOperation({
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -72,8 +64,7 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
@@ -93,6 +84,4 @@ export const protocol = Protocol.make({
},
})
export const compact = ResponsesCompaction.make(adapter)
export * as XAIResponses from "./xai-responses.js"
+1 -3
View File
@@ -1,5 +1,4 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactOperation } from "./route/client.js"
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
@@ -10,9 +9,8 @@ export interface Settings extends Readonly<Record<string, unknown>> {
export interface Definition<
ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
}
export * as ProviderPackage from "./provider-package.js"
+6 -11
View File
@@ -1,7 +1,7 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -102,11 +102,7 @@ const auth = (input: Config) => {
)
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
modelID: string | ModelID,
) =>
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
route.with({
auth: auth(input),
endpoint: endpoint(input, modelID),
@@ -165,11 +161,10 @@ const config = (settings: Settings): Config => {
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<
Settings,
OpenAIProviderOptionsInput,
CompactOperation
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
@@ -57,9 +57,7 @@ const route = Route.make({
}),
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
auth: Auth.none,
transport: AnthropicMessages.transport<
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
>(),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": HEADER_VERSION }),
})
@@ -1,13 +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"> & {
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
readonly contextManagement?: ContextManagement
readonly serviceTier?: OpenAIServiceTier
readonly [key: string]: unknown
}
+3 -9
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
import type { Route, RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
@@ -73,10 +73,7 @@ const defaults = (input: Config) => {
return rest
}
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
route: Route<Body, Prepared, Compact>,
input: Config,
) =>
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: { baseURL: input.baseURL, query: input.queryParams },
@@ -132,10 +129,7 @@ const config = (settings: Settings): Config => {
}
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) => {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
return configure(config(settings)).responses(modelID)
}
+3 -7
View File
@@ -1,5 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
export const id = ProviderID.make("xai")
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
export type XAIProviderOptionsInput = OpenAIOptionsInput
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
@@ -32,7 +32,6 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: XAIResponses.compact,
id: "openai-responses",
provider: id,
providerMetadataKey: "xai",
@@ -103,10 +102,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
modelID,
settings,
) =>
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
+13 -85
View File
@@ -13,7 +13,6 @@ import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
AIErrorReason,
GenerationOptions,
HttpOptions,
@@ -35,12 +34,7 @@ export interface RouteBody<Body> {
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
}
export interface Route<
Body,
Prepared = unknown,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
readonly compact: Compact
export interface Route<Body, Prepared = unknown> {
readonly id: string
readonly provider?: ProviderID
/** ProviderMetadata namespace emitted and consumed by this route. */
@@ -48,15 +42,13 @@ export interface Route<
readonly protocol: ProtocolID
readonly endpoint: Endpoint.Definition<Body>
readonly auth: Auth.Definition
/** Deployment headers resolved once for every operation, before transport authentication. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options, Compact>
) => LanguageModel<Options>
readonly prepareTransport: (
body: Body,
request: LLMRequest,
@@ -74,11 +66,7 @@ export interface Route<
// Normal call sites use `OpenAIChat.route`; callers only need body types
// when preparing a request with a protocol-specific type assertion.
// oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
any,
any,
Compact
>
export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input
@@ -111,15 +99,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
route: AnyRoute<Compact>,
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
return LanguageModel.make<Options>({
...mapped,
provider,
route,
@@ -162,10 +150,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
}
export interface Interface {
readonly compact: (
request: CompactionRequest,
options?: Pick<StreamOptions, "http">,
) => Effect.Effect<CompactionResponse, AIError>
readonly stream: StreamMethod
readonly generate: GenerateMethod
}
@@ -183,19 +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 & {
readonly model: 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) => {
@@ -216,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. */
@@ -238,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. */
@@ -314,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,
@@ -343,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,
@@ -351,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,
}),
@@ -440,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>
@@ -473,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,
@@ -486,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
@@ -557,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* () {
@@ -576,32 +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.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${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,
-16
View File
@@ -35,21 +35,6 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
},
) {}
/**
* A caller-requested operation the selected route does not implement, such as
* explicit compaction on a route without a compact endpoint. Detected locally
* before any network I/O, so unlike transport or provider-output failures it
* never carries HTTP context from a provider round-trip.
*/
export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOperationError>(
"AI.Error.UnsupportedOperation",
)("UnsupportedOperation", {
...ReasonFields,
operation: Schema.String,
provider: Schema.optional(ProviderID),
route: Schema.optional(RouteID),
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
@@ -122,7 +107,6 @@ export class UnknownProviderError extends Schema.TaggedError<UnknownProviderErro
export const AIErrorReason = Schema.Union([
InvalidRequestError,
UnsupportedOperationError,
NoRouteError,
AuthenticationError,
RateLimitError,
+4 -18
View File
@@ -3,7 +3,6 @@ import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
CompactionPart,
ProviderMetadata,
ToolCallPart,
ToolOutput,
@@ -63,8 +62,6 @@ export { ProviderMetadata } from "./messages.js"
* Matches the same escape-hatch field on `LLMEvent`.
*/
export class Usage extends Schema.Class<Usage>("AI.Usage")({
/** Effective input size of the final message iteration, when reported; not billed totals. */
contextTokens: Schema.optional(Schema.Number),
inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number),
@@ -75,7 +72,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
* to zero. The one place subtraction happens in this contract; the clamp
* means a provider reporting `reasoningTokens > outputTokens` produces a
* harmless zero rather than a negative that crashes downstream schemas.
@@ -91,12 +88,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
/** A replacement context window, not an assistant message to append to prior history. */
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
replacement: Schema.Array(Message),
usage: Schema.optional(Usage),
}) {}
export const StepStart = Schema.Struct({
type: Schema.tag("step-start"),
index: Schema.Number,
@@ -250,7 +241,6 @@ export const ProviderErrorEvent = Schema.Struct({
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
const llmEventTagged = Schema.Union([
CompactionPart,
StepStart,
TextStart,
TextDelta,
@@ -284,7 +274,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
* `events.filter(LLMEvent.guards["tool-call"])`.
*/
export const LLMEvent = Object.assign(llmEventTagged, {
compaction: CompactionPart.make,
stepStart: StepStart.make,
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
@@ -322,7 +311,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
}),
providerError: ProviderErrorEvent.make,
is: {
compaction: llmEventTagged.guards.compaction,
stepStart: llmEventTagged.guards["step-start"],
textStart: llmEventTagged.guards["text-start"],
textDelta: llmEventTagged.guards["text-delta"],
@@ -345,10 +333,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = (
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
events: ReadonlyArray<LLMEvent>,
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
) => {
const order: string[] = []
const parts = new Map<string, string>()
@@ -575,8 +563,6 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
const next = appendEvent(state, event)
switch (event.type) {
case "compaction":
return appendContent(next, event)
case "text-start":
return ensureText(next, event.id, event.providerMetadata)
case "text-delta":
+8 -66
View File
@@ -7,10 +7,8 @@ import {
HttpOptions,
JsonSchema,
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
} from "./options.js"
import { ProviderID } from "./ids.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
@@ -54,7 +52,6 @@ export const MediaPart = Schema.Struct({
filename: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
@@ -188,40 +185,9 @@ export const ReasoningPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Reasoning" })
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
/** A provider-generated context checkpoint, distinct from visible assistant text. */
type CompactionContent =
| { readonly encrypted: string; readonly text?: never }
| { readonly text: string | null; readonly encrypted?: never }
const compactionPartSchema = Schema.Struct({
type: Schema.Literal("compaction"),
provider: ProviderID,
id: Schema.optional(Schema.String),
encrypted: Schema.optional(Schema.String),
/** Null means the provider failed to produce a summary; prior history must be retained. */
text: Schema.optional(Schema.NullOr(Schema.String)),
})
.pipe(
Schema.refine(
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
{ message: "Compaction requires either encrypted content or a summary" },
),
)
.annotate({ identifier: "LLM.Content.Compaction" })
export type CompactionPart = typeof compactionPartSchema.Type
export const CompactionPart = Object.assign(compactionPartSchema, {
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
ToolCallPart,
ToolResultPart,
ReasoningPart,
CompactionPart,
]).pipe(Schema.toTaggedUnion("type"))
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
Schema.toTaggedUnion("type"),
)
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
export class Message extends Schema.Class<Message>("LLM.Message")({
@@ -229,7 +195,6 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
role: MessageRole,
content: Schema.Array(ContentPart),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
@@ -307,7 +272,7 @@ export namespace ToolChoice {
}
}
const requestSchema = Schema.Struct({
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String),
model: LanguageModelSchema,
system: Schema.Array(SystemPart),
@@ -321,26 +286,12 @@ const requestSchema = Schema.Struct({
// Stable cache affinity for protocols that support provider-managed prompt caching.
promptCacheKey: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
requestSchema.fields,
) {
declare readonly model: Model
// Preserve model inference instead of inheriting the schema's erased constructor signature.
// oxlint-disable-next-line no-useless-constructor
constructor(input: LLMRequest.Input<Model>) {
super(input)
}
}
}) {}
export namespace LLMRequest {
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
readonly model: Model
}
export type Input = ConstructorParameters<typeof LLMRequest>[0]
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
export const input = (request: LLMRequest): Input => ({
id: request.id,
model: request.model,
system: request.system,
@@ -355,16 +306,7 @@ export namespace LLMRequest {
metadata: request.metadata,
})
export function update<Model extends LanguageModel>(
request: LLMRequest,
patch: Partial<Input<Model>> & { readonly model: Model },
): LLMRequest<Model>
export function update<Model extends LanguageModel>(
request: LLMRequest<Model>,
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
): LLMRequest<Model>
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
export function update(request: LLMRequest, patch: Partial<Input>) {
export const update = (request: LLMRequest, patch: Partial<Input>) => {
if (Object.keys(patch).length === 0) return request
return new LLMRequest({
...input(request),
+10 -34
View File
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute, CompactOperation } from "../route/client.js"
import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -173,18 +173,15 @@ export namespace LanguageModelCompatibility {
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
}
export class LanguageModel<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
> {
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
constructor(input: LanguageModel.ConstructorInput<Compact>) {
constructor(input: LanguageModel.ConstructorInput) {
this.id = input.id
this.provider = input.provider
this.route = input.route
@@ -192,11 +189,8 @@ export class LanguageModel<
this.compatibility = input.compatibility
}
static make<
Options extends ProviderOptions = ProviderOptions,
Compact extends CompactOperation | undefined = CompactOperation | undefined,
>(input: LanguageModel.Input<Compact>) {
return new LanguageModel<Options, Compact>({
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new LanguageModel<Options>({
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: input.route,
@@ -206,9 +200,7 @@ export class LanguageModel<
})
}
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
): LanguageModel.ConstructorInput<Compact> {
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
return {
id: model.id,
provider: model.provider,
@@ -218,41 +210,25 @@ export class LanguageModel<
}
}
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
model: LanguageModel<Options, Compact>,
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
): LanguageModel<Options, Compact>
static update<Options extends ProviderOptions>(
model: LanguageModel<Options>,
patch: Partial<LanguageModel.Input>,
): LanguageModel<Options>
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model
return LanguageModel.make<Options>({
...LanguageModel.input(model),
...patch,
route: patch.route ?? model.route,
})
}
}
export namespace LanguageModel {
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
export type ConstructorInput = {
readonly id: ModelID
readonly provider: ProviderID
readonly route: AnyRoute<Compact>
readonly route: AnyRoute
readonly defaults?: LanguageModelDefaults
readonly compatibility?: LanguageModelCompatibility
}
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
ConstructorInput<Compact>,
"id" | "provider" | "defaults" | "compatibility"
> & {
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID
readonly provider: string | ProviderID
readonly defaults?: LanguageModelDefaults.Input
+11 -25
View File
@@ -4,7 +4,6 @@ import { LLMClient } from "./route/client.js"
import {
LLMEvent,
LLMResponse,
CompactionResponse,
type FinishReasonDetails,
type AIError,
type LLMRequest,
@@ -13,7 +12,7 @@ import {
} from "./schema/index.js"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -100,6 +99,8 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
const make = (options: LayerOptions) =>
Effect.sync(() => {
const requests: LLMRequest[] = []
@@ -112,41 +113,26 @@ const make = (options: LayerOptions) =>
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
)
const take = (request: LLMRequest) =>
Effect.suspend(() => {
const stream: ClientInterface["stream"] = (request) =>
Stream.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request)
const waiting = started
started = Deferred.makeUnsafe()
const gate = activeGate
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
if (!gate) return Effect.succeed(response)
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
const streamed = toStream(response)
if (!gate) return streamed
return Stream.unwrap(
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
)
} finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void)
}
})
const stream: ClientInterface["stream"] = (request) =>
Stream.unwrap(
take(request).pipe(
Effect.map((response) => {
if (response instanceof CompactionResponse)
return Stream.die("TestLLM generation requires an event response")
return Stream.isStream(response) ? response : Stream.fromIterable(response)
}),
),
)
const test = Test.of({
compact: (request) =>
take(request).pipe(
Effect.flatMap((response) =>
response instanceof CompactionResponse
? Effect.succeed(response)
: Effect.die("TestLLM compaction requires a CompactionResponse"),
),
),
stream,
generate: (request) =>
stream(request).pipe(
-1
View File
@@ -56,7 +56,6 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
role: message.role,
content,
metadata: message.metadata,
providerMetadata: message.providerMetadata,
native: message.native,
})
}
-97
View File
@@ -1,97 +0,0 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { LLM, LLMRequest, Message } from "../src/index.js"
import { LLMClient } from "../src/route/client.js"
import { OpenAI } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { runtimeLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
Effect.gen(function* () {
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
const calls: string[] = []
const server = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const path = new URL(request.url).pathname
calls.push(path)
const body = await request.json()
expect(request.headers.get("authorization")).toBe("Bearer fixture")
if (path === "/v1/responses/compact") {
expect(body.stream).toBeUndefined()
return Response.json({
object: "response.compaction",
output: [checkpoint],
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
})
}
expect(body.input[0]).toEqual(checkpoint)
expect(body.stream).toBe(true)
if (calls.length === 2)
return new Response(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
{ headers: { "content-type": "text/event-stream" } },
)
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
const output = sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
},
{ type: "response.completed", response: { id: "resp_2" } },
)
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
controller.enqueue(new TextEncoder().encode(output.slice(37)))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
}),
),
(server) => Effect.sync(() => server.stop(true)),
)
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
"fixture",
)
const request = LLM.request({
model,
prompt: "original",
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
})
const compacted = yield* LLMClient.compact(request)
const messages = [...compacted.replacement, 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"])
}),
)
-101
View File
@@ -1,101 +0,0 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CompactionPart, CompactionResponse, 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"
import { testEffect } from "./lib/effect.js"
import { fixedResponse } from "./lib/http.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("explicit compaction serializes a replacement window without a messages alias", () => {
const response = new CompactionResponse({
replacement: [
Message.user("retained input"),
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
],
})
const codec = Schema.fromJsonString(CompactionResponse)
const decoded = Schema.decodeSync(codec)(Schema.encodeSync(codec)(response))
expect(decoded.replacement).toEqual(response.replacement)
expect("messages" in decoded).toBe(false)
})
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)
}
})
testEffect(fixedResponse("")).effect(
"explicit compaction on a route without a compact endpoint fails with UnsupportedOperation",
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("fixture"),
prompt: "hello",
})
expect(LLMClient.canCompact(request)).toBe(false)
const error = yield* LLMClient.compact(
request as unknown as Parameters<typeof LLMClient.compact>[0],
).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") {
expect(error.reason.operation).toBe("compact")
expect(error.reason.provider).toBe("anthropic")
expect(error.reason.route).toBe("anthropic-messages")
}
}),
)
@@ -1,133 +0,0 @@
import { Effect } from "effect"
import {
CompactionPart,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
} from "../../src/index.js"
import { OpenAI, Azure, XAI, Anthropic, 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") }),
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 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.replacement satisfies ReadonlyArray<Message>
// @ts-expect-error The replacement window is named explicitly; the old field is not an alias.
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" }],
},
})
const anthropic = Anthropic.configure().model("claude-opus-4-6")
LLM.request({
model: anthropic,
providerOptions: {
contextManagement: {
edits: [{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" }],
},
},
})
LLM.request({
model: anthropic,
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,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.replacement,
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,139 +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("mints an id for terminal checkpoints that omit one", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
}),
)
@@ -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,456 +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, OpenAICompatibleResponses } 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, tag] of [
[
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
"InvalidRequest",
],
[
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
model.provider === "xai" ? "UnsupportedOperation" : "InvalidRequest",
],
] as const) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe(tag)
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[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.replacement.map((message) => message.role)).toEqual([
"user",
"assistant",
"assistant",
"assistant",
"user",
"assistant",
])
expect(compacted.replacement[1]?.content).toEqual([
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
])
expect(compacted.replacement[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
expect(compacted.replacement[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.replacement))
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.replacement))
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("UnsupportedOperation")
expect(error.message).toContain("LLMClient.compact")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction")
}),
)
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.replacement.map((message) => message.role)).toEqual(["user", "assistant"])
expect(compacted.replacement[0]?.content).toEqual([{ type: "text", text: "retained" }])
expect(compacted.replacement[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.replacement))
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
}),
)
}
for (const model of [
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
OpenAICompatibleResponses.configure({ apiKey: "test", baseURL: "https://compatible.example/v1" }).model("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("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact")
}),
)
}
testEffect(
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toContain("original payload")
expect(error.reason.http?.status).toBe(200)
}),
)
@@ -1,60 +0,0 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message } from "../../src/index.js"
import { OpenAI, Azure, XAI } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const model of [
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
XAI.configure({ apiKey: "test" }).responses("fixture"),
]) {
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
Effect.gen(function* () {
const details = [undefined, "low", "high", "auto"]
const message = Message.user(
details.map((detail) => ({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata:
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
})),
)
const codec = Schema.fromJsonString(Message)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
}),
)
expect(prepared.body.input[0].content).toEqual(
details.map((detail) => ({
type: "input_image",
image_url: "https://example.com/image.png",
detail,
})),
)
}),
)
}
it.effect("rejects malformed image detail instead of silently discarding it", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "https://example.com/image.png",
providerMetadata: { openai: { detail: 42 } },
}),
],
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -217,7 +217,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,11 +230,17 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -266,121 +272,126 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("mints an id for a done-only tool that never had one", () =>
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -425,6 +436,21 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -474,15 +500,14 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,21 +586,23 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -608,6 +610,43 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a streamed tool call with only the new tool output", () =>
it.effect("continues an item-id-less tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,7 +485,6 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2130,6 +2129,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2349,7 +2389,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2850,7 +2890,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item start events", () =>
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2881,6 +2921,25 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -3734,6 +3793,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3921,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3936,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -3958,6 +4053,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
-8
View File
@@ -24,7 +24,6 @@ import {
ToolResultValue,
TransportError,
UnknownProviderError,
UnsupportedOperationError,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
@@ -277,12 +276,6 @@ test("AI errors serialize diagnostics only on their typed reason", () => {
test("AI error reasons are tagged Errors with required messages", () => {
const reasons = [
new InvalidRequestError({ message: "Invalid request" }),
new UnsupportedOperationError({
message: "Unsupported operation",
operation: "compact",
provider: model.provider,
route: "fake-route",
}),
new NoRouteError({
message: "No route",
route: RouteID.make("missing"),
@@ -300,7 +293,6 @@ test("AI error reasons are tagged Errors with required messages", () => {
]
expect(reasons.map((reason) => reason._tag)).toEqual([
"InvalidRequest",
"UnsupportedOperation",
"NoRoute",
"Authentication",
"RateLimit",
+1 -62
View File
@@ -1,18 +1,5 @@
import { describe, expect } from "bun:test"
import {
AIError,
CompactionPart,
CompactionResponse,
LanguageModel,
LLM,
LLMClient,
LLMEvent,
LLMRequest,
Message,
ProviderID,
RateLimitError,
} from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
import { OpenAIChat } from "../src/protocols/openai-chat.js"
import { TestLLM } from "../src/testing.js"
import { Effect, Fiber, Latch, Stream } from "effect"
@@ -79,54 +66,6 @@ describe("TestLLM legacy client", () => {
})
describe("TestLLM first-class client", () => {
it.effect("rejects response fixtures for the wrong operation", () =>
Effect.gen(function* () {
const client = yield* TestLLM.Test
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
yield* client.push(TestLLM.stop(), new CompactionResponse({ replacement: [] }))
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({
replacement: [
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.replacement })
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
@@ -11,7 +11,6 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -123,7 +122,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean | TimelineDetail>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -92,8 +92,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.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(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", 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))
@@ -116,8 +114,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.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(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", 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()
@@ -67,7 +67,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
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(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -88,7 +87,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.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(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -30,11 +30,9 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -68,15 +66,6 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -67,12 +67,6 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -101,7 +95,9 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
@@ -225,8 +221,7 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
})
return requests
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -17,9 +16,6 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -37,9 +36,7 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -71,9 +68,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -105,7 +100,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -24,23 +23,20 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -50,9 +46,6 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -102,9 +95,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -119,7 +110,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -127,13 +118,7 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -156,11 +141,28 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -170,9 +172,6 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -50,9 +49,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -78,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
const notices = page.locator('[data-slot="session-timeline-notice"]')
await expect(notices).toHaveCount(4)
await expect(notices.nth(0)).toHaveText(/^Agent changed\s*Explore$/)
await expect(notices.nth(0)).toContainText("Agent · explore")
await expect(notices.nth(1)).toContainText("explore finished · Search code")
await expect(notices.nth(2)).toContainText("Continuing after restart")
await expect(notices.nth(3)).toContainText("Skill · Review")
@@ -88,12 +84,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
@@ -170,27 +161,27 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
await expect(card).not.toContainText("(background)")
await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0)
await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0)
const hint = page.getByRole("button", { name: /move running work to the background/i })
const hint = page.locator('[data-component="session-background-hint"]')
const hintPrefix = hint.locator('[data-slot="session-background-hint-prefix"]')
await expect(hint).toBeVisible()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect
.poll(async () => {
const [cardBox, hintBox] = await Promise.all([card.boundingBox(), hint.boundingBox()])
if (!cardBox || !hintBox) return undefined
const [cardBox, hintBox, prefixBox] = await Promise.all([
card.boundingBox(),
hint.boundingBox(),
hintPrefix.boundingBox(),
])
if (!cardBox || !hintBox || !prefixBox) return undefined
return {
aligned: Math.abs(cardBox.x - hintBox.x) < 2,
aligned: Math.abs(cardBox.x - prefixBox.x) < 2,
ordered: cardBox.y < hintBox.y,
}
})
@@ -207,25 +198,21 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
})
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.locator('[data-component="task-tool-card"]').click()
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
await expect(page.getByRole("button", { name: /move running work to the background/i })).toHaveCount(0)
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -262,7 +249,7 @@ for (const name of ["shell", "subagent"] as const) {
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
@@ -281,9 +268,9 @@ test("shows a badge for active background work", async ({ page }) => {
})
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "1 background task running", exact: true })
const summary = page.getByRole("button", { name: "1 item running in background" })
await expect(summary).toContainText("1")
await expect(summary).toContainText("1 background task running")
await expect(summary).toContainText("Running work in background")
await summary.click()
await expect(
page.locator('[data-component="session-background-list"]').getByText("Agent", { exact: true }),
@@ -294,7 +281,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -382,22 +368,14 @@ test("separates blocking and already-backgrounded work into two rows", async ({
},
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByRole("button", { name: /move running work to the background/i })).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 background tasks running", exact: true })
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
await summary.click()
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -15,9 +14,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -37,7 +34,6 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -114,11 +110,8 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -134,7 +127,6 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,49 +5,135 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
const settings = page.getByTestId("settings-screen")
await page.keyboard.press("Control+,")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
}
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
messages: [
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -161,19 +161,17 @@ test("keeps failed search calls and their error cards inside the collapsed stack
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-component="collapsed-tool-group"]')
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
"href",
"#opencode-v2-icon-outline-hexagonal-warning",
"#opencode-v2-icon-circle-exclamation",
)
await expect
.poll(() =>
@@ -182,7 +180,6 @@ test("keeps failed search calls and their error cards inside the collapsed stack
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
@@ -1,5 +1,4 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -75,29 +74,21 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
await setupTimeline(page, {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -115,6 +106,8 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -8,11 +7,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("keeps shell and question failures in their Used group", async ({ page }) => {
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -24,46 +23,30 @@ test("keeps shell and question failures in their Used group", async ({ page }) =
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -94,12 +77,9 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -119,20 +99,23 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -165,10 +148,7 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -20,9 +19,6 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -55,9 +51,7 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -95,8 +89,13 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -134,7 +133,7 @@ for (const name of ["read", "shell", "subagent"] as const) {
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(working).toBeInViewport()
if (name !== "read") {
const hint = page.getByRole("button", { name: /move running work to the background/i })
const hint = page.locator('[data-component="session-background-hint"]')
await expect(hint).toBeInViewport()
await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px")
await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) })
@@ -260,7 +259,6 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
@@ -32,24 +32,6 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -9,42 +9,30 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
test("new session tab hugs its content", async ({ page }) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, directory }) => {
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionID },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
{ server, sessionID: sessionA.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -194,7 +182,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -292,7 +280,9 @@ test("appearance experimental settings control vertical tab details", async ({ p
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"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
@@ -78,13 +78,9 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -53,8 +53,9 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -75,23 +76,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+1 -2
View File
@@ -26,8 +26,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+1 -6
View File
@@ -2,11 +2,6 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+2 -23
View File
@@ -47,7 +47,6 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -282,24 +281,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -692,7 +673,6 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -721,7 +701,6 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -770,9 +749,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-faint"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+1 -15
View File
@@ -3,8 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -46,18 +45,6 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -118,7 +105,6 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,7 +14,6 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -80,35 +79,6 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,8 +37,6 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
-7
View File
@@ -57,8 +57,6 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -672,11 +670,6 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
@@ -21,8 +21,7 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -46,7 +45,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -174,7 +172,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
@@ -50,7 +50,6 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
@@ -1,6 +1,5 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -16,7 +15,6 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -186,14 +184,7 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
-1
View File
@@ -134,7 +134,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
-1
View File
@@ -46,7 +46,6 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
-1
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
-1
View File
@@ -136,7 +136,6 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
-1
View File
@@ -136,7 +136,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
-38
View File
@@ -100,7 +100,6 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -676,14 +675,11 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -955,40 +951,6 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
-1
View File
@@ -40,7 +40,6 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
-1
View File
@@ -41,7 +41,6 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",

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