mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-04 07:56:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c553a08b7 |
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
-38
@@ -247,8 +247,6 @@ 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.
|
||||
|
||||
For `compact(request, { mechanism: "trigger" })`, script a `CompactionCheckpointResponse` instead. It carries `checkpoint`, `responseID`, and optional `usage`. Endpoint and trigger calls reject each other's fixtures; both share the same queue, gates, lazy recording, and fallback controls.
|
||||
|
||||
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`.
|
||||
@@ -261,7 +259,7 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` (equivalently, `{ mechanism: "endpoint" }`) 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. This mechanism does not accept a WebSocket executor.
|
||||
`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.
|
||||
|
||||
@@ -281,41 +279,6 @@ Generation-only body overlays such as `stream` and `store` are not sent to the c
|
||||
|
||||
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.
|
||||
|
||||
### Streamed checkpoint compaction
|
||||
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request. It follows the [Codex V2 request shape](https://github.com/openai/codex/blob/728cb12/codex-rs/core/src/compact_remote_v2_attempt.rs), with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Session/cache identifiers, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
|
||||
Only a successful `response.completed` with a response ID and exactly one logical encrypted checkpoint succeeds. Repeated item events are correlated by ID/output slot, including ID-less checkpoints. Other output is ignored, not returned as assistant text or dispatched as tools. Failed, incomplete, malformed, and interrupted responses return errors rather than partial checkpoints.
|
||||
|
||||
The result is **not a replacement window**. The caller selects retained history, combines it with `result.checkpoint`, and durably installs it before continuing. The operation does not choose a retention budget, prune messages, or modify the original request.
|
||||
|
||||
The supplied WebSocket executor can reuse a compatible append baseline for the compaction request. On completion the protocol supplies no continuation checkpoint, clearing the old baseline so the next generation sends the newly installed window in full. Validation occurs before transport completion is acknowledged. There is no operation-level retry or fallback to `/responses/compact`; existing safe transport fallback may use SSE, with full history and no connection-local response ID.
|
||||
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -42,7 +42,6 @@ const canonical = (value: unknown): string => {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
|
||||
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
|
||||
return `{${Object.keys(value)
|
||||
.filter((key) => value[key] !== undefined)
|
||||
.sort()
|
||||
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
|
||||
.join(",")}}`
|
||||
@@ -150,12 +149,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
if (
|
||||
Array.isArray(request.input) &&
|
||||
request.input.some((item) => ProviderShared.isRecord(item) && item.type === "compaction_trigger")
|
||||
)
|
||||
return observation
|
||||
const responseID = event.response?.id
|
||||
if (!responseID || responseID.trim().length === 0) return observation
|
||||
return {
|
||||
|
||||
@@ -304,11 +304,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 +316,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 +395,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
|
||||
@@ -423,10 +416,15 @@ export interface ParserState {
|
||||
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>>
|
||||
}
|
||||
|
||||
@@ -877,6 +875,9 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
|
||||
[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,38 +921,9 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
const outputItemID = (state: Pick<ParserState, "outputItems">, 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: Pick<ParserState, "outputItems">,
|
||||
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: Pick<ParserState, "outputItems">, 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]
|
||||
@@ -1025,7 +997,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:
|
||||
@@ -1038,16 +1010,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,
|
||||
@@ -1060,15 +1036,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 [
|
||||
@@ -1088,16 +1065,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 ?? "",
|
||||
@@ -1169,13 +1148,13 @@ 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 (!item.id || typeof item.encrypted_content !== "string")
|
||||
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
|
||||
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
@@ -1192,10 +1171,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
] 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) {
|
||||
@@ -1211,7 +1195,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
|
||||
@@ -1219,22 +1204,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 [
|
||||
@@ -1245,12 +1244,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 : []
|
||||
@@ -1334,17 +1334,21 @@ 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 !== "compaction" && item.type !== "function_call") continue
|
||||
if (item.type === "compaction") {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
}
|
||||
if (
|
||||
item.type === "function_call" &&
|
||||
(!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)
|
||||
@@ -1411,9 +1415,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(
|
||||
@@ -1453,16 +1460,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,
|
||||
),
|
||||
@@ -1472,7 +1483,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")
|
||||
@@ -1505,9 +1520,11 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
@@ -1520,7 +1537,7 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(Event),
|
||||
initial,
|
||||
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
|
||||
step,
|
||||
terminal,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -104,18 +103,6 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
/** Request control, never conversation content. */
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -175,35 +162,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
})
|
||||
})
|
||||
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
overlay?.input !== undefined ||
|
||||
overlay?.previous_response_id !== undefined ||
|
||||
overlay?.conversation !== undefined
|
||||
)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
}),
|
||||
}
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
@@ -243,11 +201,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)
|
||||
@@ -281,7 +240,7 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -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,122 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
interface State {
|
||||
readonly parser: Pick<OpenResponses.ParserState, "id" | "provider" | "outputItems">
|
||||
readonly checkpoints: Readonly<Record<string, CompactionCheckpointResponse["checkpoint"]>>
|
||||
readonly responseID?: string
|
||||
}
|
||||
|
||||
const onOutputItem = Effect.fn("ResponsesCheckpoint.onOutputItem")(function* (
|
||||
state: State,
|
||||
input: OpenResponses.Event,
|
||||
) {
|
||||
const event = OpenResponses.normalize(state.parser, input)
|
||||
const item = event.item
|
||||
if (!item) return state
|
||||
const parser =
|
||||
event.output_index === undefined || state.parser.outputItems[event.output_index] === item.id
|
||||
? state.parser
|
||||
: { ...state.parser, outputItems: { ...state.parser.outputItems, [event.output_index]: item.id } }
|
||||
const next = parser === state.parser ? state : { ...state, parser }
|
||||
if (event.type === "response.output_item.added" || item.type !== "compaction") return next
|
||||
if (
|
||||
event.output_index !== undefined &&
|
||||
Object.entries(state.parser.outputItems).some(
|
||||
([index, id]) => id === item.id && Number(index) !== event.output_index,
|
||||
)
|
||||
)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction checkpoint appeared in multiple output slots")
|
||||
if (!item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output is missing its encrypted content")
|
||||
const previous = state.checkpoints[item.id]
|
||||
if (previous && previous.encrypted !== item.encrypted_content)
|
||||
return yield* ProviderShared.eventError(parser.id, "Compaction output changed after completion")
|
||||
if (previous) return next
|
||||
return {
|
||||
...next,
|
||||
checkpoints: {
|
||||
...state.checkpoints,
|
||||
[item.id]: { type: "compaction", provider: parser.provider, id: item.id, encrypted: item.encrypted_content },
|
||||
},
|
||||
} satisfies State
|
||||
})
|
||||
|
||||
/** Collect a trigger response before acknowledging transport completion. No generation output escapes. */
|
||||
export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
Effect.fn("ResponsesCheckpoint.execute")(function* (request, executor, options) {
|
||||
const source = request.model.route
|
||||
let result: CompactionCheckpointResponse | undefined
|
||||
// Route registries erase the frame type. The codec validates that boundary before parsing.
|
||||
const event: Schema.Codec<OpenResponses.Event, unknown> = OpenResponses.protocol.stream.event
|
||||
const protocol = Protocol.make({
|
||||
id: source.protocol,
|
||||
body,
|
||||
stream: {
|
||||
event,
|
||||
initial: (request: LLMRequest): State => ({
|
||||
parser: { id: source.id, provider: request.model.provider, outputItems: {} },
|
||||
checkpoints: {},
|
||||
}),
|
||||
terminal: OpenResponses.terminal,
|
||||
step: Effect.fn("ResponsesCheckpoint.step")(function* (state: State, event: OpenResponses.Event) {
|
||||
if (event.response?.id && state.responseID && event.response.id !== state.responseID)
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response ID changed during execution")
|
||||
if (event.type === "response.created") return [{ ...state, responseID: event.response?.id }, []] as const
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return yield* OpenResponses.providerFailure(event, "Compaction request failed")
|
||||
if (event.type === "response.incomplete")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response was incomplete")
|
||||
if (event.type === "response.output_item.added" || event.type === "response.output_item.done")
|
||||
return [yield* onOutputItem(state, event), []] as const
|
||||
if (event.type !== "response.completed") return [state, []] as const
|
||||
const responseID = event.response?.id
|
||||
if (!responseID?.trim())
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response is missing its response ID")
|
||||
if (event.response?.status !== undefined && event.response.status !== "completed")
|
||||
return yield* ProviderShared.eventError(source.id, "Compaction response did not complete successfully")
|
||||
let next = state
|
||||
for (const [index, item] of (event.response?.output ?? []).entries()) {
|
||||
next = yield* onOutputItem(next, { type: "response.output_item.done", output_index: index, item })
|
||||
}
|
||||
const checkpoints = Object.values(next.checkpoints)
|
||||
const checkpoint = checkpoints[0]
|
||||
if (checkpoints.length !== 1 || !checkpoint)
|
||||
return yield* ProviderShared.eventError(
|
||||
source.id,
|
||||
"Compaction response must contain exactly one checkpoint",
|
||||
)
|
||||
result = new CompactionCheckpointResponse({
|
||||
checkpoint,
|
||||
responseID,
|
||||
usage: OpenResponses.mapUsage(event.response?.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
return [next, [LLMEvent.finish({ reason: { normalized: "stop" } })]] as const
|
||||
}),
|
||||
},
|
||||
})
|
||||
const route = Route.make({
|
||||
id: source.id,
|
||||
provider: source.provider,
|
||||
providerMetadataKey: source.providerMetadataKey,
|
||||
protocol,
|
||||
endpoint: source.endpoint,
|
||||
auth: source.auth,
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
export * as ResponsesCheckpoint from "./responses-checkpoint.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,
|
||||
|
||||
@@ -46,12 +46,9 @@ 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* ProviderShared.invalidRequest(
|
||||
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
)
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
|
||||
@@ -72,8 +69,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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,7 +10,7 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
}
|
||||
|
||||
@@ -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, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } 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"
|
||||
@@ -39,7 +39,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
|
||||
const responsesRoute = OpenAIResponses.route.with({
|
||||
compact: { endpoint: OpenAIResponses.route.compact.endpoint },
|
||||
id: "azure-openai-responses",
|
||||
provider: id,
|
||||
auth: routeAuth,
|
||||
@@ -103,7 +102,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
@@ -169,7 +168,7 @@ const config = (settings: Settings): Config => {
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } 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,7 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactionOperations | undefined>(
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
@@ -132,11 +132,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
typeof OpenAIResponses.route.compact
|
||||
>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } 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"
|
||||
@@ -32,7 +32,7 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,11 +103,10 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
+35
-113
@@ -14,7 +14,6 @@ import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -39,7 +38,7 @@ export interface RouteBody<Body> {
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
readonly id: string
|
||||
@@ -54,15 +53,7 @@ export interface Route<
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
): Route<Body, Prepared, Next>
|
||||
(
|
||||
patch: Omit<RoutePatch<Body, Prepared>, "compact"> & { readonly compact?: undefined },
|
||||
): Route<Body, Prepared, Compact>
|
||||
(patch: RoutePatch<Body, Prepared>): Route<Body, Prepared>
|
||||
}
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
@@ -83,7 +74,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 CompactionOperations | undefined = CompactionOperations | undefined> = Route<
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
@@ -110,7 +101,6 @@ export interface RouteDefaultsInput {
|
||||
}
|
||||
|
||||
export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly id?: string
|
||||
readonly provider?: string | ProviderID
|
||||
readonly providerMetadataKey?: string
|
||||
@@ -121,7 +111,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
@@ -172,7 +162,10 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: CompactMethod
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -196,64 +189,12 @@ export type CompactOperation = (
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type TriggerCompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options: TriggerCompactOptions,
|
||||
) => Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
|
||||
/** Protocol capabilities, not deployment/model eligibility. */
|
||||
export interface CompactionOperations {
|
||||
readonly endpoint?: CompactOperation
|
||||
readonly trigger?: TriggerCompactOperation
|
||||
}
|
||||
|
||||
export interface EndpointCompactOptions extends Pick<StreamOptions, "http"> {
|
||||
readonly mechanism?: "endpoint"
|
||||
readonly webSocket?: never
|
||||
}
|
||||
|
||||
export interface TriggerCompactOptions extends StreamOptions {
|
||||
readonly mechanism: "trigger"
|
||||
}
|
||||
|
||||
// Keep the required route shape explicit: the schema class's self type erases its model parameter in assignability.
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly endpoint: CompactOperation }>
|
||||
}
|
||||
export type CheckpointRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, { readonly trigger: TriggerCompactOperation }>
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
}
|
||||
|
||||
export interface CompactMethod<R = never> {
|
||||
(request: CheckpointRequest, options: TriggerCompactOptions): Effect.Effect<CompactionCheckpointResponse, AIError, R>
|
||||
(request: CompactionRequest, options?: EndpointCompactOptions): Effect.Effect<CompactionResponse, AIError, R>
|
||||
}
|
||||
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options?: { readonly mechanism?: "endpoint" },
|
||||
): request is CompactionRequest
|
||||
export function canCompact(
|
||||
request: LLMRequest,
|
||||
options: { readonly mechanism: "trigger" },
|
||||
): request is CheckpointRequest
|
||||
export function canCompact(request: LLMRequest, options?: { readonly mechanism?: string }) {
|
||||
if (options?.mechanism === "trigger") return request.model.route.compact?.trigger !== undefined
|
||||
if (options?.mechanism !== undefined && options.mechanism !== "endpoint") return false
|
||||
return request.model.route.compact?.endpoint !== undefined
|
||||
}
|
||||
|
||||
const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefined) => {
|
||||
if (mechanism !== undefined && mechanism !== "endpoint" && mechanism !== "trigger")
|
||||
return ProviderShared.invalidRequest(`Unknown compaction mechanism: ${mechanism}`)
|
||||
return ProviderShared.unsupportedOperation({
|
||||
operation: mechanism === "trigger" ? "compact.trigger" : "compact",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: `${request.model.provider}/${request.model.route.id} does not support ${mechanism === "trigger" ? "trigger" : "explicit"} compaction`,
|
||||
})
|
||||
}
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
@@ -275,7 +216,7 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -297,7 +238,7 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactionOperations
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -385,10 +326,9 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
const { id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
...routeInput,
|
||||
compact: "compact" in patch ? compact : routeInput.compact,
|
||||
id: id ?? routeInput.id,
|
||||
provider: provider ?? routeInput.provider,
|
||||
providerMetadataKey:
|
||||
@@ -403,7 +343,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactionOperations | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -500,12 +440,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, Prepared, Compact>
|
||||
export function make<Body, Frame, Event, State, Compact extends CompactionOperations>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: Compact },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, Compact>
|
||||
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>
|
||||
@@ -617,23 +557,14 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError, Service>
|
||||
export function compact(
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service>
|
||||
export function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.gen(function* () {
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options)) return yield* client.compact(request, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return yield* client.compact(request, options)
|
||||
return yield* unsupportedCompaction(request, options?.mechanism)
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
}
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
@@ -647,27 +578,18 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(request: LLMRequest, options?: EndpointCompactOptions | TriggerCompactOptions) {
|
||||
return Effect.suspend((): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> => {
|
||||
if (options?.mechanism === "trigger" && canCompact(request, options))
|
||||
return request.model.route.compact.trigger(prepareRequest(request), executor, options)
|
||||
if ((options?.mechanism === undefined || options.mechanism === "endpoint") && canCompact(request))
|
||||
return request.model.route.compact.endpoint(prepareRequest(request), executor, options)
|
||||
return unsupportedCompaction(request, options?.mechanism)
|
||||
})
|
||||
}
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact,
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.invalidRequest(
|
||||
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
)
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,12 +9,6 @@ export type {
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
CompactionRequest,
|
||||
CheckpointRequest,
|
||||
EndpointCompactOptions,
|
||||
TriggerCompactOptions,
|
||||
} from "./client.js"
|
||||
export * from "./executor.js"
|
||||
export { Auth } from "./auth.js"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -97,21 +97,6 @@ export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.Co
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
/** A checkpoint only; retained history and replacement-window construction belong to the caller. */
|
||||
export class CompactionCheckpointResponse extends Schema.Class<CompactionCheckpointResponse>(
|
||||
"LLM.CompactionCheckpointResponse",
|
||||
)({
|
||||
checkpoint: CompactionPart.pipe(
|
||||
Schema.refine(
|
||||
(part): part is CompactionPart & { readonly encrypted: string; readonly text?: never } =>
|
||||
part.encrypted !== undefined && part.encrypted.length > 0,
|
||||
{ message: "A checkpoint response requires encrypted compaction content" },
|
||||
),
|
||||
),
|
||||
responseID: Schema.String.check(Schema.isPattern(/\S/)),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactionOperations } from "../route/client.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -175,7 +175,7 @@ export namespace LanguageModelCompatibility {
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
@@ -194,7 +194,7 @@ export class LanguageModel<
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactionOperations | undefined = CompactionOperations | undefined,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
id: ModelID.make(input.id),
|
||||
@@ -206,7 +206,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
return {
|
||||
@@ -218,11 +218,11 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactionOperations | undefined>(
|
||||
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 CompactionOperations | undefined>(
|
||||
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>
|
||||
@@ -241,7 +241,7 @@ export class LanguageModel<
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
@@ -249,7 +249,7 @@ export namespace LanguageModel {
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactionOperations | undefined = CompactionOperations | undefined> = Omit<
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
|
||||
+11
-39
@@ -1,17 +1,10 @@
|
||||
export * as TestLLM from "./testing.js"
|
||||
|
||||
import {
|
||||
LLMClient,
|
||||
type CompactionRequest,
|
||||
type CheckpointRequest,
|
||||
type EndpointCompactOptions,
|
||||
type TriggerCompactOptions,
|
||||
} from "./route/client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -20,11 +13,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
|
||||
| CompactionCheckpointResponse
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -143,38 +132,21 @@ const make = (options: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse || response instanceof CompactionCheckpointResponse)
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
function compact(
|
||||
request: CompactionRequest,
|
||||
options?: EndpointCompactOptions,
|
||||
): Effect.Effect<CompactionResponse, AIError>
|
||||
function compact(
|
||||
request: CheckpointRequest,
|
||||
options: TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionCheckpointResponse, AIError>
|
||||
function compact(
|
||||
request: LLMRequest,
|
||||
options?: EndpointCompactOptions | TriggerCompactOptions,
|
||||
): Effect.Effect<CompactionResponse | CompactionCheckpointResponse, AIError> {
|
||||
return take(request).pipe(
|
||||
Effect.flatMap((response): Effect.Effect<CompactionResponse | CompactionCheckpointResponse> => {
|
||||
if (options?.mechanism === "trigger")
|
||||
return response instanceof CompactionCheckpointResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM trigger compaction requires a CompactionCheckpointResponse")
|
||||
return response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse")
|
||||
}),
|
||||
)
|
||||
}
|
||||
const test = Test.of({
|
||||
compact,
|
||||
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,10 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { 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")
|
||||
@@ -77,25 +75,3 @@ test("tagged content and event guards accept both checkpoint representations", (
|
||||
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,146 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionCheckpointResponse,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Anthropic,
|
||||
Azure,
|
||||
AmazonBedrock,
|
||||
AmazonBedrockMantle,
|
||||
OpenAI,
|
||||
OpenAICompatibleResponses,
|
||||
XAI,
|
||||
} from "../../src/providers.js"
|
||||
import type { WebSocketChannelExecutor } from "../../src/route.js"
|
||||
import type { RoutePatch } from "../../src/route/client.js"
|
||||
import type { OpenAIResponsesBody } from "../../src/protocols/openai-responses.js"
|
||||
import type { Prepared } from "../../src/protocols/open-responses-channel.js"
|
||||
|
||||
declare const webSocket: WebSocketChannelExecutor
|
||||
const model = OpenAI.configure().responses("fixture")
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
LLMClient.compact(request).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "endpoint" }).pipe(Effect.map((result) => result satisfies CompactionResponse))
|
||||
LLMClient.compact(request, { mechanism: "trigger", webSocket }).pipe(
|
||||
Effect.map((result) => {
|
||||
result satisfies CompactionCheckpointResponse
|
||||
result.checkpoint.encrypted satisfies string
|
||||
result.responseID satisfies string
|
||||
// @ts-expect-error A trigger does not return replacement history.
|
||||
result.replacement
|
||||
}),
|
||||
)
|
||||
// @ts-expect-error Endpoint compaction does not accept a WebSocket executor.
|
||||
LLMClient.compact(request, { mechanism: "endpoint", webSocket })
|
||||
// @ts-expect-error Omitting mechanism selects the HTTP endpoint.
|
||||
LLMClient.compact(request, { webSocket })
|
||||
// @ts-expect-error Unknown mechanisms do not have a permissive fallback overload.
|
||||
LLMClient.compact(request, { mechanism: "other" })
|
||||
|
||||
for (const selected of [
|
||||
model,
|
||||
OpenAI.model("fixture", {}),
|
||||
model.route.with({ headers: { fixture: "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.make(LanguageModel.input(model)),
|
||||
LanguageModel.update(model, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model: selected }), { mechanism: "trigger" })
|
||||
}
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [] }), { mechanism: "trigger" })
|
||||
|
||||
const azure = Azure.configure({ resourceName: "fixture" }).responses("fixture")
|
||||
const xai = XAI.configure().responses("fixture")
|
||||
LLMClient.compact(LLM.request({ model: azure }))
|
||||
LLMClient.compact(LLM.request({ model: xai }))
|
||||
// @ts-expect-error Azure must not inherit OpenAI's trigger operation.
|
||||
LLMClient.compact(LLM.request({ model: azure }), { mechanism: "trigger" })
|
||||
LLMClient.compact(LLM.request({ model: Azure.responsesModel("fixture", { resourceName: "fixture" }) }), {
|
||||
// @ts-expect-error Azure's package entrypoint must preserve its narrower capability.
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error xAI endpoint support does not imply trigger support.
|
||||
LLMClient.compact(LLM.request({ model: xai }), { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI's package entrypoint must preserve its narrower capability.
|
||||
LLMClient.compact(LLM.request({ model: XAI.model("fixture", {}) }), { mechanism: "trigger" })
|
||||
|
||||
const unsupported = {
|
||||
bedrock: LLM.request({ model: AmazonBedrock.configure().model("fixture") }),
|
||||
mantle: LLM.request({ model: AmazonBedrockMantle.configure().responses("fixture") }),
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openai: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azure: LLM.request({ model: Azure.configure({ resourceName: "fixture" }).chat("fixture") }),
|
||||
xai: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Bedrock does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.bedrock)
|
||||
// @ts-expect-error Bedrock does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.bedrock, { mechanism: "trigger" })
|
||||
// @ts-expect-error Mantle does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.mantle)
|
||||
// @ts-expect-error Mantle must not inherit trigger support from OpenAI's protocol.
|
||||
LLMClient.compact(unsupported.mantle, { mechanism: "trigger" })
|
||||
// @ts-expect-error Anthropic does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Anthropic does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.anthropic, { mechanism: "trigger" })
|
||||
// @ts-expect-error OpenAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.openai)
|
||||
// @ts-expect-error OpenAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.openai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Azure Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.azure)
|
||||
// @ts-expect-error Azure Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.azure, { mechanism: "trigger" })
|
||||
// @ts-expect-error xAI Chat does not expose endpoint compaction.
|
||||
LLMClient.compact(unsupported.xai)
|
||||
// @ts-expect-error xAI Chat does not expose trigger compaction.
|
||||
LLMClient.compact(unsupported.xai, { mechanism: "trigger" })
|
||||
// @ts-expect-error Generic protocol compatibility does not grant endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
// @ts-expect-error Generic protocol compatibility does not grant trigger support.
|
||||
LLMClient.compact(unsupported.compatible, { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the model replaces its capability.
|
||||
LLMClient.compact(LLMRequest.update(request, { model: azure }), { mechanism: "trigger" })
|
||||
// @ts-expect-error Changing the route replaces its capability.
|
||||
LLMClient.compact(LLM.request({ model: LanguageModel.update(model, { route: azure.route }) }), { mechanism: "trigger" })
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: model.route.with({ compact: { endpoint: model.route.compact.endpoint } }).model({ id: "fixture" }),
|
||||
}),
|
||||
// @ts-expect-error Replacing route operations does not retain the old trigger capability.
|
||||
{ mechanism: "trigger" },
|
||||
)
|
||||
|
||||
declare const dynamic: LLMRequest
|
||||
declare const patch: Partial<LLMRequest.Input>
|
||||
declare const routePatch: RoutePatch<OpenAIResponsesBody, Prepared>
|
||||
// @ts-expect-error A dynamic operation override cannot preserve trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with(routePatch).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Explicitly removing operations removes trigger support.
|
||||
LLMClient.compact(LLM.request({ model: model.route.with({ compact: undefined }).model({ id: "fixture" }) }), {
|
||||
mechanism: "trigger",
|
||||
})
|
||||
// @ts-expect-error Dynamic models must be narrowed.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
if (LLMClient.canCompact(dynamic, { mechanism: "trigger" })) {
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
LLMClient.Service.use((client) => client.compact(dynamic, { mechanism: "trigger" }))
|
||||
}
|
||||
if (LLMClient.canCompact(dynamic)) {
|
||||
LLMClient.compact(dynamic)
|
||||
// @ts-expect-error Endpoint narrowing does not grant trigger support.
|
||||
LLMClient.compact(dynamic, { mechanism: "trigger" })
|
||||
}
|
||||
// @ts-expect-error A dynamic model override cannot preserve trigger support.
|
||||
LLMClient.compact(LLMRequest.update(request, patch), { mechanism: "trigger" })
|
||||
@@ -1,374 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { CompactionCheckpointResponse, LLM, LLMClient, LLMRequest, LanguageModel, SystemPart } from "../../src/index.js"
|
||||
import { Anthropic, Azure, OpenAI, XAI } from "../../src/providers.js"
|
||||
import { Route } from "../../src/route/client.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse, scriptedResponses, truncatedStream } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "fixture" }).responses("fixture"), prompt: "hello" })
|
||||
const trigger = { mechanism: "trigger" } as const
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toBe("/v1/responses")
|
||||
expect(new URL(request.url).searchParams.get("deployment")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
expect(request.headers.authorization).toBe("Bearer fixture")
|
||||
expect(request.headers["chatgpt-account-id"]).toBe("fixture-account")
|
||||
expect(request.headers["x-codex-beta-features"]).toBe("remote_compaction_v2")
|
||||
expect(request.headers["x-deployment"]).toBe("resolved")
|
||||
const body = JSON.parse(text)
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: "system\noperator",
|
||||
parallel_tool_calls: true,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [checkpoint],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
input_tokens_details: { cached_tokens: 40 },
|
||||
output_tokens: 5,
|
||||
total_tokens: 105,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const route = Route.make({
|
||||
id: "fixture-responses",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: request.model.route.auth,
|
||||
transport: OpenAIResponses.transport,
|
||||
headers: () => {
|
||||
calls.push("headers")
|
||||
return { "x-deployment": "resolved" }
|
||||
},
|
||||
}).with({ endpoint: { query: { deployment: "fixture" } } })
|
||||
const input = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
system: [SystemPart.make("system"), SystemPart.make("operator")],
|
||||
prompt: "hello",
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
service_tier: "priority",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
})
|
||||
const original = LLMRequest.input(input)
|
||||
const result = yield* LLMClient.compact(input, {
|
||||
...trigger,
|
||||
http: (request, next) => {
|
||||
calls.push("http")
|
||||
return next(request)
|
||||
},
|
||||
})
|
||||
expect(result).toBeInstanceOf(CompactionCheckpointResponse)
|
||||
expect(result.checkpoint).toMatchObject({
|
||||
type: "compaction",
|
||||
provider: "openai",
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
expect(result.responseID).toBe("resp_1")
|
||||
expect(result.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 5,
|
||||
totalTokens: 105,
|
||||
cacheReadInputTokens: 40,
|
||||
})
|
||||
expect(LLMRequest.input(input)).toEqual(original)
|
||||
expect(calls).toEqual(["headers", "http"])
|
||||
const codec = Schema.fromJsonString(CompactionCheckpointResponse)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(result))).toEqual(result)
|
||||
expect("replacement" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const id of ["cmp_1", undefined]) {
|
||||
for (const added of [true, false]) {
|
||||
const item = { type: "compaction", id, encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
...(added ? [{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id } }] : []),
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.output_item.done", output_index: 0, item },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [item] } },
|
||||
),
|
||||
),
|
||||
).effect(`correlates repeated checkpoint events: id=${id}, added=${added}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.checkpoint.id).toBeString()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: { type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" },
|
||||
},
|
||||
{ type: "response.output_text.delta", delta: "do not expose this" },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [{ type: "function_call", id: "fc_1", name: "unexpected", arguments: "not JSON" }, checkpoint],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
).effect("ignores other output rather than generating an answer or dispatching tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, trigger)
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect("message" in result).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, events] of Object.entries({
|
||||
missing: [{ type: "response.completed", response: { id: "resp_1", output: [] } }],
|
||||
multiple: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, { ...checkpoint, id: "cmp_2" }] } },
|
||||
],
|
||||
duplicateSlots: [{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, checkpoint] } }],
|
||||
malformed: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ type: "compaction", id: "cmp_1" }] } },
|
||||
],
|
||||
empty: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "" }] } },
|
||||
],
|
||||
wrongType: [
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: 42 }] } },
|
||||
],
|
||||
noResponseID: [{ type: "response.completed", response: { output: [checkpoint] } }],
|
||||
changedID: [
|
||||
{ type: "response.created", response: { id: "resp_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_2", output: [checkpoint] } },
|
||||
],
|
||||
changedCheckpoint: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [{ ...checkpoint, encrypted_content: "changed" }] },
|
||||
},
|
||||
],
|
||||
incomplete: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.incomplete", response: { id: "resp_1", incomplete_details: { reason: "max_output_tokens" } } },
|
||||
],
|
||||
failed: [
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.failed", response: { id: "resp_1", error: { code: "server_error", message: "failed" } } },
|
||||
],
|
||||
wrongStatus: [{ type: "response.completed", response: { id: "resp_1", status: "incomplete", output: [checkpoint] } }],
|
||||
})) {
|
||||
const wire = events.map((event) => ({ ...event, fixture_extra: "preserved" }))
|
||||
testEffect(
|
||||
fixedResponse(sseEvents(...wire), { headers: { "content-type": "text/event-stream", "x-fixture": "preserved" } }),
|
||||
).effect(`rejects ${name} checkpoint response and preserves original error context`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason.body).toBe(JSON.stringify(wire.at(-1)))
|
||||
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-fixture": "preserved" } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: checkpoint }))).effect(
|
||||
"rejects clean EOF without response.completed",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
testEffect(truncatedStream([sseEvents({ type: "response.output_item.done", item: checkpoint })])).effect(
|
||||
"does not return a checkpoint from an interrupted stream",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(request, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("Transport")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { conversation: "stored" }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(LLMRequest.update(request, { http: { body } }), trigger).pipe(
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("trigger capability follows selected routes, independently of endpoint support", () => {
|
||||
expect(LLMClient.canCompact(request, trigger)).toBe(true)
|
||||
for (const model of [
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
]) {
|
||||
expect(LLMClient.canCompact(LLM.request({ model }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLM.request({ model }), trigger)).toBe(false)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model }), trigger)).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(
|
||||
LLM.request({ model: LanguageModel.update(request.model, { route: model.route }) }),
|
||||
trigger,
|
||||
),
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
"untyped unsupported mechanisms and routes fail locally in both client surfaces",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* LLMClient.Service
|
||||
for (const model of [
|
||||
Anthropic.configure().model("fixture"),
|
||||
Azure.configure({ resourceName: "fixture" }).responses("fixture"),
|
||||
]) {
|
||||
const unsupported = LLM.request({ model })
|
||||
// @ts-expect-error Exercise untyped consumers; runtime must still reject unsupported routes.
|
||||
const error = yield* LLMClient.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("UnsupportedOperation")
|
||||
// @ts-expect-error The service has the same runtime guard.
|
||||
const serviceError = yield* client.compact(unsupported, trigger).pipe(Effect.flip)
|
||||
expect(serviceError.reason._tag).toBe("UnsupportedOperation")
|
||||
}
|
||||
// @ts-expect-error Exercise an unknown mechanism supplied by JavaScript.
|
||||
const error = yield* LLMClient.compact(request, { mechanism: "other" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
// @ts-expect-error An empty string is not the default mechanism.
|
||||
const empty = yield* client.compact(request, { mechanism: "" }).pipe(Effect.flip)
|
||||
expect(empty.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const valid of [true, false]) {
|
||||
testEffect(fixedResponse("must not use HTTP")).effect(
|
||||
`acknowledges channel completion only after validation: valid=${valid}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
let completed = 0
|
||||
const operation = LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket: {
|
||||
execute: () =>
|
||||
Effect.succeed({
|
||||
frames: Stream.make(
|
||||
JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: valid ? [checkpoint] : [] },
|
||||
}),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
completed++
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
const result = yield* Effect.result(operation)
|
||||
expect(result._tag).toBe(valid ? "Success" : "Failure")
|
||||
expect(completed).toBe(valid ? 1 : 0)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
test("checkpoint result schema rejects failed or unencrypted representations", () => {
|
||||
const decode = Schema.decodeUnknownSync(CompactionCheckpointResponse)
|
||||
for (const checkpoint of [
|
||||
{ type: "compaction", provider: "anthropic", text: null },
|
||||
{ type: "compaction", provider: "anthropic", text: "summary" },
|
||||
{ type: "compaction", provider: "openai", encrypted: "" },
|
||||
])
|
||||
expect(() => decode({ checkpoint, responseID: "resp_1" })).toThrow()
|
||||
expect(() =>
|
||||
decode({ checkpoint: { type: "compaction", provider: "openai", encrypted: "opaque" }, responseID: " " }),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
testEffect(
|
||||
scriptedResponses([
|
||||
sseEvents(
|
||||
{ type: "response.created", response: { id: "resp_discarded" } },
|
||||
{ type: "response.output_item.done", item: { ...checkpoint, encrypted_content: "discarded" } },
|
||||
{ type: "response.incomplete", response: { id: "resp_discarded", usage: { input_tokens: 999 } } },
|
||||
),
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_success", output: [checkpoint], usage: { input_tokens: 12 } },
|
||||
}),
|
||||
]),
|
||||
).effect("an explicitly retried effect does not reuse failed-attempt checkpoint or metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const operation = LLMClient.compact(request, trigger)
|
||||
yield* operation.pipe(Effect.flip)
|
||||
const result = yield* operation
|
||||
expect(result.checkpoint.encrypted).toBe("opaque")
|
||||
expect(result.responseID).toBe("resp_success")
|
||||
expect(result.usage?.inputTokens).toBe(12)
|
||||
}),
|
||||
)
|
||||
@@ -125,15 +125,12 @@ testEffect(
|
||||
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
|
||||
}),
|
||||
),
|
||||
).effect("mints an id for terminal checkpoints that omit one", () =>
|
||||
).effect("rejects terminal checkpoints missing an id", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
const error = 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")
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("missing its id")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -130,22 +130,16 @@ for (const model of [
|
||||
}),
|
||||
],
|
||||
})
|
||||
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) {
|
||||
for (const candidate of [
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
|
||||
],
|
||||
}),
|
||||
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(tag)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
const response = yield* LLMClient.compact(candidate)
|
||||
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
|
||||
}
|
||||
@@ -367,9 +361,8 @@ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic comp
|
||||
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
|
||||
)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("UnsupportedOperation")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("LLMClient.compact")
|
||||
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -435,9 +428,7 @@ for (const model of [
|
||||
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")
|
||||
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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
CompactionCheckpointResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -80,47 +79,6 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("scripts trigger checkpoints lazily with gates, queue order, and fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure().responses("fixture"), prompt: "hello" })
|
||||
const checkpoint = new CompactionCheckpointResponse({
|
||||
checkpoint: { type: "compaction", provider: ProviderID.make("openai"), encrypted: "opaque" },
|
||||
responseID: "resp_fixture",
|
||||
})
|
||||
const endpoint = new CompactionResponse({ replacement: [] })
|
||||
yield* client.push(checkpoint, endpoint)
|
||||
const operation = LLMClient.compact(request, { mechanism: "trigger" })
|
||||
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(checkpoint)
|
||||
expect(yield* client.compact(request)).toBe(endpoint)
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(request)
|
||||
return checkpoint
|
||||
})
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.always(checkpoint)
|
||||
expect(yield* LLMClient.compact(request, { mechanism: "trigger" })).toBe(checkpoint)
|
||||
yield* client.push(endpoint, checkpoint, checkpoint)
|
||||
expect(yield* client.compact(request, { mechanism: "trigger" }).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM trigger compaction requires a CompactionCheckpointResponse",
|
||||
)
|
||||
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",
|
||||
)
|
||||
expect(yield* client.requests()).toHaveLength(7)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts", "test/provider/checkpoint.test.ts"]
|
||||
"include": ["test/**/*.types.ts", "test/testing.test.ts"]
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
@@ -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,12 +161,7 @@ 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")
|
||||
@@ -212,9 +198,6 @@ 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" } },
|
||||
@@ -230,7 +213,6 @@ 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,
|
||||
{
|
||||
@@ -299,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,
|
||||
{
|
||||
@@ -388,13 +369,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
})
|
||||
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
|
||||
await expect(page.getByText(/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 page.getByRole("button", { name: "Session details" }).click()
|
||||
const summary = page.getByRole("button", { name: "2 items running in background" })
|
||||
await expect(summary).toContainText("2")
|
||||
@@ -402,7 +376,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
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,14 +161,12 @@ 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(
|
||||
@@ -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")
|
||||
@@ -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"]'))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "የተዘጋውን ትር እንደገና ክፈት",
|
||||
|
||||
@@ -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": "إعادة فتح علامة التبويب المغلقة",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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": "Повторно отваряне на затворен раздел",
|
||||
|
||||
@@ -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": "বন্ধ ট্যাব আবার খুলুন",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
|
||||
|
||||
@@ -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": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
|
||||
|
||||
@@ -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": "Άνοιγμα ξανά κλειστής καρτέλας",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "برگه بسته را دوباره باز کنید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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": "פתח מחדש את הכרטיסייה הסגורה",
|
||||
|
||||
@@ -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": "बंद टैब पुनः खोलें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Վերաբացել փակ ներդիրը",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "閉じたタブを再度開く",
|
||||
|
||||
@@ -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": "დახურული ჩანართის ხელახლა გახსნა",
|
||||
|
||||
@@ -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": "បើកផ្ទាំងបិទឡើងវិញ",
|
||||
|
||||
@@ -37,7 +37,6 @@ export const dict = {
|
||||
"command.language.cycle": "언어 순환",
|
||||
"command.language.set": "언어 사용: {{language}}",
|
||||
"command.session.new": "새 세션",
|
||||
"command.session.import": "세션 가져오기",
|
||||
"command.file.open": "파일 열기",
|
||||
"command.tab.close": "탭 닫기",
|
||||
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
|
||||
|
||||
@@ -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": "ເປີດແຖບປິດຄືນໃໝ່",
|
||||
|
||||
@@ -137,7 +137,6 @@ export const dict = {
|
||||
"command.language.cycle": "Perjungti kalbą",
|
||||
"command.language.set": "Naudokite kalbą: {{language}}",
|
||||
"command.session.new": "Naujas seansas",
|
||||
"command.session.import": "Importuoti seansą",
|
||||
"command.file.open": "Atidaryti failą",
|
||||
"command.tab.close": "Uždaryti skirtuką",
|
||||
"command.tab.reopenClosed": "Iš naujo atidaryti uždarytą skirtuką",
|
||||
|
||||
@@ -133,7 +133,6 @@ export const dict = {
|
||||
"command.language.cycle": "Mainīt valodu",
|
||||
"command.language.set": "Izmantot valodu: {{language}}",
|
||||
"command.session.new": "Jauna sesija",
|
||||
"command.session.import": "Importēt sesiju",
|
||||
"command.file.open": "Atvērt failu",
|
||||
"command.tab.close": "Aizvērt cilni",
|
||||
"command.tab.reopenClosed": "Atvērt aizvērtu cilni",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user