mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-27 10:54:42 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 506c30e590 | |||
| 45fd44db01 |
@@ -18,6 +18,7 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
|
||||
])
|
||||
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
|
||||
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
export interface Options {
|
||||
readonly id: string
|
||||
@@ -26,7 +27,6 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly continuation?: OpenResponsesContinuation.Shape
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,7 +163,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
continuation: options.continuation,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
interface CheckpointValue {
|
||||
readonly version: typeof VERSION
|
||||
@@ -14,19 +15,12 @@ interface CheckpointValue {
|
||||
readonly output: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
|
||||
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
|
||||
*/
|
||||
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
|
||||
|
||||
export interface DriverInput {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
readonly continuation?: Shape
|
||||
}
|
||||
|
||||
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
|
||||
@@ -133,26 +127,22 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
output = []
|
||||
const previous = checkpointValue(checkpoint)
|
||||
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
|
||||
const fields = previous ? shape(request) : undefined
|
||||
const delta = previous && fields ? incremental(request, previous) : undefined
|
||||
if (!previous || !fields || !delta)
|
||||
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
const delta = previous ? incremental(request, previous) : undefined
|
||||
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
return {
|
||||
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
|
||||
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
|
||||
mode: "incremental" as const,
|
||||
}
|
||||
}),
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,15 +153,6 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
|
||||
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
|
||||
// no code. Classified failures such as context overflow keep their runner-owned recovery.
|
||||
if (
|
||||
create.mode === "incremental" &&
|
||||
observation.error.reason._tag === "InvalidRequest" &&
|
||||
observation.error.reason.classification === undefined
|
||||
)
|
||||
return rejected(observation, "retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
@@ -191,7 +172,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output?.length
|
||||
output: event.response?.output
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
@@ -205,4 +186,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -86,7 +86,6 @@ export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
})
|
||||
|
||||
const OpenResponsesWebSearchCall = Schema.StructWithRest(
|
||||
@@ -183,7 +182,6 @@ export const InputItem = Schema.Union([
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
status: Schema.optional(Schema.String),
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
@@ -193,7 +191,6 @@ export const InputItem = Schema.Union([
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.String,
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
@@ -226,7 +223,6 @@ type OpenResponsesReasoningInput = {
|
||||
id?: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
provider_metadata?: Record<string, unknown>
|
||||
}
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -329,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
@@ -404,44 +401,13 @@ export const Event = Schema.StructWithRest(
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
/** Replay opaque gateway continuation state only for adapters that own this extension. */
|
||||
readonly preserveProviderMetadata?: boolean
|
||||
readonly nativeTool?: (
|
||||
native: NonNullable<ToolDefinition["native"]>,
|
||||
) => Effect.Effect<{ readonly type: string }, AIError>
|
||||
@@ -461,7 +427,6 @@ export interface ParserState {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly preserveProviderMetadata: boolean
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
@@ -521,16 +486,7 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
|
||||
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
|
||||
}
|
||||
|
||||
const replayProviderMetadata = (metadata: ProviderMetadata | undefined, key: string, adapter: ProviderAdapter) => {
|
||||
const value = metadata?.[key]?.providerMetadata
|
||||
return adapter.preserveProviderMetadata && ProviderShared.isRecord(value) ? { provider_metadata: value } : {}
|
||||
}
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
adapter: ProviderAdapter,
|
||||
): OpenResponsesInputItem => {
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
@@ -539,15 +495,10 @@ const lowerToolCall = (
|
||||
name: part.name,
|
||||
namespace: part.namespace,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
adapter: ProviderAdapter,
|
||||
): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
@@ -560,7 +511,6 @@ const lowerReasoning = (
|
||||
...(id === undefined ? {} : { id }),
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,11 +655,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
// Replayed text is a finished input item, even if generation was cut short.
|
||||
status: "completed",
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
...replayProviderMetadata(group.parts.at(-1)?.providerMetadata, providerMetadataKey, adapter),
|
||||
})),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
@@ -730,14 +678,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
if (reasoning.provider_metadata !== undefined) existing.provider_metadata = reasoning.provider_metadata
|
||||
continue
|
||||
}
|
||||
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
|
||||
@@ -747,7 +694,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey, adapter))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -1086,17 +1033,8 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
}
|
||||
|
||||
const outputMetadata = (state: ParserState, item: OutputItem, extra?: Record<string, unknown>) =>
|
||||
providerMetadata(state, {
|
||||
itemId: item.id,
|
||||
...extra,
|
||||
...(state.preserveProviderMetadata && ProviderShared.isRecord(item.provider_metadata)
|
||||
? { providerMetadata: item.provider_metadata }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
|
||||
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs normally stream reasoning items in this order:
|
||||
// `output_item.added` (reasoning) →
|
||||
@@ -1159,7 +1097,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
}
|
||||
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const metadata = outputMetadata(state, item)
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
@@ -1282,7 +1220,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = outputMetadata(state, item, phase === undefined ? undefined : { phase })
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
@@ -1297,11 +1235,10 @@ 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 = outputMetadata(state, item)
|
||||
const pending = state.tools[item.id]
|
||||
const registered = pending !== undefined
|
||||
const tools = pending
|
||||
? ToolStream.start(state.tools, item.id, { ...pending, providerMetadata: metadata })
|
||||
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,
|
||||
@@ -1555,7 +1492,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
preserveProviderMetadata: adapter.preserveProviderMetadata ?? false,
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
const ADAPTER = "organization-routes"
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: "Organization routes",
|
||||
preserveProviderMetadata: true,
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
store: Schema.Literal(false),
|
||||
provider_options: Schema.Struct({
|
||||
"openai-responses": Schema.Struct({ include: Schema.Array(Schema.String) }),
|
||||
}),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OrganizationRoutes.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter)
|
||||
// A route can select another provider on each call. Keep full history and only
|
||||
// portable options; native caching and stored IDs would exclude translated
|
||||
// targets. Scope encrypted reasoning to native Responses targets so their
|
||||
// stateless continuations remain replayable without blocking other protocols.
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
model: body.model,
|
||||
input: body.input,
|
||||
instructions: body.instructions,
|
||||
tools: body.tools,
|
||||
tool_choice: body.tool_choice,
|
||||
stream: true as const,
|
||||
store: false as const,
|
||||
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
|
||||
max_output_tokens:
|
||||
body.max_output_tokens !== undefined && body.max_output_tokens > 0 ? body.max_output_tokens : undefined,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
parallel_tool_calls: body.parallel_tool_calls,
|
||||
metadata: body.metadata,
|
||||
reasoning: body.reasoning?.effort === undefined ? undefined : { effort: body.reasoning.effort },
|
||||
text: body.text,
|
||||
})
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
...OpenResponses.protocol,
|
||||
id: ADAPTER,
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
...OpenResponses.protocol.stream,
|
||||
initial: (request: LLMRequest) => OpenResponses.initial(request, adapter),
|
||||
},
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: ADAPTER,
|
||||
protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: HttpTransport.sseJson.with<typeof Body.Type>(),
|
||||
defaults: { providerOptions: { store: false, include: [] } },
|
||||
})
|
||||
|
||||
export * as OrganizationRoutes from "./organization-routes.js"
|
||||
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -38,10 +37,11 @@ const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -65,13 +61,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
// Read before the exclusivity check narrows a conflicting settings object to `never`.
|
||||
const provider = ProviderID.make(settings.provider ?? id)
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider,
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -37,8 +37,7 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -75,8 +74,7 @@ export const provider = {
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -37,18 +35,12 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -126,10 +123,7 @@ export const provider = {
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OrganizationRoutes } from "../protocols/organization-routes.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type Options = Pick<
|
||||
OpenResponsesProviderOptionsInput,
|
||||
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
|
||||
>
|
||||
|
||||
export const id = ProviderID.make("organization-routes")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export const routes = [OrganizationRoutes.route]
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const provider = input.provider ?? "organization-routes"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = OrganizationRoutes.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -41,10 +41,6 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
|
||||
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
|
||||
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
|
||||
}),
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -129,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
|
||||
@@ -115,11 +115,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
}
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
|
||||
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
|
||||
// after cleanup, EventEmitter would throw it as an uncaught exception.
|
||||
ws.addEventListener("error", () => {}, { once: true })
|
||||
ws.close(1000)
|
||||
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
|
||||
ws.close(1000)
|
||||
}
|
||||
const onOpen = () => {
|
||||
cleanup()
|
||||
|
||||
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
+6
-6
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+4
-4
File diff suppressed because one or more lines are too long
+6
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -81,7 +81,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+5
-5
File diff suppressed because one or more lines are too long
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -325,7 +322,7 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -1458,13 +1458,7 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "open-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ it.effect("conversation lowering excludes generation settings and tool definitio
|
||||
instructions: "Keep the context",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "hi" }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "server_shutting_down",
|
||||
"message": "Server is shutting down. Please retry your request.",
|
||||
"param": null,
|
||||
"diagnostic": "retain-original-frame"
|
||||
}`
|
||||
for (const model of [
|
||||
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
|
||||
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1,120 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test-key" }).responses("example-model"),
|
||||
configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
]) {
|
||||
describe(`${model.route.protocol} message replay`, () => {
|
||||
const key = model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
it.effect("marks assistant text completed regardless of stored status", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
...[undefined, "in_progress", "incomplete", "completed"].map((status, index) =>
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
providerMetadata: { [key]: { status } },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Saved ${index}`,
|
||||
providerMetadata: { [key]: { itemId: `msg_${index}`, phase: "commentary", status } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `Final ${index}`,
|
||||
providerMetadata: { [key]: { itemId: `msg_final_${index}`, phase: "final_answer", status } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Continue" }],
|
||||
providerMetadata: { [key]: { status: "incomplete" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
...[0, 1, 2, 3].flatMap((index) => [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg_${index}`,
|
||||
phase: "commentary",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: `Saved ${index}` }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: `msg_final_${index}`,
|
||||
phase: "final_answer",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: `Final ${index}` }],
|
||||
},
|
||||
]),
|
||||
{ role: "user", status: "incomplete", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays truncated text as completed while retaining the response finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Respond" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_partial", status: "in_progress" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_partial", delta: "The next step is" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_partial",
|
||||
status: "incomplete",
|
||||
content: [{ type: "output_text", text: "The next step is" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "response.incomplete",
|
||||
response: { status: "incomplete", incomplete_details: { reason: "max_output_tokens" } },
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("length")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message, Message.user("Continue")] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg_partial",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "The next step is" }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("Open Responses-compatible route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -299,27 +299,23 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Kept." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `history_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Long." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "provider_value/with+symbols",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Opaque." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "output_text", text: "No suffix." },
|
||||
{ type: "output_text", text: "No prefix." },
|
||||
@@ -860,7 +856,6 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||
],
|
||||
})
|
||||
@@ -208,7 +208,7 @@ describe("OpenAI Responses WebSocket recorded", () => {
|
||||
instructions: "Follow the user's exact reply instruction.",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||
{ role: "assistant", status: "completed", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -30,7 +30,6 @@ import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
@@ -70,39 +69,14 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
|
||||
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
const base = baseChannelDriver(message)
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
|
||||
return {
|
||||
...base,
|
||||
observe: (create, frame) =>
|
||||
base.observe(create, frame).pipe(
|
||||
Effect.map((observation) =>
|
||||
observation.type === "provider-failure"
|
||||
? {
|
||||
...observation,
|
||||
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
|
||||
}
|
||||
: observation,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
base = baseChannelDriver,
|
||||
continuation?: OpenResponsesContinuation.Shape,
|
||||
) => {
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
message,
|
||||
base: base(message),
|
||||
continuation,
|
||||
base: baseChannelDriver(message),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -411,7 +385,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -586,54 +560,52 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
@@ -687,47 +659,45 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
const continued = yield* next.create(saved)
|
||||
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
),
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
@@ -882,105 +852,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest, classifyingChannelDriver)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
yield* first.create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver(
|
||||
{
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
},
|
||||
classifyingChannelDriver,
|
||||
)
|
||||
// Codex reports a stale previous_response_id as a plain invalid_request_error.
|
||||
const stale = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
|
||||
})
|
||||
const incremental = yield* second.create(saved)
|
||||
expect(incremental.mode).toBe("incremental")
|
||||
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
|
||||
|
||||
// A full send has no continuation to blame, so the same error stays a provider failure.
|
||||
const full = yield* second.create(undefined)
|
||||
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
|
||||
|
||||
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
|
||||
const overflow = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
|
||||
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
|
||||
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
|
||||
const internal = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "ProviderInternal" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shapes the incremental send with the route continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
instructions: "You are terse.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const secondRequest = {
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
}
|
||||
const saved = checkpoint(
|
||||
yield* continuationDriver(firstRequest).observe(
|
||||
yield* continuationDriver(firstRequest).create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
|
||||
const trimmed = yield* continuationDriver(
|
||||
secondRequest,
|
||||
baseChannelDriver,
|
||||
({ instructions: _, ...rest }) => rest,
|
||||
).create(saved)
|
||||
expect(trimmed.mode).toBe("incremental")
|
||||
expect(JSON.parse(trimmed.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// Declining the continuation sends the step in full and never sends a previous_response_id.
|
||||
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
|
||||
expect(declined.mode).toBe("full")
|
||||
expect(JSON.parse(declined.message)).toEqual(secondRequest)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
@@ -2179,7 +2050,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
@@ -2262,7 +2132,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
@@ -2270,7 +2139,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_final",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
@@ -2278,7 +2146,6 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "msg_null",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
@@ -3409,19 +3276,14 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Before." }],
|
||||
},
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -3685,14 +3547,12 @@ describe("OpenAI Responses route", () => {
|
||||
type: "message",
|
||||
id: "history_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: `message_${"a".repeat(64)}`,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "World" }],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/organization-routes.js"
|
||||
import { provider } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: "session-test",
|
||||
baseURL: "https://console.example.test/inference/route/openai/v1",
|
||||
provider: "opencode-routes-org_test",
|
||||
headers: { "x-opencode-org-id": "org_test" },
|
||||
}).model("route/coding")
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
protocol: "openai-responses",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_native",
|
||||
summary: [{ type: "summary_text", text: "Need a lookup" }],
|
||||
encrypted_content: "opaque-openai-reasoning",
|
||||
},
|
||||
{ type: "message", id: "msg_native", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_native_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "function_call", id: "fc_native_clock", call_id: "call_clock", name: "clock", arguments: "{}" },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "google",
|
||||
content: [
|
||||
{ text: "Need a lookup", thought: true },
|
||||
{ text: "Checking." },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "opaque-google-signature" },
|
||||
{ functionCall: { name: "clock", args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "anthropic-messages",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Need a lookup", signature: "opaque-anthropic-signature" },
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool_use", id: "call_lookup", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "tool_use", id: "call_clock", name: "clock", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "openai-chat",
|
||||
content: [
|
||||
{
|
||||
role: "assistant",
|
||||
reasoning_content: "Need a lookup",
|
||||
content: "Checking.",
|
||||
tool_calls: [
|
||||
{ id: "call_lookup", type: "function", function: { name: "lookup", arguments: '{"query":"weather"}' } },
|
||||
{ id: "call_clock", type: "function", function: { name: "clock", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const finalText = sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", content: [{ type: "output_text", text: "Sunny." }] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_final" } },
|
||||
)
|
||||
|
||||
describe("organization routes", () => {
|
||||
it.effect("uses the Responses route endpoint with stateless portable options", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "Help with code.",
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session-cache",
|
||||
providerOptions: {
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previousResponseId: "resp_old",
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(input.request.url).toBe("https://console.example.test/inference/route/openai/v1/responses")
|
||||
expect(input.request.headers.authorization).toBe("Bearer session-test")
|
||||
expect(input.request.headers["x-opencode-org-id"]).toBe("org_test")
|
||||
expect(yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(input.text)).toEqual({
|
||||
model: "route/coding",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
|
||||
instructions: "Help with code.",
|
||||
stream: true,
|
||||
store: false,
|
||||
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
|
||||
reasoning: { effort: "low" },
|
||||
})
|
||||
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.text).toBe("Sunny.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits an unknown output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", generation: { maxTokens: 0 } }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it.effect(`preserves ${fixture.protocol} continuation metadata through a streamed parallel tool loop`, () =>
|
||||
Effect.gen(function* () {
|
||||
const metadata = {
|
||||
protocol: fixture.protocol,
|
||||
model: "native-model",
|
||||
connection_id: "conn_native",
|
||||
endpoint_id: "endpoint_native",
|
||||
content: fixture.content,
|
||||
group_id: "resp_tools",
|
||||
}
|
||||
const marker = { group_id: "resp_tools" }
|
||||
const items = [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Need a lookup" }],
|
||||
provider_metadata: metadata,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
provider_metadata: marker,
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
provider_metadata: marker,
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_clock",
|
||||
call_id: "call_clock",
|
||||
name: "clock",
|
||||
arguments: "{}",
|
||||
provider_metadata: marker,
|
||||
},
|
||||
]
|
||||
const first = yield* LLMClient.generate(LLM.request({ model, prompt: "Check weather and time." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "Need a lookup",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Checking." },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 2,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_lookup", delta: '{"query":"weather"}' },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 3,
|
||||
item: { type: "function_call", id: "fc_clock", call_id: "call_clock", name: "clock", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_clock", delta: "{}" },
|
||||
...items.map((item, output_index) => ({ type: "response.output_item.done", output_index, item })),
|
||||
{ type: "response.completed", response: { id: "resp_tools", output: items } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(first.toolCalls).toHaveLength(2)
|
||||
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(2)
|
||||
expect(
|
||||
first.message.content.map((part) => part.providerMetadata?.["opencode-routes-org_test"]?.providerMetadata),
|
||||
).toEqual([metadata, marker, marker, marker])
|
||||
|
||||
const second = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: { previousResponseId: "resp_tools" },
|
||||
messages: [
|
||||
Message.user("Check weather and time."),
|
||||
first.message,
|
||||
Message.tool({ id: "call_lookup", name: "lookup", result: { weather: "sunny" } }),
|
||||
Message.tool({ id: "call_clock", name: "clock", result: { time: "noon" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
input: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
store: Schema.Boolean,
|
||||
include: Schema.optional(Schema.Array(Schema.String)),
|
||||
previous_response_id: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
)(input.text)
|
||||
expect(body.store).toBe(false)
|
||||
expect(body.include).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
expect(body.input.map((item) => item.type ?? item.role)).toEqual([
|
||||
"user",
|
||||
"reasoning",
|
||||
"message",
|
||||
"function_call",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"function_call_output",
|
||||
])
|
||||
expect(body.input.slice(1, 5).map((item) => item.provider_metadata)).toEqual([
|
||||
metadata,
|
||||
marker,
|
||||
marker,
|
||||
marker,
|
||||
])
|
||||
expect(body.input.slice(3, 5).map((item) => item.call_id)).toEqual(["call_lookup", "call_clock"])
|
||||
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(second.text).toBe("Sunny.")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("preserves opaque state on an empty reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const metadata = {
|
||||
protocol: "anthropic-messages",
|
||||
content: [{ type: "redacted_thinking", data: "opaque" }],
|
||||
group_id: "resp_empty",
|
||||
}
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_empty", summary: [], provider_metadata: metadata },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_empty" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.input).toEqual([
|
||||
{ type: "reasoning", id: "rs_empty", summary: [], encrypted_content: null, provider_metadata: metadata },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps opaque route metadata out of other Responses providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const other = provider
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://other.example.test/v1",
|
||||
provider: "opencode-routes-org_test",
|
||||
})
|
||||
.model("other")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: other, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Hi" }],
|
||||
provider_metadata: { group_id: "resp_1", secret: "opaque" },
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content[0]?.providerMetadata).toEqual({ "opencode-routes-org_test": { itemId: "msg_1" } })
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: other,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Hi",
|
||||
providerMetadata: {
|
||||
"opencode-routes-org_test": { itemId: "msg_1", providerMetadata: { secret: "opaque" } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(replay.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hi" }],
|
||||
},
|
||||
])
|
||||
expect(replay.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,18 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -20,35 +13,6 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
|
||||
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
|
||||
Effect.gen(function* () {
|
||||
let driver: WebSocketChannelDriver | undefined
|
||||
yield* LLMClient.generate(request, {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.sync(() => {
|
||||
driver = exchange.driver
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
|
||||
if (!driver) throw new Error("Expected a WebSocket channel driver")
|
||||
return driver
|
||||
})
|
||||
|
||||
const completed = (driver: WebSocketChannelDriver, id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const create = yield* driver.create(undefined)
|
||||
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
|
||||
const observation = yield* driver.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
|
||||
)
|
||||
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
|
||||
return observation.checkpoint
|
||||
})
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("composes the Open Responses baseline with xAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -198,78 +162,6 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
|
||||
Effect.gen(function* () {
|
||||
// xAI answers a rejected response.create with an error envelope that carries no event type.
|
||||
const envelope = ProviderShared.encodeJson({
|
||||
error: {
|
||||
message:
|
||||
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
|
||||
type: "api_error",
|
||||
},
|
||||
})
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
|
||||
expect(error.reason.body).toBe(envelope)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
|
||||
Effect.gen(function* () {
|
||||
const step = (store: boolean, ...prompts: string[]) =>
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are terse.",
|
||||
messages: prompts.map((prompt) => Message.user(prompt)),
|
||||
providerOptions: { store },
|
||||
})
|
||||
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
|
||||
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
|
||||
|
||||
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
|
||||
expect(stored.mode).toBe("incremental")
|
||||
expect(JSON.parse(stored.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// The connection cache only serves stored responses, so the default store: false never chains.
|
||||
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
|
||||
expect(unstored.mode).toBe("full")
|
||||
expect(JSON.parse(unstored.message)).toMatchObject({
|
||||
instructions: "You are terse.",
|
||||
store: false,
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
import { SessionBrowserPane } from "../src/session/browser/pane"
|
||||
|
||||
export function mountBrowserPane() {
|
||||
const host = document.createElement("main")
|
||||
host.dataset.testid = "browser-pane-fixture"
|
||||
host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px"
|
||||
document.body.appendChild(host)
|
||||
|
||||
function Fixture() {
|
||||
const [store, setStore] = createStore({
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
url: `https://${name.toLowerCase()}.example/`,
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 0,
|
||||
}))
|
||||
// Record the native boundary per registration: hiding Beta cannot hide Alpha's view.
|
||||
const registrations = new Map<string, BrowserPaneRegistration>(
|
||||
tabs.map((tab) => [
|
||||
tab.title,
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
)
|
||||
const browser: ReturnType<typeof createSessionBrowser> = {
|
||||
available: () => true,
|
||||
attached: () => !!registrations.get(store.session),
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => undefined,
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: () => undefined,
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Browser pane lifecycle</h1>
|
||||
<p>Selected session: {store.session}</p>
|
||||
<nav style={{ display: "flex", gap: "20px", margin: "16px 0" }}>
|
||||
<For each={["Alpha", "Beta", "Empty"]}>
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
</Show>
|
||||
</div>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<div
|
||||
data-testid={`native-${tab.title}`}
|
||||
data-visible={!!store.layouts[tab.title]?.visible}
|
||||
style={{ padding: "12px", margin: "8px 0", border: "1px solid #555" }}
|
||||
>
|
||||
{tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
() => (
|
||||
<LanguageProvider locale="en">
|
||||
<UiI18nBridge>
|
||||
<DialogProvider>
|
||||
<Fixture />
|
||||
</DialogProvider>
|
||||
</UiI18nBridge>
|
||||
</LanguageProvider>
|
||||
),
|
||||
host,
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}`
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
await page.evaluate(async (fixture) => {
|
||||
const { mountBrowserPane } = await import(fixture)
|
||||
mountBrowserPane()
|
||||
}, fixture)
|
||||
await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await page.screenshot({ path: testInfo.outputPath("session-switch.png") })
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Empty", exact: true }).click()
|
||||
await expect(root.locator("#browser-panel")).toHaveCount(0)
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Unmount pane", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# Session-export load benchmark
|
||||
|
||||
Replay an exported session against a production app build. The two cases compare the default Compact preset with every category ungrouped and details still collapsed.
|
||||
|
||||
From `packages/app` in PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:PLAYWRIGHT_BUILD = '1'
|
||||
$env:PLAYWRIGHT_BASE_URL = 'http://127.0.0.1:4398' # Existing production preview
|
||||
$env:LAGGY_SESSION_FILE = 'C:\path\session.json'
|
||||
$env:LAGGY_SESSION_OUTPUT = 'C:\tmp\opencode\session-load'
|
||||
$env:LAGGY_SESSION_HISTORY = 'paged' # Or 'full' to supply all exported history
|
||||
bun x playwright test --config e2e/performance/playwright.config.ts timeline/laggy-session-benchmark.spec.ts --repeat-each=20 --workers=1 --retries=0
|
||||
bun e2e/performance/timeline/laggy-session-report.ts $env:LAGGY_SESSION_OUTPUT
|
||||
```
|
||||
|
||||
Each test uses a fresh browser context and measures one cold load, switches back to the source session, then measures one warm load. Repetitions therefore interleave `cold → warm` pairs rather than collecting separate cold and warm batches. There are no discarded warm-up switches. The warm member of every pair must issue zero message requests. Each pair is saved in a separate JSON file; compare paired differences as well as the cold and warm distributions when system load varies.
|
||||
|
||||
The report writes `summary.json` and prints the median, p95, maximum, and median paired cold-minus-warm difference. It rejects incomplete or cold-only records instead of mixing them into paired results.
|
||||
|
||||
`--repeat-each=20` collects 20 pairs per grouping mode. `LAGGY_SESSION_COLD_ONLY=1` remains available for focused cold profiling. Screenshots are taken after a pair finishes, not between its measurements.
|
||||
|
||||
The app shell, source session, model control, and fonts are ready before the timed action. These measurements cover session entry, not application startup. `firstCorrectObservedMs` begins at mousedown and ends when the destination is visible at its expected bottom position, including Compact's automatic history fill. `stableObservedMs` includes three-observation confirmation and must not be treated as additional rendering time.
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` for Chrome traces. `LAGGY_TRACE_ITERATION=0` traces the cold load; the default (`1`) traces the warm member of the pair. Profile separately from timing runs.
|
||||
|
||||
`LAGGY_HTTP=1` disables route interception for an external HTTP replay server containing the same export and source fixture. Keep direct HTTP and Playwright-routed cold series separate: routing adds transport overhead. Raw samples, mode settings, viewport, browser version, and screenshots are retained in the output directory. The export itself is not copied into the repository.
|
||||
@@ -1,227 +0,0 @@
|
||||
import { readFileSync, mkdirSync, writeFileSync } from "node:fs"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { timelineCategories, timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
import { stressSessionHref } from "./timeline-test-helpers"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
|
||||
const file = process.env.LAGGY_SESSION_FILE
|
||||
const session = file
|
||||
? (JSON.parse(readFileSync(file, "utf8")) as {
|
||||
info: {
|
||||
id: string
|
||||
projectID: string
|
||||
title: string
|
||||
model?: { id: string; providerID: string }
|
||||
location: { directory: string }
|
||||
time: { created: number; updated: number }
|
||||
}
|
||||
messages: SessionMessageInfo[]
|
||||
})
|
||||
: undefined
|
||||
const sourceID = "ses_laggy_benchmark_source"
|
||||
const sourceMessageID = "msg_laggy_benchmark_source"
|
||||
const history = process.env.LAGGY_SESSION_HISTORY ?? "full"
|
||||
const viewport = { width: 1440, height: 900 }
|
||||
|
||||
benchmark.use({ viewport, video: "off", trace: "off", serviceWorkers: "block", traceScope: "interaction" })
|
||||
|
||||
for (const mode of ["compact", "ungrouped"] as const) {
|
||||
benchmark(`laggy session: ${mode}`, async ({ page, report }, testInfo) => {
|
||||
benchmark.skip(!session, "Set LAGGY_SESSION_FILE to a session export")
|
||||
if (!session) return
|
||||
const output = process.env.LAGGY_SESSION_OUTPUT ?? testInfo.outputPath("session-load")
|
||||
const model = session.info.model ?? { id: "benchmark-model", providerID: "benchmark" }
|
||||
const lastID = session.messages.findLast((message) => message.type === "user")!.id
|
||||
const lastText = session.messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim()),
|
||||
)!
|
||||
benchmark.setTimeout(Number(process.env.LAGGY_SESSION_TIMEOUT ?? 180_000))
|
||||
const requests: string[] = []
|
||||
const errors: string[] = []
|
||||
page.on("pageerror", (error) => errors.push(error.message))
|
||||
if (process.env.LAGGY_HTTP === "1")
|
||||
page.on("request", (request) => {
|
||||
const match = new URL(request.url()).pathname.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (request.method() === "GET" && match) requests.push(decodeURIComponent(match[1]))
|
||||
})
|
||||
const detail = Object.fromEntries(
|
||||
timelineCategories.map((category) => [
|
||||
category,
|
||||
{
|
||||
...timelinePresets[2].value[category],
|
||||
placement: mode === "compact" ? "grouped" : "separate",
|
||||
},
|
||||
]),
|
||||
)
|
||||
const directory = session.info.location.directory
|
||||
if (process.env.LAGGY_HTTP !== "1")
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: session.info.projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "session-benchmark",
|
||||
time: session.info.time,
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: model.providerID,
|
||||
name: model.providerID,
|
||||
models: { [model.id]: { id: model.id, name: model.id, limit: { context: 1_000_000 } } },
|
||||
},
|
||||
],
|
||||
connected: [model.providerID],
|
||||
default: { providerID: model.providerID, modelID: model.id },
|
||||
},
|
||||
sessions: [session.info, { ...session.info, id: sourceID, title: "Benchmark source" }],
|
||||
pageMessages: (id, limit, before) => {
|
||||
if (id !== session.info.id)
|
||||
return {
|
||||
items: [
|
||||
{
|
||||
id: sourceMessageID,
|
||||
type: "user",
|
||||
text: "Benchmark source",
|
||||
time: { created: session.info.time.created },
|
||||
},
|
||||
],
|
||||
}
|
||||
if (history === "full") return { items: session.messages }
|
||||
const end = before ? session.messages.findIndex((message) => message.id === before) : session.messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: session.messages.slice(start, end),
|
||||
cursor: start > 0 ? session.messages[start].id : undefined,
|
||||
}
|
||||
},
|
||||
onMessages: (request) => {
|
||||
if (request.phase === "start") requests.push(request.sessionID)
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ detail, directory, server, sessionIDs, dirBase64 }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { timelineDetail: detail } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{
|
||||
detail,
|
||||
directory,
|
||||
server: process.env.PLAYWRIGHT_BASE_URL!,
|
||||
sessionIDs: [sourceID, session.info.id],
|
||||
dirBase64: base64Encode(directory),
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(sourceID))
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(page.getByRole("button", { name: model.id, exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready.then(() => undefined))
|
||||
expect(requests).toEqual([sourceID])
|
||||
const startedAt = new Date().toISOString()
|
||||
const samples = []
|
||||
const phases = process.env.LAGGY_SESSION_COLD_ONLY === "1" ? (["cold"] as const) : (["cold", "warm"] as const)
|
||||
for (const [iteration, phase] of phases.entries()) {
|
||||
const before = requests.length
|
||||
const stopTrace =
|
||||
iteration === Number(process.env.LAGGY_TRACE_ITERATION ?? 1)
|
||||
? await startChromeTrace(page, `laggy-${history}-${mode}`)
|
||||
: undefined
|
||||
const result = await measureSessionSwitch(page, {
|
||||
destinationIDs: session.messages.map((message) => message.id),
|
||||
sourceIDs: [sourceMessageID],
|
||||
lastID,
|
||||
requiredPartID: history === "paged" && mode === "compact" ? `${lastText.id}:text:0` : undefined,
|
||||
requireBottomAnchor: true,
|
||||
href: stressSessionHref(session.info.id),
|
||||
switch: async () => {
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(session.info.id)}"]`).click()
|
||||
},
|
||||
})
|
||||
await stopTrace?.()
|
||||
await expectSessionTitle(page, session.info.title)
|
||||
if (history === "full" || mode === "ungrouped") await waitForStableTimeline(page, lastID)
|
||||
await expect(
|
||||
page.locator('[data-timeline-key] [data-component="markdown"]:not([data-markdown-ready])'),
|
||||
).toHaveCount(0)
|
||||
expect(result.firstCorrectObservedMs).not.toBeNull()
|
||||
expect(result.stableObservedMs).not.toBeNull()
|
||||
if (phase === "warm") expect(requests.length - before).toBe(0)
|
||||
samples.push({
|
||||
iteration,
|
||||
phase,
|
||||
messageRequests: requests.length - before,
|
||||
messageResources: await page.evaluate((sessionID) => {
|
||||
const start = performance.getEntriesByName("session-switch:start").at(-1)!.startTime
|
||||
return (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.startTime >= start && new URL(entry.name).pathname === `/api/session/${sessionID}/message`,
|
||||
)
|
||||
.map((entry) => ({
|
||||
limit: Number(new URL(entry.name).searchParams.get("limit")),
|
||||
startMs: entry.startTime - start,
|
||||
durationMs: entry.duration,
|
||||
transferBytes: entry.transferSize,
|
||||
}))
|
||||
}, session.info.id),
|
||||
...result,
|
||||
})
|
||||
if (iteration === phases.length - 1) {
|
||||
mkdirSync(output, { recursive: true })
|
||||
if (testInfo.repeatEachIndex === 0) await page.screenshot({ path: `${output}/${mode}.png` })
|
||||
break
|
||||
}
|
||||
await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(sourceID)}"]`).click()
|
||||
await expectSessionTitle(page, "Benchmark source")
|
||||
await expect(page.locator('[data-slot="user-message-text"]')).toHaveText("Benchmark source")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
}
|
||||
expect(errors).toEqual([])
|
||||
const result = {
|
||||
pair: testInfo.repeatEachIndex,
|
||||
startedAt,
|
||||
mode,
|
||||
history,
|
||||
file,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
browser: page.context().browser()!.version(),
|
||||
detail,
|
||||
samples,
|
||||
}
|
||||
writeFileSync(`${output}/${mode}-${testInfo.repeatEachIndex}.json`, JSON.stringify(result, null, 2))
|
||||
report(
|
||||
{ samples },
|
||||
{
|
||||
mode,
|
||||
sampling: "cold/warm pair in one browser context",
|
||||
pair: testInfo.repeatEachIndex,
|
||||
messages: session.messages.length,
|
||||
viewport,
|
||||
data: history === "full" ? "full exported history" : "paginated exported history",
|
||||
transport: process.env.LAGGY_HTTP === "1" ? "http" : "playwright-route",
|
||||
inputEvent: "mousedown",
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
export {}
|
||||
|
||||
type Pair = {
|
||||
mode: "compact" | "ungrouped"
|
||||
samples: { phase: string; firstCorrectObservedMs: number | null; messageRequests: number }[]
|
||||
}
|
||||
|
||||
const directory = Bun.argv[2]
|
||||
if (!directory) throw new Error("Pass the directory containing session-load pairs")
|
||||
const pairs = await Promise.all(
|
||||
[...new Bun.Glob("{compact,ungrouped}-*.json").scanSync(directory)].map(async (file) => {
|
||||
const pair = (await Bun.file(`${directory}/${file}`).json()) as Pair
|
||||
const cold = pair.samples.find((sample) => sample.phase === "cold")
|
||||
const warm = pair.samples.find((sample) => sample.phase === "warm")
|
||||
if (cold?.firstCorrectObservedMs == null || warm?.firstCorrectObservedMs == null)
|
||||
throw new Error(`Expected a completed cold/warm pair in ${file}`)
|
||||
return {
|
||||
mode: pair.mode,
|
||||
cold: cold.firstCorrectObservedMs,
|
||||
warm: warm.firstCorrectObservedMs,
|
||||
requests: warm.messageRequests,
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!pairs.length) throw new Error(`No session-load pairs found in ${directory}`)
|
||||
|
||||
const result = ["compact", "ungrouped"].flatMap((mode) => {
|
||||
const selected = pairs.filter((pair) => pair.mode === mode)
|
||||
if (!selected.length) return []
|
||||
return [
|
||||
{
|
||||
mode,
|
||||
cold: { ...stats(selected.map((pair) => pair.cold)), over50ms: selected.filter((pair) => pair.cold > 50).length },
|
||||
warm: { ...stats(selected.map((pair) => pair.warm)), over50ms: selected.filter((pair) => pair.warm > 50).length },
|
||||
pairedColdMinusWarm: stats(selected.map((pair) => pair.cold - pair.warm)),
|
||||
messageRequestsDuringWarm: selected.reduce((total, pair) => total + pair.requests, 0),
|
||||
},
|
||||
]
|
||||
})
|
||||
await Bun.write(`${directory}/summary.json`, JSON.stringify(result, null, 2))
|
||||
console.table(
|
||||
result.map((row) => ({
|
||||
mode: row.mode,
|
||||
pairs: row.cold.n,
|
||||
coldMedianMs: Math.round(row.cold.median * 10) / 10,
|
||||
coldP95Ms: Math.round(row.cold.p95 * 10) / 10,
|
||||
warmMedianMs: Math.round(row.warm.median * 10) / 10,
|
||||
warmP95Ms: Math.round(row.warm.p95 * 10) / 10,
|
||||
warmMaxMs: Math.round(row.warm.max * 10) / 10,
|
||||
pairedDifferenceMs: Math.round(row.pairedColdMinusWarm.median * 10) / 10,
|
||||
})),
|
||||
)
|
||||
|
||||
function stats(values: number[]) {
|
||||
const sorted = values.toSorted((left, right) => left - right)
|
||||
return {
|
||||
n: sorted.length,
|
||||
median: (sorted[Math.floor((sorted.length - 1) / 2)] + sorted[Math.floor(sorted.length / 2)]) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1],
|
||||
min: sorted[0],
|
||||
max: sorted.at(-1)!,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_background_image"
|
||||
const directory = "/tmp/background-image"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const image = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
)
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_background_image",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "background-image",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
})
|
||||
|
||||
test("selects, restores, and removes a background image", async ({ page }) => {
|
||||
const providerTip = page.locator('[data-component="new-session-tip"][data-kind="provider"]')
|
||||
await expect(providerTip).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
const chooser = page.waitForEvent("filechooser")
|
||||
await settings.getByRole("button", { name: "Choose image", exact: true }).click()
|
||||
await (await chooser).setFiles({ name: "background.png", mimeType: "image/png", buffer: image })
|
||||
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeVisible()
|
||||
const shell = page.locator('[data-component="app-shell"]')
|
||||
await expect(shell).toHaveAttribute("data-background-image", "")
|
||||
await expect(shell).toHaveCSS("background-image", /blob:/)
|
||||
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page.locator('[data-component="new-session"][data-background-surface="canvas"]')).toBeVisible()
|
||||
await expect(providerTip).toBeHidden()
|
||||
await page.reload()
|
||||
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
|
||||
await expect(shell).toHaveAttribute("data-background-image", "")
|
||||
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(settings).toBeFocused()
|
||||
await settings.getByRole("tab", { name: "Appearance", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Remove", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "Remove", exact: true })).toBeHidden()
|
||||
await expect(shell).not.toHaveAttribute("data-background-image", "")
|
||||
})
|
||||
@@ -16,11 +16,6 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
@@ -22,10 +22,6 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsBranches: ["feature/api", "main", "origin/release"],
|
||||
})
|
||||
await page.route("**/api/vcs/branches?*", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
|
||||
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem(
|
||||
@@ -48,28 +44,10 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
|
||||
await page.getByRole("button", { name: "from main", exact: true }).click()
|
||||
const search = page.getByRole("textbox", { name: "Search branches", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("feature")
|
||||
await expect(search).toHaveValue("feature")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
|
||||
await expect(search).toBeFocused()
|
||||
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
|
||||
|
||||
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
|
||||
await expect(selected).toBeVisible()
|
||||
await selected.click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(selected).toBeFocused()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("feature")
|
||||
await expect(search).toHaveValue("feature")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api"])
|
||||
await page.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(page.getByRole("menuitemradio")).toHaveText(["feature/api", "main", "origin/release"])
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -85,38 +85,6 @@ for (const width of [1000, 1440]) {
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps moving header content out of the toggle area (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
for (const opened of [true, false]) {
|
||||
// Pause in the same task as the click so even the first painted state can be inspected.
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", String(!opened))
|
||||
for (const progress of [0.08, 0.16, 0.25, 0.5, 0.8, 0.96]) {
|
||||
await expectHeaderClearOfToggle(page, toggle, progress)
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.finish())
|
||||
})
|
||||
}
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
@@ -198,75 +166,10 @@ for (const width of [1000, 1440]) {
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
|
||||
// Closing the terminal clears the region's animation flag while retaining the review contents.
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => element.getAnimations().every((animation) => animation.playState === "finished")),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="session-review-content"]')).toHaveCSS("opacity", "0")
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expectHeaderClearOfToggle(page, toggle, 0.25)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress: number) {
|
||||
const geometry = await page.locator('[data-slot="session-chat-panel"]').evaluate((chat, progress) => {
|
||||
const row = chat.parentElement!
|
||||
const animations = row
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
const width = animations.find(
|
||||
(animation) => animation instanceof CSSTransition && animation.transitionProperty === "width",
|
||||
)!
|
||||
animations.forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = Number(width.effect!.getTiming().duration) * progress
|
||||
})
|
||||
const chatBounds = chat.getBoundingClientRect()
|
||||
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
return {
|
||||
row: row.getBoundingClientRect().width,
|
||||
panelWidth: panelBounds.width,
|
||||
gap:
|
||||
getComputedStyle(row).direction === "rtl"
|
||||
? chatBounds.left - panelBounds.right
|
||||
: panelBounds.left - chatBounds.right,
|
||||
contentOpacity: Number(getComputedStyle(document.querySelector('[data-slot="session-review-content"]')!).opacity),
|
||||
panels: chatBounds.width + panelBounds.width + parseFloat(getComputedStyle(row).columnGap),
|
||||
}
|
||||
}, progress)
|
||||
expect(geometry.gap).toBeCloseTo(8, 1)
|
||||
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
|
||||
if (progress === 0.25) {
|
||||
expect(geometry.contentOpacity).toBeGreaterThan(0)
|
||||
expect(geometry.contentOpacity).toBeLessThan(1)
|
||||
}
|
||||
|
||||
const clip = await toggle.boundingBox()
|
||||
if (!clip) throw new Error("Review toggle bounds are unavailable")
|
||||
// Header contents must make no difference to the pixels behind the fixed toggle.
|
||||
expect(await page.screenshot({ clip })).toEqual(
|
||||
await page.screenshot({
|
||||
clip,
|
||||
style: ".session-review-v2-tabs-bar { visibility: hidden !important; }",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const reducedMotion of [false, true]) {
|
||||
test(`suppresses the scrollbar from toggle press until timeline interaction (reduced motion: ${reducedMotion})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, { seedHistory: true, reducedMotion })
|
||||
const chat = page.locator('[data-slot="session-chat-panel"]')
|
||||
const scroll = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const viewport = scroll.locator(".scroll-view__viewport")
|
||||
const thumb = scroll.locator('.scroll-view__thumb[data-orientation="vertical"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(thumb).toHaveCount(1)
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
|
||||
for (const opened of [true, false]) {
|
||||
await toggle.hover()
|
||||
await page.mouse.down()
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await page.mouse.up()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await chat.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations().map((animation) => animation.finished))
|
||||
})
|
||||
await expect(chat).toHaveAttribute("data-width-animating", "false")
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
// Late scroll anchoring must not bring the thumb back after the panel has settled.
|
||||
await viewport.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
element.addEventListener("scroll", () => resolve(), { once: true })
|
||||
element.scrollTop += element.scrollTop > 0 ? -1 : 1
|
||||
}),
|
||||
)
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -308,7 +308,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^Used\s*2\s*Read, Grep$/)
|
||||
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/^2 used\s*Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { waitForStableTimeline } from "../performance/timeline/session-tab-switch-probe"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("recovers from a failed cold history load when another session is selected", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await page.route(`**/api/session/${fixture.targetID}/message?*`, (route) =>
|
||||
route.fulfill({ status: 500, json: { message: "History unavailable" } }),
|
||||
)
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: "Something went wrong", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("focuses Find in the selected cached timeline", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
pageMessages: (id) => ({
|
||||
items: [{ id: `msg_${id}`, type: "user", text: `History for ${id}`, time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
const search = page.locator('[data-component="timeline-search-bar"] input')
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.type("History")
|
||||
await expect(search).toHaveValue("History")
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`History for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await page.keyboard.press("ControlOrMeta+f")
|
||||
await expect(search).toBeFocused()
|
||||
await search.press("Escape")
|
||||
await expect(search).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("disposes the old workspace's shell while destination history is loading", async ({ page }) => {
|
||||
const destination = "C:/OpenCode/OtherProject"
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
const reads: string[] = []
|
||||
const output = { text: "Initial shell output\n" }
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: fixture.sessions.map((session) =>
|
||||
session.id === fixture.targetID ? { ...session, directory: destination } : session,
|
||||
),
|
||||
pageMessages: (id) => ({
|
||||
items:
|
||||
id === fixture.sourceID
|
||||
? ([
|
||||
{ id: "msg_workspace_source", type: "user", text: "Follow the shell", time: { created: 1 } },
|
||||
{
|
||||
id: "msg_workspace_shell",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_workspace_shell",
|
||||
name: "shell",
|
||||
time: { created: 2 },
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "run checks" },
|
||||
metadata: { shellID: "sh_workspace_source" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[])
|
||||
: [],
|
||||
}),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await page.route("**/api/shell/sh_workspace_source/output?*", (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")!
|
||||
reads.push(directory)
|
||||
if (directory !== fixture.directory)
|
||||
return route.fulfill({ status: 404, json: { _tag: "ShellNotFoundError", id: "sh_workspace_source" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory },
|
||||
data: {
|
||||
output: output.text.slice(Number(url.searchParams.get("cursor") ?? 0)),
|
||||
cursor: output.text.length,
|
||||
size: output.text.length,
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
(detail) =>
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: { ...detail, shell: { placement: "separate", details: "expanded" } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
timelinePresets[2].value,
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const shell = page.locator('[data-timeline-part-id="call_workspace_shell"]')
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Initial shell output")
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-session-title]")).toHaveText(fixture.expected.targetTitle)
|
||||
output.text += "Output after returning\n"
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toContainText("Output after returning")
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
expect(reads.length).toBeGreaterThan(1)
|
||||
expect(reads.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads the transcript code font before opening rich history", async ({ page }) => {
|
||||
const font = page.waitForResponse((response) => /IBMPlexMono-Text[^/]*\.woff2/.test(response.url()))
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_font_source", type: "user", text: "A transcript with no code", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
|
||||
expect((await font).ok()).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
|
||||
})
|
||||
|
||||
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: fixture.messages[id] ?? [] }),
|
||||
beforeMessagesResponse: async ({ sessionID }) => {
|
||||
if (sessionID !== fixture.targetID) return
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
},
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
try {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await requested.promise
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(0)
|
||||
release.resolve()
|
||||
await waitForStableTimeline(page, fixture.expected.targetMessageIDs.at(-1)!)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
test(`restores a ${grouped ? "grouped" : "separate"} timeline after inactive updates and a resize`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
const messages: Record<string, SessionMessageInfo[]> = Object.fromEntries(
|
||||
[fixture.sourceID, fixture.targetID].map((id) => [
|
||||
id,
|
||||
[
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: `tool_${id}`,
|
||||
name: "shell",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { command: `echo ${id}` },
|
||||
metadata: {},
|
||||
content: [{ type: "text", text: `Output for ${id}` }],
|
||||
},
|
||||
},
|
||||
{ type: "text", text: `Answer for ${id}` },
|
||||
],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
]),
|
||||
)
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: fixture.sessions,
|
||||
pageMessages: (id) => ({ items: messages[id] ?? [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(
|
||||
({ grouped, detail }) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
timelineDetail: {
|
||||
...detail,
|
||||
shell: { placement: grouped ? "grouped" : "separate", details: "collapsed" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ grouped, detail: timelinePresets[2].value },
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expect(page.getByText(`Answer for ${fixture.sourceID}`, { exact: true })).toBeVisible()
|
||||
if (grouped)
|
||||
await page
|
||||
.locator(
|
||||
'[data-component="collapsed-tool-group"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"]',
|
||||
)
|
||||
.click()
|
||||
const shell = page.locator(`[data-timeline-part-id="tool_${fixture.sourceID}"]`)
|
||||
const trigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
const original = await page.locator("[data-timeline-virtual-content]").elementHandle()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.targetID)}"]`).click()
|
||||
await expect(page.getByText(`Answer for ${fixture.targetID}`, { exact: true })).toBeVisible()
|
||||
await expect(shell).toHaveCount(0)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(false)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
events.push({
|
||||
id: "evt_cached_text",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: fixture.directory },
|
||||
durable: { aggregateID: fixture.sourceID, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: fixture.sourceID,
|
||||
assistantMessageID: `msg_assistant_${fixture.sourceID}`,
|
||||
ordinal: 0,
|
||||
text: "Updated while inactive",
|
||||
},
|
||||
})
|
||||
await page.setViewportSize({ width: 900, height: 650 })
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(fixture.sourceID)}"]`).click()
|
||||
await expect(page.getByText("Updated while inactive", { exact: true })).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText(`Output for ${fixture.sourceID}`)
|
||||
expect(await original!.evaluate((element) => element.isConnected)).toBe(true)
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCount(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("[data-timeline-key]")
|
||||
.evaluateAll((rows) =>
|
||||
rows.every(
|
||||
(row) =>
|
||||
(row.firstElementChild?.getBoundingClientRect().height ?? 0) <= row.getBoundingClientRect().height + 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("1 used Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -110,7 +110,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 40
|
||||
const messagePageSize = 20
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
@@ -188,7 +188,7 @@ for (const scenario of scenarios) {
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: 20 },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
for (const window of ["assistant-only", "mixed"] as const) {
|
||||
test(`renders the ${window} latest page before parent hydration and preserves it afterward`, async ({ page }) => {
|
||||
const session = { ...fixture.sessions[0]!, id: `ses_hydration_${window}` }
|
||||
// Compact's initial 40 and the next 20 begin with an assistant; page three supplies its parent.
|
||||
const messages = Array.from({ length: 61 }, (_, index): SessionMessageInfo => {
|
||||
// Both 20-message pages begin with an assistant; only page three supplies its parent.
|
||||
const messages = Array.from({ length: 41 }, (_, index): SessionMessageInfo => {
|
||||
const id = `msg_hydration_${index}`
|
||||
const time = { created: 1700000000000 + index * 1_000 }
|
||||
if (index === 0 || (window === "mixed" && index === 59))
|
||||
if (index === 0 || (window === "mixed" && index === 39))
|
||||
return { id, type: "user", time, text: `Prompt ${index}` }
|
||||
return {
|
||||
id,
|
||||
@@ -21,7 +21,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
time: { ...time, completed: time.created + 500 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
content: [{ type: "text", text: index === 60 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
content: [{ type: "text", text: index === 40 ? "## Hydrated tail\n\n**Ready.**" : `Answer ${index}` }],
|
||||
}
|
||||
})
|
||||
const gates = [21, 1].map((index) => ({
|
||||
@@ -43,18 +43,18 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await gate.release.promise
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
expect(limit).toBe(before ? 20 : 40)
|
||||
expect(limit).toBe(20)
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: messages.slice(start, end), cursor: start > 0 ? messages[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_60:text:0"]')
|
||||
const tail = page.locator('[data-timeline-part-id="msg_hydration_40:text:0"]')
|
||||
const markdown = tail.locator('[data-component="markdown"]')
|
||||
const content = page.locator("[data-timeline-virtual-content]", { has: tail })
|
||||
const viewport = page.locator(".scroll-view__viewport", { has: tail })
|
||||
const orphan = page.locator('[data-timeline-row="AssistantPart"]', {
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_58:text:0"]'),
|
||||
has: page.locator('[data-timeline-part-id="msg_hydration_38:text:0"]'),
|
||||
})
|
||||
const expectReadyTail = async () => {
|
||||
await expect(content).toHaveCSS("visibility", "visible")
|
||||
@@ -75,7 +75,7 @@ for (const window of ["assistant-only", "mixed"] as const) {
|
||||
await expect(orphan).toHaveAttribute("data-message-id", "msg_hydration_21")
|
||||
if (window === "mixed")
|
||||
await expect(
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_59"]'),
|
||||
page.locator('[data-timeline-row="UserMessage"][data-message-id="msg_hydration_39"]'),
|
||||
).toBeInViewport()
|
||||
const original = await markdown.elementHandle()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
directory,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
@@ -86,13 +87,12 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders compaction progress, summary, and outcome in 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)],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
await timeline.send(
|
||||
@@ -104,15 +104,7 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-working"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 900 })
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeInViewport()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
@@ -122,16 +114,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
@@ -143,18 +125,6 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const completed = await compaction.getByText("Session compacted", { exact: true }).boundingBox()
|
||||
return !!summary && !!completed && completed.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByRole("status")).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
@@ -176,10 +146,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("Session compaction failed", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(failed.getByRole("status")).toHaveCount(0)
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
@@ -197,48 +164,11 @@ test("updates running compactions to failed and cancelled boundaries", async ({
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(cancelled.getByText("Session compaction cancelled", { exact: true })).toBeVisible()
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(cancelled.getByRole("status")).toHaveCount(0)
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
await expect(cancelled).not.toContainText("Summary before cancellation.")
|
||||
})
|
||||
|
||||
test("shows an interrupted outcome when stopping automatic compaction", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
})
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial automatic summary." }))
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
await expect(compaction).toContainText("Partial automatic summary.")
|
||||
|
||||
const request = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/interrupt`,
|
||||
)
|
||||
await page.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await request
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compaction.getByText("Session compaction started", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compaction interrupted", { exact: true })).toBeVisible()
|
||||
await expect(compaction.getByText("Session compaction failed", { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
await expect(compaction.getByRole("status")).toHaveCount(0)
|
||||
await expect(compaction).not.toContainText("Partial automatic summary.")
|
||||
await expect(compaction).not.toContainText("Compaction was interrupted")
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
settings: {
|
||||
@@ -456,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
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*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.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 page.getByRole("button", { name: "1 used 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()
|
||||
|
||||
@@ -45,7 +45,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
|
||||
@@ -75,7 +75,7 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
@@ -114,8 +114,8 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -129,11 +129,12 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
@@ -161,7 +162,7 @@ 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 summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
|
||||
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [{ type: "text", text: `Answer for ${id}` }],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/location?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
|
||||
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
await page.goto(stressSessionHref(sessions[0]!.id))
|
||||
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
|
||||
|
||||
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
const active = sessions[0]!
|
||||
events.push({
|
||||
id: "evt_workspace_cycle_update",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: active.directory },
|
||||
durable: { aggregateID: active.id, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: active.id,
|
||||
assistantMessageID: `msg_assistant_${active.id}`,
|
||||
ordinal: 0,
|
||||
text: "Still receiving updates",
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
test.describe(colorScheme, () => {
|
||||
test.use({ colorScheme, contextOptions: { reducedMotion: "reduce" } })
|
||||
|
||||
test("project card edges stay inside the settings scrollport", async ({ page }, info) => {
|
||||
const projects = ["rebase", "dinocms", "opencode", "Playground"].map((name, index) => ({
|
||||
id: `project-${index}`,
|
||||
name,
|
||||
canonical: `/projects/${name}`,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}))
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: "/projects/rebase",
|
||||
project: projects[0],
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
})
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({ json: projects, headers: { "access-control-allow-origin": "*" } }),
|
||||
)
|
||||
await page.addInitScript((projects) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: projects.map((project) => ({ worktree: project.canonical, expanded: true })) },
|
||||
}),
|
||||
)
|
||||
}, projects)
|
||||
await page.goto("/")
|
||||
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeEnabled()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel")
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeVisible()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
for (const width of [1280, 1050, 960, 720, 600]) {
|
||||
await page.setViewportSize({ width, height: 720 })
|
||||
await page.mouse.move(0, 0)
|
||||
await page.screenshot({ path: info.outputPath(`projects-${width}.png`), animations: "disabled" })
|
||||
// Raised cards paint a half-pixel border outside their box. The scrollport
|
||||
// must leave room for that border and the soft shadow on both sides.
|
||||
await expect
|
||||
.poll(() =>
|
||||
panel.getByText("rebase", { exact: true }).evaluate((label) => {
|
||||
const row = label.parentElement!.parentElement!
|
||||
const bounds = row.getBoundingClientRect()
|
||||
const clips = []
|
||||
for (let parent = row.parentElement; parent; parent = parent.parentElement) {
|
||||
if (getComputedStyle(parent).overflowX === "visible") continue
|
||||
const clip = parent.getBoundingClientRect()
|
||||
clips.push(bounds.left - clip.left, clip.right - bounds.right)
|
||||
}
|
||||
return Math.min(...clips)
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(4)
|
||||
await expect(panel).toHaveJSProperty("scrollWidth", await panel.evaluate((el) => el.clientWidth))
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await panel.getByText("rebase", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 260 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await page.mouse.wheel(0, 400)
|
||||
await expect(panel.getByText("Playground", { exact: true })).toBeInViewport({ ratio: 1 })
|
||||
await expect(panel.getByRole("heading", { name: "Projects", exact: true })).toBeInViewport({ ratio: 1 })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -80,7 +80,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const id = state.connections
|
||||
let ended = false
|
||||
let own: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
let keepalive: ReturnType<typeof setInterval> | undefined
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
own = controller
|
||||
@@ -90,15 +89,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||
)
|
||||
state.buffer.splice(0).forEach((item) => controller.enqueue(encoder.encode(item)))
|
||||
// Match the real server's idle stream so long scenarios do not
|
||||
// trigger the client's 45-second stall watchdog and reload history.
|
||||
keepalive = setInterval(() => controller.enqueue(encoder.encode(": keepalive\n\n")), 15_000)
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === controller) state.controller = undefined
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
@@ -108,7 +103,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
cancel() {
|
||||
if (ended) return
|
||||
ended = true
|
||||
clearInterval(keepalive)
|
||||
if (state.controller === own) state.controller = undefined
|
||||
},
|
||||
})
|
||||
|
||||
@@ -132,6 +132,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
</Show>
|
||||
<form
|
||||
data-component="composer"
|
||||
data-background-surface="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
classList={{
|
||||
|
||||
@@ -88,12 +88,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -126,9 +122,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
|
||||
(error) => failSubmission(input, session, "command", error, restore, value.id),
|
||||
)
|
||||
// Commands always steer: the server applies a command's configured
|
||||
// agent and model immediately at admission, so queueing one would
|
||||
// reconfigure the turn it is supposed to wait behind.
|
||||
void sendCommand(
|
||||
session,
|
||||
{ ...value, delivery: "steer" },
|
||||
command,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
|
||||
return
|
||||
}
|
||||
} finally {
|
||||
@@ -320,8 +322,7 @@ async function sendCommand(
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Like queued prompts, queued commands must not apply the composer's selection to active work.
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track)
|
||||
await applySelection(session, value.selection, track)
|
||||
await session.api.command({
|
||||
sessionID: session.id,
|
||||
command: command.command,
|
||||
@@ -358,8 +359,7 @@ async function applySelection(
|
||||
async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
// Switching agent or model reconfigures the session immediately, and with it
|
||||
@@ -389,9 +389,7 @@ async function sendPrompt(
|
||||
},
|
||||
},
|
||||
}
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function Home() {
|
||||
const scroll = createHomeScrollController(sessions.data.groups)
|
||||
return (
|
||||
<div
|
||||
data-background-surface="panel"
|
||||
class={`
|
||||
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
|
||||
+41
-63
@@ -3,6 +3,38 @@
|
||||
@import "@opencode/ui/styles/tokens";
|
||||
@import "tw-animate-css";
|
||||
|
||||
[data-component="app-shell"][data-background-image] {
|
||||
background-image: linear-gradient(rgb(255 255 255 / 28%), rgb(255 255 255 / 28%)), var(--app-background-image);
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="app-shell"][data-background-image] {
|
||||
background-image: linear-gradient(rgb(0 0 0 / 28%), rgb(0 0 0 / 28%)), var(--app-background-image);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] .bg-v2-background-bg-deep {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="panel"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 72%, transparent);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="canvas"] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-background-surface="composer"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 55%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
[data-component="app-shell"][data-background-image] [data-component="new-session-tip"][data-kind="provider"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
|
||||
@@ -38,65 +70,12 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
#review-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Like the composer toolbar, fade only clipped content without reserving layout space.
|
||||
The fade contracts as overflow clears; the second mask preserves the header divider. */
|
||||
#review-panel .session-review-v2-tabs-bar {
|
||||
--session-review-header-fade: clamp(0px, calc(100% - 100cqi), 24px);
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
|
||||
&:dir(rtl) {
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to left,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The panel's width animation supplies the slide; only fade its fixed-width contents. */
|
||||
[data-slot="session-side-region-presence"][data-opened] [data-slot="session-review-content"] {
|
||||
transition: opacity 200ms ease-out 40ms;
|
||||
}
|
||||
|
||||
/* Cached contents must stay transparent even after the presence animation finishes. */
|
||||
#review-panel[aria-hidden="true"] > [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] [data-slot="session-review-content"] {
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
@@ -135,15 +114,10 @@
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-review-content"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[data-slot="session-review-content"] {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
@@ -183,9 +157,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Presence needs an animation lifetime, but the panel frame must never fade. */
|
||||
@keyframes side-region-presence-in {
|
||||
from,
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -196,7 +174,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function NewSessionView(props: {
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div
|
||||
data-component="new-session"
|
||||
data-background-surface="canvas"
|
||||
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
|
||||
>
|
||||
<ComposerDropzone
|
||||
@@ -194,6 +195,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="new-session-tip"
|
||||
data-kind={displayed()}
|
||||
data-visible={tip() !== undefined}
|
||||
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
@@ -148,7 +148,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={24}
|
||||
overflowPadding={8}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
@@ -177,7 +177,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<Menu.SubContent class="max-h-[224px] w-[200px] overflow-y-auto">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
@@ -242,7 +242,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu
|
||||
placement="bottom"
|
||||
gutter={4}
|
||||
onOpenChange={(open) => {
|
||||
onOpenChange(open)
|
||||
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
@@ -251,14 +258,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
// Kobalte defers its list autofocus until after the focus scope opens.
|
||||
setTimeout(() => requestAnimationFrame(() => branchSearchInput?.focus({ preventScroll: true })))
|
||||
}}
|
||||
>
|
||||
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
|
||||
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -80,8 +79,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
|
||||
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -101,7 +100,6 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (isOrganizationRouteProvider(model.providerID)) return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
@@ -198,9 +197,6 @@ const ModelList: Component<{
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
@@ -493,9 +489,6 @@ function ModelSelectorPopoverView(props: {
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
@@ -9,7 +8,6 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id?: string
|
||||
name: string
|
||||
}
|
||||
capabilities?: {
|
||||
@@ -38,9 +36,7 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
props,
|
||||
) => {
|
||||
const language = useLanguage()
|
||||
const route = () => isOrganizationRouteProvider(props.model.provider.id ?? "")
|
||||
const sourceName = (model: ModelInfo) => {
|
||||
if (route()) return model.provider.name
|
||||
const value = `${model.id} ${model.name}`.toLowerCase()
|
||||
|
||||
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
|
||||
@@ -62,16 +58,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
const title = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
@@ -104,13 +98,11 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
<div class="flex w-[180px] flex-col gap-2">
|
||||
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,17 +110,15 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
return (
|
||||
<div class="flex flex-col gap-1 py-1">
|
||||
<div class="text-13-medium">{title()}</div>
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,7 +280,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ተቋርጧል",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} ሞዴሎች ከአሁን በኋላ አይገኙም።",
|
||||
"model.tag.free": "ነጻ",
|
||||
"model.tag.variable": "ተለዋዋጭ",
|
||||
"model.tag.latest": "የቅርብ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -290,7 +290,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
|
||||
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
|
||||
"model.tag.free": "مجاني",
|
||||
"model.tag.variable": "متغير",
|
||||
"model.tag.latest": "الأحدث",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,7 +287,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ayrıldı",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modelləri artıq mövcud deyil.",
|
||||
"model.tag.free": "Pulsuz",
|
||||
"model.tag.variable": "Dəyişkən",
|
||||
"model.tag.latest": "Ən son",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,7 +287,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} прекъснат",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} модели вече не са налични.",
|
||||
"model.tag.free": "безплатно",
|
||||
"model.tag.variable": "Променлива",
|
||||
"model.tag.latest": "Последни",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,7 +284,6 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} সংযোগ বিচ্ছিন্ন",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} মডেল আর উপলব্ধ নেই৷",
|
||||
"model.tag.free": "বিনামূল্যে",
|
||||
"model.tag.variable": "পরিবর্তনশীল",
|
||||
"model.tag.latest": "সর্বশেষ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -292,7 +292,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
|
||||
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
|
||||
"model.tag.free": "Grátis",
|
||||
"model.tag.variable": "Variável",
|
||||
"model.tag.latest": "Mais recente",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -308,7 +308,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
|
||||
|
||||
"model.tag.free": "Besplatno",
|
||||
"model.tag.variable": "Promjenjiva",
|
||||
"model.tag.latest": "Najnovije",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -286,7 +286,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} desconnectat",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models ja no estan disponibles.",
|
||||
"model.tag.free": "Gratuït",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Última",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,7 +284,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojeno",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modely již nejsou k dispozici.",
|
||||
"model.tag.free": "Zdarma",
|
||||
"model.tag.variable": "Proměnlivá",
|
||||
"model.tag.latest": "Nejnovější",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -205,7 +205,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
|
||||
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Nyeste",
|
||||
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
|
||||
@@ -196,7 +196,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
|
||||
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
|
||||
"model.tag.free": "Kostenlos",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Neueste",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -289,7 +289,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ކަނެކްޓް ވެއްޖެއެވެ",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} މޮޑެލްތައް މިހާރު ލިބެން ނެތެވެ.",
|
||||
"model.tag.free": "ހިލޭ",
|
||||
"model.tag.variable": "ބަދަލުވާ",
|
||||
"model.tag.latest": "އެންމެފަހުގެ",
|
||||
"model.provider.anthropic": "Anthropic އެވެ",
|
||||
"model.provider.openai": "OpenAI އެވެ",
|
||||
|
||||
@@ -288,7 +288,6 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} མཐུད་ལམ་ཆད་ཡོདཔ།",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} དཔེ་ཚད་ཚུ་ད་ལས་ཕར་འཐོབ་མི་ཚུགས།",
|
||||
"model.tag.free": "རིན་མེད་སྟོང་པ",
|
||||
"model.tag.variable": "འགྱུར་བཅོས་ཅན་",
|
||||
"model.tag.latest": "ད༌རེས༌ནངས༌པ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,7 +285,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} αποσυνδέθηκε",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} μοντέλα δεν είναι πλέον διαθέσιμα.",
|
||||
"model.tag.free": "Δωρεάν",
|
||||
"model.tag.variable": "Μεταβλητή",
|
||||
"model.tag.latest": "Τελευταία",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -107,7 +107,6 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.browser.open": "Open browser",
|
||||
"command.browser.reload": "Reload browser page",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
@@ -267,7 +266,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
|
||||
|
||||
"model.tag.free": "Free",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Latest",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
@@ -770,8 +768,7 @@ export const dict = {
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "Plus {{count}} attachment",
|
||||
"session.queue.attachments.other": "Plus {{count}} attachments",
|
||||
"session.queue.attachments": "+ attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
@@ -899,7 +896,7 @@ export const dict = {
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
@@ -1031,6 +1028,13 @@ export const dict = {
|
||||
"settings.appearance.row.tabs.vertical": "Vertical",
|
||||
"settings.appearance.row.projectName.title": "Show project names",
|
||||
"settings.appearance.row.projectName.description": "Show project names in vertical tabs and the mobile tab drawer",
|
||||
"settings.appearance.row.backgroundImage.title": "Background image",
|
||||
"settings.appearance.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.appearance.row.backgroundImage.choose": "Choose image",
|
||||
"settings.appearance.row.backgroundImage.remove": "Remove",
|
||||
"settings.appearance.row.backgroundImage.pickerTitle": "Choose a background image",
|
||||
"settings.appearance.row.backgroundImage.error.unsupported": "Choose a PNG, JPEG, GIF, WebP, AVIF, or BMP image.",
|
||||
"settings.appearance.row.backgroundImage.error.too-large": "Background images must be 20 MB or smaller.",
|
||||
"settings.notifications.description": "Choose when to receive notifications and hear sounds",
|
||||
"settings.shortcuts.description": "Customize shortcuts for common actions",
|
||||
"settings.servers.description": "Manage server connections",
|
||||
@@ -1127,7 +1131,7 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
|
||||
@@ -308,7 +308,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
|
||||
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Más reciente",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,7 +283,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} on katkestatud",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} mudelit pole enam saadaval.",
|
||||
"model.tag.free": "Tasuta",
|
||||
"model.tag.variable": "Muutuv",
|
||||
"model.tag.latest": "Viimased",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,7 +284,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد",
|
||||
"provider.disconnect.toast.disconnected.description": "مدل های {{provider}} دیگر در دسترس نیستند.",
|
||||
"model.tag.free": "رایگان",
|
||||
"model.tag.variable": "متغیر",
|
||||
"model.tag.latest": "آخرین",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -191,7 +191,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "Yhteys palveluntarjoajaan {{provider}} katkaistu",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}}-mallit eivät ole enää saatavilla.",
|
||||
"model.tag.free": "Ilmainen",
|
||||
"model.tag.variable": "Muuttuva",
|
||||
"model.tag.latest": "Uusin",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,7 +283,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} slitið",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modellir eru ikki tøkir longur.",
|
||||
"model.tag.free": "Ókeypis",
|
||||
"model.tag.variable": "Skiftandi",
|
||||
"model.tag.latest": "Nýggjasta",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user