Compare commits

..
Author SHA1 Message Date
Kit Langton e8c895a940 fix(core): normalize RPC handler failures 2026-09-02 21:14:38 -04:00
267 changed files with 2169 additions and 5440 deletions
-1
View File
@@ -1046,7 +1046,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-I2sHU46Bdp1DmW/ygNKKxF6U53oNIeVeAS8TmMdtvzw=",
"aarch64-linux": "sha256-aQUEBmfHhaR/YdsRyw0u6hsEo0TEmXHBMyiGnOFRCeI=",
"aarch64-darwin": "sha256-40Y3HYSjs8S0M3SFNUrU8lMYaHy7vpmiW+7b6Y9pKzs=",
"x86_64-darwin": "sha256-7iGegI/1pXcR5W0UBX0wAWAga+F34fhj9lbAHotVwEo="
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
}
}
-1
View File
@@ -177,7 +177,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch"
}
}
+102 -81
View File
@@ -304,11 +304,6 @@ export const OpenResponsesUsage = Schema.Struct({
})
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
// The spec requires `id` on every output item, but some gateways drop it from
// later item events (Bedrock Mantle renames it to `item_id` on
// `output_item.done` and `response.completed.output`). Decode it as optional
// and let `normalize` recover or mint it once before the parser runs.
// https://www.openresponses.org/specification#extending-items
export const StreamItem = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -321,7 +316,6 @@ export const StreamItem = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
@@ -401,7 +395,6 @@ export const Event = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
export interface ProviderAdapter {
readonly id: string
@@ -423,10 +416,15 @@ export interface ParserState {
readonly name: string
readonly providerMetadataKey: string
readonly tools: ToolStream.State<string>
// Call ids stay independent of item ids, which may be omitted or reused.
readonly completedTools: ReadonlySet<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -877,6 +875,9 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
[state.providerMetadataKey]: metadata,
})
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
item.type === "reasoning" && typeof item.id === "string"
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -920,34 +921,9 @@ const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
return parts.filter((part) => part !== undefined).join("\n\n")
}
const outputItemID = (state: ParserState, event: Event) =>
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
message: "msg",
reasoning: "rs",
function_call: "fc",
compaction: "cmp",
}
// An item without an id adopts the id already open in its output slot,
// otherwise it gets a locally minted one.
const resolveItem = (state: ParserState, item: StreamItem, index: number | undefined): OutputItem => ({
...item,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
`${ITEM_ID_PREFIX[item.type] ?? "item"}_${crypto.randomUUID().replaceAll("-", "")}`,
})
// Registered output slots are authoritative for `item_id` routing, and items
// are resolved here so everything downstream can rely on `item.id`.
export const normalize = (state: ParserState, input: Event): NormalizedEvent => ({
...input,
item_id: input.item_id === undefined ? undefined : outputItemID(state, input),
item: input.item ? resolveItem(state, input.item, input.output_index) : input.item,
})
const startReasoningSummaryPart = (state: ParserState, itemID: string, index: number): StepResult => {
const item = state.reasoningItems[itemID]
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined) return [state, NO_EVENTS]
@@ -1021,7 +997,7 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
@@ -1034,16 +1010,20 @@ const reasoningMetadata = (state: ParserState, item: OutputItem) =>
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResult => {
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (!item) return [state, NO_EVENTS]
if (item.type === "message") {
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== item.id)
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -1056,15 +1036,16 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
{
...state,
lifecycle,
completedMessages,
message: {
id: item.id,
phase: phase === undefined && state.message?.id === item.id ? state.message.phase : phase,
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
events,
]
}
if (item.type === "reasoning") {
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
@@ -1084,16 +1065,18 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
events,
]
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS]
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, item.id, {
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
name: item.name ?? "",
input: item.arguments ?? "",
@@ -1165,13 +1148,13 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
state: ParserState,
item: NormalizedEvent["item"],
item: Event["item"],
) {
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "compaction") {
if (typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its encrypted content")
if (!item.id || typeof item.encrypted_content !== "string")
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
@@ -1188,10 +1171,15 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
] satisfies StepResult
}
if (item.type === "message") {
const active = state.message?.id === item.id
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined && active ? state.message?.phase : itemPhase
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
const content: string[] = []
for (const part of parts) {
@@ -1207,7 +1195,8 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: active ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1215,22 +1204,36 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name, providerMetadata: metadata })
const callID = item.call_id
if (state.completedTools.has(callID)) return [state, NO_EVENTS] satisfies StepResult
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined
const fallback = item.id ?? callID
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered =
state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID)
const id = registered ?? fallback
const tools =
registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: callID,
name: item.name,
providerMetadata: metadata,
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
const events: LLMEvent[] = []
const finished = result.events ?? []
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents =
registered || finished.length === 0
registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: item.call_id, name: item.name, providerMetadata: metadata }), ...finished]
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished]
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
events.push(...resultEvents)
return [
@@ -1241,12 +1244,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall,
tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},
events,
] satisfies StepResult
}
if (item.type === "reasoning") {
if (isReasoningItem(item)) {
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
const metadata = reasoningMetadata(state, item)
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
@@ -1330,17 +1334,21 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
let current = state
const events: LLMEvent[] = []
if (event.type === "response.completed") {
// An output item's array position is its output index.
for (const item of (event.response?.output ?? []).map((item, index) => resolveItem(state, item, index))) {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (item.type === "compaction" && state.lifecycle.stepStarted && !state.completedCompactions.has(item.id))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
const recoverable =
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
if (!recoverable) continue
for (const item of event.response?.output ?? []) {
if (item.type !== "compaction" && item.type !== "function_call") continue
if (item.type === "compaction") {
// Terminal recovery cannot insert a checkpoint before already-emitted content.
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
return yield* ProviderShared.eventError(
state.id,
"Cannot recover a compaction checkpoint after output has been emitted",
)
}
if (
item.type === "function_call" &&
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -1407,9 +1415,12 @@ export const providerFailure = (event: Event, fallback: string, body = ProviderS
return new AIError({ reason })
}
// Callers must pass events through `normalize` first. The OpenAPI requires
// string IDs but imposes no minLength; empty is not missing.
export const step = (state: ParserState, event: NormalizedEvent) => {
export const step = (state: ParserState, input: Event) => {
// The OpenAPI requires string IDs but imposes no minLength; empty is not missing.
const event =
input.item_id !== undefined && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (event.item_id === undefined) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1449,16 +1460,20 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
? Effect.succeed(onReasoningSummaryPartDone(state, event))
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
if (
event.item?.type === "reasoning" &&
event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0
)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
event.output_index !== undefined && id !== undefined
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
@@ -1468,7 +1483,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
return event.item_id !== undefined
? onFunctionCallArgumentsDelta(state, event)
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.output_item.done") {
if (event.item?.type === "message" && event.item.id === undefined)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return onOutputItemDone(state, event.item)
}
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error")
@@ -1501,9 +1520,11 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
providerMetadataKey: metadataKey(request.model),
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
completedTools: new Set<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -1516,7 +1537,7 @@ export const protocol = Protocol.make({
stream: {
event: Protocol.jsonEvent(Event),
initial,
step: (state: ParserState, event: Event) => step(state, normalize(state, event)),
step,
terminal,
},
})
@@ -201,11 +201,12 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id !== undefined
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
-25
View File
@@ -6,13 +6,11 @@ import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/
import {
InvalidProviderOutputError,
InvalidRequestError,
UnsupportedOperationError,
AIError,
HttpContext,
type ContentPart,
type LLMRequest,
type MediaPart,
type ProviderID,
type TextPart,
type ToolResultPart,
} from "../schema/index.js"
@@ -256,29 +254,6 @@ export const invalidRequest = (message: string, cause?: unknown) =>
reason: new InvalidRequestError({ message, cause }),
})
/**
* Canonical constructor for operations the selected route does not implement.
* Prefer this over `invalidRequest` when the failure is a missing route
* capability rather than a malformed caller input, so consumers can branch on
* `reason._tag` plus `reason.operation` instead of matching message text.
*/
export const unsupportedOperation = (input: {
readonly operation: string
readonly message: string
readonly provider?: ProviderID
readonly route?: string
readonly cause?: unknown
}) =>
new AIError({
reason: new UnsupportedOperationError({
operation: input.operation,
message: input.message,
provider: input.provider,
route: input.route,
cause: input.cause,
}),
})
export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (
route: string,
name: string,
@@ -3,7 +3,8 @@ import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.
import { OpenResponses } from "../open-responses.js"
import { Lifecycle } from "./lifecycle.js"
export type Item = OpenResponses.OutputItem & {
export type Item = OpenResponses.StreamItem & {
readonly id: string
readonly status?: string
readonly action?: unknown
readonly queries?: unknown
@@ -26,8 +27,8 @@ export interface Definition {
export type Definitions = Readonly<Record<string, Definition>>
export const isItem = <Tools extends Definitions>(item: OpenResponses.OutputItem, tools: Tools): item is Item =>
item.type in tools
export const isItem = <Tools extends Definitions>(item: OpenResponses.StreamItem, tools: Tools): item is Item =>
item.type in tools && typeof item.id === "string" && item.id.length > 0
export const onDone: (
state: OpenResponses.ParserState,
+4 -8
View File
@@ -46,12 +46,9 @@ const adapter = {
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
if (request.providerOptions?.contextManagement !== undefined)
return yield* ProviderShared.unsupportedOperation({
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* ProviderShared.invalidRequest(
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
)
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -72,8 +69,7 @@ const HOSTED_TOOLS = {
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, input: OpenResponses.Event) => {
const event = OpenResponses.normalize(state, input)
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+3 -6
View File
@@ -585,12 +585,9 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.suspend(() => {
const operation = request.model.route.compact
if (!operation)
return ProviderShared.unsupportedOperation({
operation: "compact",
provider: request.model.provider,
route: request.model.route.id,
message: `${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
})
return ProviderShared.invalidRequest(
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
)
return operation(prepareRequest(request), executor, options)
}),
})
-16
View File
@@ -35,21 +35,6 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
},
) {}
/**
* A caller-requested operation the selected route does not implement, such as
* explicit compaction on a route without a compact endpoint. Detected locally
* before any network I/O, so unlike transport or provider-output failures it
* never carries HTTP context from a provider round-trip.
*/
export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOperationError>(
"AI.Error.UnsupportedOperation",
)("UnsupportedOperation", {
...ReasonFields,
operation: Schema.String,
provider: Schema.optional(ProviderID),
route: Schema.optional(RouteID),
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
@@ -122,7 +107,6 @@ export class UnknownProviderError extends Schema.TaggedError<UnknownProviderErro
export const AIErrorReason = Schema.Union([
InvalidRequestError,
UnsupportedOperationError,
NoRouteError,
AuthenticationError,
RateLimitError,
+1 -25
View File
@@ -1,10 +1,8 @@
import { expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Schema } from "effect"
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
import { OpenAI, Anthropic } from "../src/providers.js"
import { testEffect } from "./lib/effect.js"
import { fixedResponse } from "./lib/http.js"
test("runtime capability checks follow model and route updates", () => {
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
@@ -77,25 +75,3 @@ test("tagged content and event guards accept both checkpoint representations", (
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
}
})
testEffect(fixedResponse("")).effect(
"explicit compaction on a route without a compact endpoint fails with UnsupportedOperation",
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Anthropic.configure({ apiKey: "test" }).model("fixture"),
prompt: "hello",
})
expect(LLMClient.canCompact(request)).toBe(false)
const error = yield* LLMClient.compact(
request as unknown as Parameters<typeof LLMClient.compact>[0],
).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") {
expect(error.reason.operation).toBe("compact")
expect(error.reason.provider).toBe("anthropic")
expect(error.reason.route).toBe("anthropic-messages")
}
}),
)
+5 -8
View File
@@ -125,15 +125,12 @@ testEffect(
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
}),
),
).effect("mints an id for terminal checkpoints that omit one", () =>
).effect("rejects terminal checkpoints missing an id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
const error = yield* LLMClient.generate(
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
)
const part = response.message.content[0]
expect(part?.type).toBe("compaction")
if (part?.type !== "compaction") return
expect(part.id).toMatch(/^cmp_[0-9a-f]{32}$/)
expect(part.encrypted).toBe("opaque")
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("missing its id")
}),
)
@@ -130,22 +130,16 @@ for (const model of [
}),
],
})
for (const [candidate, tag] of [
[
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
"InvalidRequest",
],
[
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
model.provider === "xai" ? "UnsupportedOperation" : "InvalidRequest",
],
] as const) {
for (const candidate of [
LLMRequest.update(request, {
tools: [
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
],
}),
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
]) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe(tag)
expect(error.reason._tag).toBe("InvalidRequest")
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
@@ -367,9 +361,8 @@ testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic comp
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
)
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("LLMClient.compact")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("in-band-compaction")
}),
)
@@ -435,9 +428,7 @@ for (const model of [
Effect.gen(function* () {
// @ts-expect-error Untyped callers must still receive the runtime capability error.
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("does not support explicit compaction")
if (error.reason._tag === "UnsupportedOperation") expect(error.reason.operation).toBe("compact")
expect(error.reason._tag).toBe("InvalidRequest")
}),
)
}
@@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -217,7 +217,7 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves non-empty done-only message content", () =>
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
@@ -230,11 +230,17 @@ describe("Open Responses basic-item lifecycles", () => {
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
@@ -266,121 +272,126 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
// Captured from Bedrock Mantle (openai.gpt-oss-120b): the terminal function_call
// items rename `id` to `item_id` and carry a stray `output_index`.
it.effect("recovers a terminal function_call id from its output slot", () =>
it.effect("treats a repeated message lifecycle as replay", () =>
Effect.gen(function* () {
const terminal = {
type: "function_call",
item_id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: '{\n "city": "Paris"\n}',
output_index: 1,
status: "completed",
}
const events = yield* collect(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "msg_879a68b589198b4c" },
},
{ type: "response.output_item.done", output_index: 0, item: { type: "reasoning", id: "msg_879a68b589198b4c" } },
{
type: "response.output_item.added",
output_index: 1,
item: {
type: "function_call",
id: "fc_828bee50dee1d029",
call_id: "call_bc1eb4b42e70ee53",
name: "get_weather",
arguments: "",
status: "in_progress",
},
},
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "fc_828bee50dee1d029",
delta: '{\n "city": "Paris"\n}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_828bee50dee1d029",
arguments: '{\n "city": "Paris"\n}',
},
{ type: "response.output_item.done", output_index: 1, item: terminal },
{
type: "response.completed",
response: { id: "resp_1", output: [{ type: "reasoning", id: "msg_879a68b589198b4c" }, terminal] },
},
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
completed,
)
const providerMetadata = { "openai-compatible": { itemId: "fc_828bee50dee1d029" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
expect(events.filter(LLMEvent.is.textEnd)).toEqual([
{
type: "tool-input-delta",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
text: '{\n "city": "Paris"\n}',
input: { city: "Paris" },
},
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
{
type: "tool-call",
id: "call_bc1eb4b42e70ee53",
name: "get_weather",
input: { city: "Paris" },
providerMetadata,
type: "text-end",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("mints an id for a done-only tool that never had one", () =>
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
{
type: "response.output_item.done",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
const call = events.find(LLMEvent.is.toolCall)
expect(call).toMatchObject({ id: "call_1", name: "lookup", input: { query: "weather" } })
expect(call?.providerMetadata?.["openai-compatible"]).toMatchObject({
itemId: expect.stringMatching(/^fc_[0-9a-f]{32}$/),
})
}),
)
it.effect("opens and closes a done-only tool", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect({ type: "response.output_item.done", item }, completed)
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
it.effect(`opens and closes a done-only tool ${id === undefined ? "without" : "with"} an item id`, () =>
Effect.gen(function* () {
const item = {
type: "function_call",
...(id === undefined ? {} : { id }),
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const events = yield* collect(
{ type: "response.output_item.done", item },
{ type: "response.output_item.done", item: { ...item, id: "fc_1" } },
{ type: "response.output_item.added", item },
completed,
)
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
expect(events.filter(LLMEvent.is.finish)).toEqual([
{
type: "finish",
reason: { normalized: "tool-calls", raw: undefined },
providerMetadata: { "openai-compatible": { responseId: "resp_1", serviceTier: undefined } },
},
])
}),
)
it.effect(`deduplicates a pending call whose item id is ${id === undefined ? "introduced" : "omitted"} later`, () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup" }
const first = { ...item, ...(id === undefined ? {} : { id }) }
const duplicate = { ...item, ...(id === undefined ? { id: "fc_1" } : {}) }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.function_call_arguments.delta", item_id: id ?? "call_1", delta: '{"query":"weather"}' },
{ type: "response.output_item.added", item: duplicate },
{ type: "response.output_item.done", item: duplicate },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: duplicate },
completed,
)
// Identity metadata comes from the first admission, not the duplicate.
const providerMetadata = id === undefined ? undefined : { "openai-compatible": { itemId: id } }
expect(events.filter((event) => event.type.startsWith("tool-"))).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata },
{
type: "tool-input-delta",
id: "call_1",
name: "lookup",
text: '{"query":"weather"}',
input: { query: "weather" },
},
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, providerMetadata },
])
}),
)
})
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
Effect.gen(function* () {
@@ -425,6 +436,21 @@ describe("Open Responses basic-item lifecycles", () => {
}),
)
it.effect("preserves call identity and pending order when an item id is reused", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" }
const events = yield* collect(
{ type: "response.output_item.added", item: first },
{ type: "response.output_item.added", item: { ...first, id: "fc_2", call_id: "call_2" } },
{ type: "response.output_item.done", item: first },
{ type: "response.output_item.added", item: { ...first, call_id: "call_3" } },
{ type: "response.output_item.done", item: first },
completed,
)
expect(events.filter(LLMEvent.is.toolCall).map((event) => event.id)).toEqual(["call_1", "call_2", "call_3"])
}),
)
it.effect("keeps text and reasoning identities separate even with empty item ids", () =>
Effect.gen(function* () {
const events = yield* collect(
@@ -474,15 +500,14 @@ describe("Open Responses basic-item lifecycles", () => {
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Answer" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "{}" },
},
completed,
)
// Generic terminal closure does not repeat the message's phase metadata.
const providerMetadata = { "openai-compatible": { itemId: "fc_1" } }
expect(events.slice(4, -2)).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata },
{ type: "tool-call", id: "call_1", name: "lookup", input: {}, providerMetadata },
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
{ type: "text-end", id: "msg_1" },
])
}),
@@ -586,21 +586,23 @@ describe("Open Responses-compatible route", () => {
Effect.gen(function* () {
yield* Effect.forEach(["response.output_item.added", "response.output_item.done"], (type) =>
Effect.forEach(fixtures, (fixture) =>
Effect.forEach([null, 0, false, {}, []], (id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
Effect.forEach(
fixture.item.type === "message" ? [undefined, null, 0, false, {}, []] : [null, 0, false, {}, []],
(id) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type, item: { ...fixture.item, id } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
),
),
)
@@ -608,6 +610,43 @@ describe("Open Responses-compatible route", () => {
)
})
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -469,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a streamed tool call with only the new tool output", () =>
it.effect("continues an item-id-less tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -485,7 +485,6 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -2130,6 +2129,47 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2349,7 +2389,7 @@ describe("OpenAI Responses route", () => {
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
@@ -2850,7 +2890,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("ignores duplicate item start events", () =>
it.effect("ignores duplicate item boundary events", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -2881,6 +2921,25 @@ describe("OpenAI Responses route", () => {
arguments: '{"query":"weather"}',
},
},
{
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
},
// Duplicates that drop the item id still resolve the same call.
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
@@ -3734,6 +3793,43 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3921,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3936,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -3958,6 +4053,37 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
-8
View File
@@ -24,7 +24,6 @@ import {
ToolResultValue,
TransportError,
UnknownProviderError,
UnsupportedOperationError,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
@@ -277,12 +276,6 @@ test("AI errors serialize diagnostics only on their typed reason", () => {
test("AI error reasons are tagged Errors with required messages", () => {
const reasons = [
new InvalidRequestError({ message: "Invalid request" }),
new UnsupportedOperationError({
message: "Unsupported operation",
operation: "compact",
provider: model.provider,
route: "fake-route",
}),
new NoRouteError({
message: "No route",
route: RouteID.make("missing"),
@@ -300,7 +293,6 @@ test("AI error reasons are tagged Errors with required messages", () => {
]
expect(reasons.map((reason) => reason._tag)).toEqual([
"InvalidRequest",
"UnsupportedOperation",
"NoRoute",
"Authentication",
"RateLimit",
@@ -11,7 +11,6 @@ import type {
} from "@opencode-ai/client/promise"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { TimelineDetail } from "@opencode-ai/session-ui/timeline/detail"
import { expect, type Page } from "@playwright/test"
import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -123,7 +122,7 @@ export async function setupTimeline(
messages?: TimelineMessage[]
sessionMessages?: SessionMessageInfo[]
sessionStatus?: Record<string, SessionStatus>
settings?: Record<string, boolean | TimelineDetail>
settings?: Record<string, boolean>
sessions?: Session[]
cpuRate?: number
viewport?: { width: number; height: number }
@@ -92,8 +92,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(false)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await page.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "scroll")
await expect
.poll(() => modified.locator("[data-code]").evaluate((element) => element.scrollWidth > element.clientWidth))
@@ -116,8 +114,6 @@ for (const direction of ["ltr", "rtl"] as const) {
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.mobileDiffWrap))
.toBe(true)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL(stressSessionHref(fixture.targetID))
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
await expect(modified.locator("[data-diff]")).toHaveAttribute("data-overflow", "wrap")
const openFile = modified.getByRole("button", { name: "Open file", exact: true })
await expect(openFile).toBeVisible()
@@ -67,7 +67,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
const menu = settings.getByRole("button", { name: "Preferences", exact: true })
const panel = settings.getByRole("tabpanel")
await expect(settings.getByRole("heading", { name: "General", exact: true })).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(menu).toBeVisible()
await menu.click()
await expect(page.getByRole("menuitemradio", { name: "Preferences", exact: true })).toBeChecked()
@@ -88,7 +87,6 @@ test("mobile settings section menu stays above a full-width panel", async ({ pag
.toBe(true)
await expect.poll(async () => (await panel.boundingBox())?.width ?? 0).toBeGreaterThan(350)
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(page.locator('[data-component="home-session-row"]')).toHaveCount(fixture.sessions.length)
})
@@ -30,11 +30,9 @@ test("session settings use the remote server context", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
await expect(settings).toBeVisible()
await expect(page).toHaveURL("/settings")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
await expect(page.getByRole("dialog")).toHaveCount(0)
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
await expect(sessionHeading).toBeAttached()
await expect(sessionHeading).toBeHidden()
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
const input = autoAccept.getByRole("switch")
@@ -68,15 +66,6 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
await settings.getByRole("button", { name: "Back to app" }).click()
await expect(settings).toBeHidden()
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(sessionHeading).toBeVisible()
})
@@ -67,12 +67,6 @@ test("follows a live session move while the agent catalog is still loading", asy
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Moved session" }
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({ general: { timelineDetail: { notices: { placement: "separate" } } } }),
)
})
const transport = await installSseTransport(page, { server: fixture.serverKey })
await mockOpenCodeServer(page, {
directory: fixture.directory,
@@ -101,7 +95,9 @@ test("follows a live session move while the agent catalog is still loading", asy
await transport.waitForConnection()
const resolved = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
return (
url.pathname === "/api/agent" && url.searchParams.get("location[directory]") === destination && response.ok()
)
})
session.directory = destination
await transport.send({
@@ -225,8 +221,7 @@ function recoveryRequests(page: Page) {
const requests: string[] = []
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path))
requests.push(path)
if (request.method() === "POST" && /^\/api\/(session\/[^/]+\/move$|worktree(?:\/|$))/.test(path)) requests.push(path)
})
return requests
}
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { setupTimeline } from "../performance/timeline-stability/fixture"
for (const width of [1400, 390]) {
@@ -17,9 +16,6 @@ for (const width of [1400, 390]) {
`\u0645\u0631\u0627\u062c\u0639\u0629 ${command}--reviewed`,
]
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
locale: profile.locale,
viewport: { width, height: 900 },
sessionMessages: [
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
@@ -37,9 +36,7 @@ test("renders a completed single-file patch", async ({ page }) => {
),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -71,9 +68,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
@@ -105,7 +100,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() =>
row.evaluate((element) => {
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -24,23 +23,20 @@ for (const expanded of [false, true]) {
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
const id = `prt_shell_default_${expanded}`
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", lines(3))], { completed: false })],
settings: {
timelineDetail: {
...timelinePresets[2].value,
shell: { placement: "separate", details: expanded ? "expanded" : "collapsed" },
},
},
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
settings: { shellToolPartsExpanded: expanded },
})
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
const trigger = expanded
? page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
: page.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
await timeline.send(partUpdated(shell(id, "completed", lines(6))))
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")))
await timeline.send(status("idle"))
await expect(page.getByText("Sibling content", { exact: true })).toBeVisible()
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 250)
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
})
}
@@ -50,9 +46,6 @@ test("transitions a streaming shell from writing through command execution", asy
const command = "printf ready"
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
})
await timeline.send(toolInputStarted({ sessionID, assistantMessageID: assistantID, id, name: "shell" }))
@@ -102,9 +95,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
const command = "sleep 10 && echo done"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { shellToolPartsExpanded: false },
})
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
@@ -119,7 +110,7 @@ test("shimmers and expands a running shell command", async ({ page }) => {
})
for (const open of [false, true]) {
test(`keeps ${open ? "expanded" : "collapsed"} Separate reasoning intent through shell completion`, async ({
test(`keeps ${open ? "expanded" : "collapsed"} reasoning intent from Thinking through standalone shell into Used`, async ({
page,
}) => {
const reasoningID = `prt_reasoning_hidden_${open}`
@@ -127,13 +118,7 @@ for (const open of [false, true]) {
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistant],
settings: {
timelineDetail: {
...timelinePresets[2].value,
thinking: { placement: "separate", details: "collapsed" },
shell: { placement: "separate", details: "collapsed" },
},
},
settings: { showReasoningSummaries: false },
cpuRate: 4,
})
const reasoning = page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)
@@ -156,11 +141,28 @@ for (const open of [false, true]) {
await timeline.send(partUpdated(shell(shellID, "completed", "done")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("idle"))
await expect(group).toHaveCount(0)
await expect(thought).toHaveAttribute("aria-expanded", String(open))
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="${shellID}"]`)).toBeVisible()
await expect(group.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
String(open),
)
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Shell")
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(used).toHaveAttribute("aria-expanded", "true")
if (!open) await thought.click()
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(reasoning.getByRole("button", { name: "Thought", exact: true })).toHaveAttribute(
"aria-expanded",
"true",
)
await expect(reasoning.getByRole("heading", { name: "Inspecting stability", exact: true })).toBeVisible()
})
}
@@ -170,9 +172,6 @@ for (const transition of ["reasoning-end", "idle", "retry"] as const) {
const text = "## Inspecting stability\n\nThe timeline is ready for the next step."
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([reasoningPart(id, text)], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const part = page.locator(`[data-timeline-part-id="${renderedPartID(id)}"]`)
const trigger = part.locator('[data-slot="collapsible-trigger"]')
@@ -1,6 +1,5 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
compactionDelta,
compactionEnded,
@@ -50,9 +49,6 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
ownerWarnings.push(message.text())
})
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [
user,
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
@@ -88,12 +84,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
})
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } },
},
sessionMessages: [user, assistant(true)],
})
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
await timeline.send(
compactionStarted({
@@ -170,12 +161,7 @@ test("updates running compactions to failed and cancelled boundaries", async ({
})
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true)],
})
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
const card = page.locator('[data-component="task-tool-card"]')
await expect(card).toBeVisible()
await expect(card).toContainText("Inspect code")
@@ -212,9 +198,6 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
const childID = "ses_running_child"
await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, subagents: { placement: "separate" } },
},
sessionMessages: [user, assistant(false, true, childID)],
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
@@ -230,7 +213,6 @@ for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -299,7 +281,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const backgroundID = "ses_background_existing"
const blockingID = "ses_background_blocking"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
sessionMessages: [
user,
{
@@ -388,13 +369,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
})
const backgroundCard = page.locator('[data-timeline-part-id="call_backgrounded"]')
await expect(page.getByText(/move running work to the background/i)).toBeVisible()
const used = page
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*2 Agent, 1 Shell$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await page.getByRole("button", { name: "Session details" }).click()
const summary = page.getByRole("button", { name: "2 items running in background" })
await expect(summary).toContainText("2")
@@ -402,7 +376,6 @@ test("separates blocking and already-backgrounded work into two rows", async ({
const list = page.locator('[data-component="session-background-list"]')
await expect(list).toContainText("Background task")
await expect(list).toContainText("sleep 120")
await expect(list).not.toContainText("Foreground task")
await expect(backgroundCard).toContainText("Background task (background)")
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await expect(
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -15,9 +14,7 @@ test.describe("session timeline projection", () => {
const first = "prt_patch_first"
const second = "prt_patch_second"
const timeline = await setupTimeline(page, {
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage([
@@ -37,7 +34,6 @@ test.describe("session timeline projection", () => {
const initial = page.locator(`[data-timeline-part-id="${first}"]`)
const initialFile = initial.locator('[data-scope="apply-patch"] [data-type="update"]')
await expect(initialFile).toBeVisible()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await initialFile.getByRole("button").click()
await expect(initialFile.getByRole("button")).toHaveAttribute("aria-expanded", "true")
await initial.evaluate((element) => {
@@ -114,11 +110,8 @@ test.describe("session timeline projection", () => {
parentID: "msg_2000_second_user",
created: 1700000006000,
})
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [firstUser, aborted, failed, nextUser, nextAssistant],
})
await timeline.send(status("idle"))
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
await timeline.send(status("idle"), 100)
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0))
@@ -134,7 +127,6 @@ test.describe("session timeline projection", () => {
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
await setupTimeline(page, {
viewport: { width: 420, height: 700 },
settings: { timelineDetail: { ...timelinePresets[2].value, notices: { placement: "separate" } } },
sessionMessages: [
{
id: "msg_model_fast_nano",
@@ -5,49 +5,135 @@ import {
reasoningPart,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
test("changes timeline presets and saves custom thinking details", async ({ page }) => {
test("changes live reasoning through Settings and persists Hidden, Compact, and Full", async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
reasoningPart("prt_reasoning_settings", "## Inspecting stability\n\nThe selected mode controls these details."),
]),
assistantMessage(
[
reasoningPart(
"prt_reasoning_settings",
"## Inspecting stability\n\nThe selected mode controls these details.",
),
],
{ completed: false },
),
],
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
const settings = page.getByTestId("settings-screen")
await page.keyboard.press("Control+,")
const slider = settings.getByRole("slider", { name: "Timeline detail", exact: true })
await expect(slider).toBeEnabled()
await slider.press("Home")
for (const [index, name] of ["Everything", "Detailed", "Compact", "Quiet", "Text only"].entries()) {
if (index) await slider.press("ArrowRight")
await expect(slider).toHaveValue(String(index))
await expect(slider).toHaveAttribute("aria-valuetext", name)
}
await slider.press("Home")
await settings.getByRole("button", { name: "Advanced", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Placement Separate", exact: true }).click()
await page.getByRole("option", { name: "Grouped", exact: true }).click()
await settings.getByRole("button", { name: "Thinking Details Expanded", exact: true }).click()
await page.getByRole("option", { name: "Collapsed", exact: true }).click()
await expect(slider).toHaveAttribute("aria-valuetext", "Custom")
await expect
.poll(() =>
page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.timelineDetail?.thinking),
)
.toEqual({ placement: "grouped", details: "collapsed" })
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await part.getByRole("button").click()
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
const settings = page.getByTestId("settings-screen")
const select = settings.locator('[data-action="settings-reasoning-mode"] [data-component="select-v2"]')
for (const label of ["Full", "Hidden", "Compact"] as const) {
await page.keyboard.press("Control+,")
await expect(settings.getByText("Model reasoning", { exact: true })).toBeVisible()
await expect(select).toHaveAttribute("aria-expanded", "false")
await select.click()
await expect(page.getByRole("listbox").getByRole("option")).toHaveText(["Hidden", "Compact", "Full"])
await page.getByRole("option", { name: label, exact: true }).click()
await expect(select).toHaveText(label)
await expect(select).toHaveAttribute("aria-expanded", "false")
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").general?.reasoningMode))
.toBe(label.toLowerCase())
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(settings).toBeHidden()
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(label === "Hidden" ? 0 : 1)
await expect(part).toHaveCount(label === "Hidden" ? 0 : 1)
if (label === "Hidden") {
await expect(page.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
continue
}
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", String(label === "Full"))
if (label === "Full")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
if (label === "Compact") {
await expect(part.getByRole("button")).toContainText("Inspecting stability")
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeHidden()
}
}
await page.keyboard.press("Control+,")
await expect(select).toHaveText("Compact")
})
// The persisted boolean migrates to compact (false) or full (true).
for (const summaries of [false, true]) {
for (const profile of ["none", "blank", "heading", "tool", "text"] as const) {
test(`projects legacy ${summaries ? "full" : "compact"} reasoning with ${profile}`, async ({ page }) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
...(profile === "none"
? []
: [
reasoningPart(
`prt_reasoning_${summaries}_${profile}`,
profile === "blank"
? " "
: "## Inspecting stability\n\nI will inspect the timeline before changing its state.",
),
]),
...(profile === "tool"
? [toolPart(`prt_reasoning_tool_${summaries}`, "skill", "running", { name: "inspect" })]
: []),
...(profile === "text" ? [textPart(`prt_reasoning_text_${summaries}`, "The timeline is stable.")] : []),
],
{ completed: false },
),
],
settings: { showReasoningSummaries: summaries },
})
const part = page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(
profile === "blank" || profile === "heading" ? 1 : 0,
)
if (profile === "none") {
await expect(part).toHaveCount(0)
return
}
if (profile === "blank") {
await expect(part).toContainText("Thinking")
await expect(part.getByRole("heading")).toHaveCount(0)
return
}
if (profile === "tool") {
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(used).toHaveText(/^Used\s*1 Skill$/)
await expect(used).toHaveAttribute("aria-expanded", "false")
await expect(page.getByText("Inspecting stability", { exact: true })).toBeHidden()
await expect(used.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Skill")
await used.click()
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(group.locator(`[data-timeline-part-id="prt_reasoning_tool_${summaries}"]`)).toBeVisible()
await expect(group.locator('[data-component="reasoning-part"]')).toHaveCount(1)
}
if (profile === "text") await expect(page.getByText("The timeline is stable.", { exact: true })).toBeVisible()
const trigger = part.locator('[data-slot="collapsible-trigger"]')
const body = part.getByText("I will inspect the timeline before changing its state.", { exact: true })
await expect(trigger).toContainText(profile === "heading" ? "Thinking" : "Thought")
await expect(trigger).toHaveAttribute("aria-expanded", String(summaries))
if (!summaries) {
await expect(body).toBeHidden()
if (profile === "heading") await expect(trigger).toContainText("Inspecting stability")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
}
await expect(body).toBeVisible()
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await expect(body).toBeHidden()
if (profile !== "heading") await expect(trigger).not.toContainText("Inspecting stability")
})
}
}
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
await setupTimeline(page, {
messages: [
@@ -18,10 +18,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
const textID = "prt_event_order_text"
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
await timeline.send(status("busy"))
await timeline.send(status("idle"))
await timeline.send(partUpdated(textPart(textID, "Final after early idle")))
await timeline.send(messageUpdated(completedAssistantInfo(assistant)))
await timeline.send(status("busy"), 100)
await timeline.send(status("idle"), 100)
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
@@ -161,14 +161,12 @@ test("keeps failed search calls and their error cards inside the collapsed stack
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator('[data-component="collapsed-tool-group"]')
const group = page.locator('[data-timeline-part-ids="prt_error_glob,prt_error_grep"]')
const summary = group.getByRole("button", { name: "Used 1 Glob, 1 Grep", exact: true })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("1 Glob, 1 Grep")
await summary.click()
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
const glob = group.locator('[data-timeline-part-id="prt_error_glob"]')
await expect(glob.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await glob.getByRole("button").click()
await expect(glob).toContainText("Invalid tool input")
await expect(glob.locator('[data-component="tool-error-card-icon"]')).toBeVisible()
await expect(glob.locator('[data-component="tool-error-card-icon"] use')).toHaveAttribute(
@@ -182,7 +180,6 @@ test("keeps failed search calls and their error cards inside the collapsed stack
.evaluate((element) => getComputedStyle(element, "::before").display),
)
.toBe("none")
await group.locator('[data-timeline-part-id="prt_error_grep"]').getByRole("button").click()
await expect(group.locator('[data-timeline-part-id="prt_error_grep"]')).toContainText(
"Search timed out after 30 seconds",
)
@@ -1,5 +1,4 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
setupTimeline,
@@ -75,29 +74,21 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
additions: 1,
deletions: 1,
}
await setupTimeline(page, {
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(
patchID,
"patch",
"completed",
{ patchText: "Update src/outline.ts" },
{ metadata: { files: [file] } },
),
toolPart(patchID, "patch", "completed", { patchText: "Update src/outline.ts" }, { metadata: { files: [file] } }),
]),
],
settings: {
timelineDetail: { ...timelinePresets[2].value, edit: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
reducedMotion: true,
})
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
const row = page.locator("[data-timeline-key]", { has: part })
await expect(card).toBeVisible()
await expect(card.getByRole("button")).toHaveAttribute("aria-expanded", "false")
await timeline.settle()
const geometry = await row.evaluate((element) => {
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
@@ -115,6 +106,8 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
cardHeight: cardRect.height,
}
})
await timeline.settle()
expect(geometry.overflow).toBeCloseTo(0.49, 1)
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
const edges = await captureCardEdges(page, card)
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantMessage,
partUpdated,
@@ -8,11 +7,11 @@ import {
userMessage,
} from "../performance/timeline-stability/fixture"
test("keeps shell and question failures in their Used group", async ({ page }) => {
test("transitions shell and question through running error outcomes", async ({ page }) => {
const shellID = "prt_transition_error_shell"
const questionID = "prt_transition_error_question"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
settings: { shellToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -24,46 +23,30 @@ test("keeps shell and question failures in their Used group", async ({ page }) =
),
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })))
await expect(page.locator(`[data-timeline-part-id="${shellID}"]`)).toContainText("exit 1")
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())))
await timeline.send(partUpdated(toolPart(shellID, "shell", "running", { command: "exit 1" })), 120)
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
await timeline.send(
partUpdated(
toolPart(
shellID,
"shell",
"completed",
{ command: "exit 1" },
{ output: "Command exited 1", metadata: { exit: 1 } },
),
),
partUpdated(toolPart(shellID, "shell", "error", { command: "exit 1" }, { error: "Command exited 1" })),
180,
)
await timeline.send(
partUpdated(
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
),
250,
)
await expect(group).toHaveAttribute("data-timeline-part-ids", `${shellID},${questionID}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
const shell = group.locator(`[data-timeline-part-id="${shellID}"]`)
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await shell.locator('[data-slot="collapsible-trigger"]').click()
await expect(shell).toContainText("Command exited 1")
const question = group.locator(`[data-timeline-part-id="${questionID}"]`)
await expect(question).toContainText(/dismissed/i)
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("keeps a failed patch in Used without losing the surviving file choice", async ({ page }) => {
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
settings: { editToolPartsExpanded: true },
messages: [
userMessage(),
assistantMessage(
@@ -94,12 +77,9 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
],
})
const group = page.locator('[data-component="collapsed-tool-group"]')
const used = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await used.click()
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await expect(file).toHaveAttribute("aria-expanded", "false")
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
@@ -119,20 +99,23 @@ test("keeps a failed patch in Used without losing the surviving file choice", as
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(group).toHaveAttribute("data-timeline-part-ids", `${failed},${surviving}`)
await expect(used).toHaveAttribute("aria-expanded", "true")
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:context:/)
await group.locator(`[data-timeline-part-id="${failed}"] [data-slot="collapsible-trigger"]`).click()
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("groups instruction files loaded by the same read", async ({ page }) => {
const id = "prt_read_instructions"
await setupTimeline(page, {
settings: { timelineDetail: { ...timelinePresets[2].value, tools: { placement: "separate" } } },
messages: [
userMessage(),
assistantMessage([
@@ -165,10 +148,7 @@ test("groups only consecutive successful skill tools", async ({ page }) => {
toolPart("prt_skill_break", "read", "completed", { path: "src/a.ts" }),
toolPart("prt_skill_last", "skill", "completed", { id: "opencode" }),
]
await setupTimeline(page, {
settings: { timelineDetail: timelinePresets[2].value },
messages: [userMessage(), assistantMessage(parts)],
})
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
const group = page.locator(`[data-timeline-part-ids="${parts.map((part) => part.id).join(",")}"]`)
await group.getByRole("button").click()
@@ -1,5 +1,4 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode-ai/session-ui/timeline/detail"
import {
assistantID,
assistantMessage,
@@ -20,9 +19,6 @@ for (const width of [1400, 390]) {
messages: [userMessage()],
sessionStatus: { [sessionID]: { type: "busy" } },
viewport: { width, height: 900 },
settings: {
timelineDetail: { ...timelinePresets[2].value, thinking: { placement: "separate", details: "collapsed" } },
},
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toHaveCount(1)
@@ -55,9 +51,7 @@ for (const name of ["shell", "patch", "subagent"] as const) {
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([], { completed: false })],
settings: {
timelineDetail: { ...timelinePresets[0].value, shell: { placement: "separate", details: "collapsed" } },
},
settings: { editToolPartsExpanded: true },
})
const working = page.locator('[data-component="session-working"]')
await expect(working).toBeVisible()
@@ -95,8 +89,13 @@ for (const name of ["shell", "patch", "subagent"] as const) {
await expect(working).toHaveCount(0)
await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata })))
await expect(tool).toBeVisible()
await expect(page.locator('[data-component="collapsed-tool-group"]')).toHaveCount(0)
if (name === "shell") {
const group = page.locator('[data-component="collapsed-tool-group"]')
await expect(
group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }),
).toHaveAttribute("aria-expanded", "false")
await expect(group).toBeVisible()
}
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working")
await expect(working).toBeVisible()
await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
@@ -260,7 +259,6 @@ for (const failed of [false, true]) {
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Check the working indicator immediately.")
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
const requested = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
@@ -32,24 +32,6 @@ test.beforeEach(async ({ page }) => {
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
})
test("settings has its own route and returns through app history", async ({ page }) => {
const settings = page.getByTestId("settings-screen")
const home = page.getByRole("button", { name: "Home", exact: true })
await expect(page).toHaveURL("/settings")
await expect(home).toHaveAttribute("aria-pressed", "false")
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
await expect(page).toHaveURL("/")
await expect(home).toHaveAttribute("aria-pressed", "true")
await page.keyboard.press("Control+]")
await expect(page).toHaveURL("/settings")
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
await expect(home).toHaveAttribute("aria-pressed", "false")
await home.click()
await expect(page).toHaveURL("/")
await expect(settings).toBeHidden()
await expect(home).toHaveAttribute("aria-pressed", "true")
})
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
@@ -29,7 +29,7 @@ test("new session tab matches neighboring session widths", async ({ page }, test
await page.goto(href)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "Session", sessionB.title])
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
@@ -194,7 +194,7 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
.poll(async () => {
const bounds = await sidebar.boundingBox()
const button = await status.boundingBox()
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
return !!bounds && !!button && bounds.x + bounds.width - button.x - button.width <= 12
})
.toBe(true)
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
@@ -78,13 +78,9 @@ for (const theme of ["light", "dark"] as const) {
await expectToken(
message,
"background-color",
scenario.accent ? "--v2-background-bg-accent" : theme === "light" ? "--v2-blue-100" : "--v2-blue-1200",
)
await expectToken(
message,
"color",
scenario.accent ? "--v2-text-text-contrast" : theme === "light" ? "--v2-blue-700" : "--v2-blue-300",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -53,8 +53,9 @@ test("creates a session in a new project and selects its model", async ({ page }
}),
sessions: [],
pageMessages: () => ({ items: [] }),
// Listings are requested by absolute path and returned relative to the stable Location.
fileList: (path) => (path === "C:/OpenCode" ? [{ path: "./", type: "directory", ignored: false }] : []),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
@@ -75,23 +76,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
const picker = page.getByRole("dialog", { name: "Open project", exact: true })
await expect(picker.getByRole("combobox")).toHaveValue("C:\\OpenCode\\NewProject")
const listing = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/fs/list" && url.searchParams.get("path") === "C:/OpenCode"
})
await picker.getByRole("button", { name: "Parent", exact: true }).click()
expect(new URL((await listing).url()).searchParams.get("location[directory]")).toBe(directory)
const directoryItem = picker.getByRole("treeitem", { name: "NewProject", exact: true })
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
await expect(directoryItem).toHaveAttribute("aria-selected", "true")
await expect(picker.getByText("C:\\OpenCode\\NewProject", { exact: true })).toBeVisible()
const selectFolder = picker.getByRole("button", { name: "Select folder", exact: true })
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await expect(picker).toBeHidden()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="composer"]'))
+1 -2
View File
@@ -26,8 +26,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+1 -6
View File
@@ -2,11 +2,6 @@
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
background: var(--v2-alpha-light-10);
}
+2 -23
View File
@@ -47,7 +47,6 @@ export type ComposerEditorProps = {
attachKeybind?: string[]
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -282,24 +281,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
/>
</Show>
<Show when={state.mode === "shell"}>
<Button
data-action="composer-exit-shell"
type="button"
variant="ghost-faint"
size="small"
class="me-3 gap-1.5 px-1.5"
onClick={() => {
props.controller.dispatch({ type: "mode.normal" })
props.controller.restoreFocus()
}}
>
{i18n.t("ui.promptInput.exitShell")}
<span class="hidden sm:block">
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
</span>
</Button>
</Show>
<ComposerEditorSubmitButton
mode={state.mode}
stopping={view.submit.stopping()}
@@ -692,7 +673,6 @@ export function ComposerEditorPopover(props: {
}) {
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
onMouseDown={(event) => event.preventDefault()}
>
@@ -721,7 +701,6 @@ export function ComposerEditorPopover(props: {
<button
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
@@ -770,9 +749,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-faint"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
+1 -15
View File
@@ -3,8 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -46,18 +45,6 @@ export function createHomeController() {
void tabs.newDraft({ server: ServerConnection.key(conn), directory })
}
function openProjectSession(conn: ServerConnection.Any, directory: string, session: SessionInfo) {
const ctx = global.ensureServerCtx(conn)
void ctx.data.session.message.sync(session.id).catch(() => undefined)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.select(tab)
ctx.data.session.remember(session)
ctx.projects.open(directory)
ctx.projects.touch(directory)
})
}
return {
selection: {
value: selection,
@@ -118,7 +105,6 @@ export function createHomeController() {
openProjectNewSession(conn, project.worktree)
},
openProjectNewSession,
openProjectSession,
},
}
}
@@ -14,7 +14,6 @@ import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
import type { HomeController } from "../model"
import { useGlobal } from "@/runtime/server/runtime"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
export const HomeServersSchema = Schema.Struct({
collapsed: Persistence.record(Persistence.fallback(Schema.Boolean, () => false)),
@@ -80,35 +79,6 @@ export function createHomeProjectsController(home: HomeController) {
select: home.project.select,
add: home.project.add,
openNewSession: home.project.openProjectNewSession,
canImportSession: !!platform.openAttachmentPickerDialog,
importSession: (conn: ServerConnection.Any, project: LocalProject) => {
if (!platform.openAttachmentPickerDialog) return
void platform
.openAttachmentPickerDialog(
{
title: language.t("command.session.import"),
accept: ["application/json"],
extensions: ["json"],
},
async (file) => {
const data = await Schema.decodeUnknownPromise(Schema.fromJsonString(SessionTransfer.Data))(
await file.text(),
)
const api = home.server.context(conn).sdk.api.session
const imported = await api.import({
...Schema.encodeSync(SessionTransfer.Data)(data),
location: { directory: project.worktree },
} as Parameters<typeof api.import>[0])
home.project.openProjectSession(conn, project.worktree, imported)
},
)
.catch((cause: unknown) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(cause, language.t("common.requestFailed")),
})
})
},
edit: (conn: ServerConnection.Any, project: LocalProject) => {
void import("@/settings/workspaces/project-dialog").then(({ DialogEditProject }) => {
void dialog.show(() => <DialogEditProject server={conn} project={project} />)
@@ -37,8 +37,6 @@ export function HomeProjects(props: {
onSelectProject={props.projects.project.select}
onAddProjects={props.projects.project.add}
onOpenProjectNewSession={props.projects.project.openNewSession}
canImportSession={props.projects.project.canImportSession}
onImportSession={props.projects.project.importSession}
onEditProject={props.projects.project.edit}
onRevealProject={props.projects.project.reveal}
onClearNotifications={props.projects.project.clearNotifications}
-7
View File
@@ -57,8 +57,6 @@ export type HomeProjectsViewProps = {
onSelectProject: (server: ServerConnection.Any, directory: string) => void
onAddProjects: (server: ServerConnection.Any, directories: string[]) => void
onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void
canImportSession: boolean
onImportSession: (server: ServerConnection.Any, project: LocalProject) => void
onEditProject: (server: ServerConnection.Any, project: LocalProject) => void
onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void
onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
@@ -672,11 +670,6 @@ function HomeProjectRow(
<Menu.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</Menu.Item>
<Show when={props.canImportSession}>
<Menu.Item onSelect={() => props.onImportSession(props.server, props.project)}>
{props.language.t("command.session.import")}
</Menu.Item>
</Show>
<Menu.Item onSelect={() => props.onEditProject(props.server, props.project)}>
{props.language.t("dialog.project.edit.title")}
</Menu.Item>
@@ -21,8 +21,7 @@ import { errorMessage } from "@/shell/layout/helpers"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { removedSessionIDs } from "@/session/session-domain"
import { pathKey } from "@/workspaces/path-key"
import { fetchSessionExport, saveSessionExport, sessionExportFilename } from "@/session/commands/export"
import { usePlatform } from "@/runtime/platform/platform"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
@@ -46,7 +45,6 @@ export function createHomeSessionsController(home: HomeController) {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const queryClient = useQueryClient()
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
@@ -174,7 +172,7 @@ export function createHomeSessionsController(home: HomeController) {
try {
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
const filename = sessionExportFilename(data.info)
if (!(await saveSessionExport(filename, data, platform))) return
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
@@ -50,7 +50,6 @@ export function createComposerProjectControls(props: { draftId: string; worktree
if (!connection) return
pickDirectory({
server: connection,
location: ServerConnection.key(connection) === ServerConnection.key(projectServer()) ? location().ref : undefined,
title,
onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : result
+1 -1
View File
@@ -52,7 +52,7 @@ export function NewSessionView(props: {
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
data-component="new-session"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
class="relative flex-1 min-h-0 overflow-hidden rounded-[10px] bg-v2-background-bg-deep"
>
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class={NEW_SESSION_CONTENT_WIDTH}>
@@ -1,6 +1,5 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
@@ -16,7 +15,6 @@ export function createProviderConnectionController(options: {
pollInterval?: number
}) {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const data = useData()
const location = () => {
@@ -186,14 +184,7 @@ export function createProviderConnectionController(options: {
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
}
return { ok: true as const, authorization: response.data }
})
.then((response) => ({ ok: true as const, authorization: response.data }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ሳይክል ቋንቋ",
"command.language.set": "ቋንቋን ተጠቀም፡ {{language}}",
"command.session.new": "አዲስ ክፍለ ጊዜ",
"command.session.import": "ክፍለ ጊዜ ማስመጣት",
"command.file.open": "ክፍት ፋይል",
"command.tab.close": "ትርፉን ዝጋ",
"command.tab.reopenClosed": "የተዘጋውን ትር እንደገና ክፈት",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.session.import": "استيراد جلسة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
"command.tab.reopenClosed": "إعادة فتح علامة التبويب المغلقة",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Dili dəyiş",
"command.language.set": "Dildən istifadə et: {{language}}",
"command.session.new": "Yeni sessiya",
"command.session.import": "Sessiyanı idxal et",
"command.file.open": "Faylı aç",
"command.tab.close": "Tabı bağla",
"command.tab.reopenClosed": "Bağlanmış tabı yenidən aç",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Цикличен език",
"command.language.set": "Използвайте език: {{language}}",
"command.session.new": "Нова сесия",
"command.session.import": "Импортиране на сесия",
"command.file.open": "Отворете файла",
"command.tab.close": "Затваряне на раздела",
"command.tab.reopenClosed": "Повторно отваряне на затворен раздел",
-1
View File
@@ -134,7 +134,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "সাইকেল ভাষা",
"command.language.set": "ভাষা ব্যবহার করুন: {{language}}",
"command.session.new": "নতুন সেশন",
"command.session.import": "সেশন আমদানি করুন",
"command.file.open": "ফাইল খুলুন",
"command.tab.close": "ট্যাব বন্ধ করুন",
"command.tab.reopenClosed": "বন্ধ ট্যাব আবার খুলুন",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.session.import": "Importar sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
"command.tab.reopenClosed": "Reabrir aba fechada",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Koristi jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovo otvori zatvorenu karticu",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Llenguatge de cicle",
"command.language.set": "Utilitza l'idioma: {{language}}",
"command.session.new": "Nova sessió",
"command.session.import": "Importa la sessió",
"command.file.open": "Obre el fitxer",
"command.tab.close": "Tanca la pestanya",
"command.tab.reopenClosed": "Torneu a obrir la pestanya tancada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Jazyk cyklu",
"command.language.set": "Použít jazyk: {{language}}",
"command.session.new": "Nová relace",
"command.session.import": "Importovat relaci",
"command.file.open": "Otevřít soubor",
"command.tab.close": "Zavřít kartu",
"command.tab.reopenClosed": "Znovu otevřete zavřenou kartu",
-1
View File
@@ -46,7 +46,6 @@ export const dict = {
"command.language.set": "Brug sprog: {{language}}",
"command.session.new": "Ny session",
"command.session.import": "Importer session",
"command.file.open": "Åbn fil",
"command.tab.close": "Luk fane",
"command.tab.reopenClosed": "Åbn lukket fane igen",
-1
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.language.cycle": "Sprache wechseln",
"command.language.set": "Sprache verwenden: {{language}}",
"command.session.new": "Neue Sitzung",
"command.session.import": "Sitzung importieren",
"command.file.open": "Datei öffnen",
"command.tab.close": "Tab schließen",
"command.tab.reopenClosed": "Geschlossenen Tab wieder öffnen",
-1
View File
@@ -136,7 +136,6 @@ export const dict = {
"command.language.cycle": "ސައިކަލް ބަސް",
"command.language.set": "ބަސް ބޭނުންކުރުން: {{language}}",
"command.session.new": "އާ ޖަލްސާއެއް",
"command.session.import": "ޖަލްސާ އިމްޕޯޓް ކުރައްވާ",
"command.file.open": "ފައިލް ހުޅުވާލާށެވެ",
"command.tab.close": "ޓެބް ބަންދުކުރުން",
"command.tab.reopenClosed": "ބަންދުކޮށްފައިވާ ޓެބް އަލުން ހުޅުވާލާށެވެ",
-1
View File
@@ -136,7 +136,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "འཁོར་བའི་སྐད་ཡིག།",
"command.language.set": "སྐད་ཡིག་ལག་ལེན་འཐབ།: {{language}}",
"command.session.new": "ལཱ་ཡུན་གསརཔ།",
"command.session.import": "ལཱ་ཡུན་ནང་འདྲེན།",
"command.file.open": "ཡིག་སྣོད་ཁ་ཕྱེ།",
"command.tab.close": "མཆོང་ལྡེ་ཁ་བསྡམས།",
"command.tab.reopenClosed": "ཁ་བསྡམས་ཡོད་པའི་མཆོང་ལྡེ་ལོག་ཁ་ཕྱེ།",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Γλώσσα κύκλου",
"command.language.set": "Γλώσσα χρήσης: {{language}}",
"command.session.new": "Νέα συνεδρία",
"command.session.import": "Εισαγωγή συνεδρίας",
"command.file.open": "Άνοιγμα αρχείου",
"command.tab.close": "Κλείσιμο καρτέλας",
"command.tab.reopenClosed": "Άνοιγμα ξανά κλειστής καρτέλας",
-38
View File
@@ -100,7 +100,6 @@ export const dict = {
"command.session.fork.description": "Create a new session from a previous message",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
"command.session.copyID": "Copy Session ID",
"palette.search.placeholder": "Search files, commands, and sessions",
@@ -676,14 +675,11 @@ export const dict = {
"session.error.incompatible.description":
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
"session.background.moveTasks": "Move {{tasks}} to background",
"session.background.moveRunning": "Move running work to background",
"session.background.inBackground": "Running {{tasks}} in background",
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
"session.background.running": "Running work in background",
"session.background.runningCount.one": "{{count}} item running in background",
"session.background.runningCount.other": "{{count}} items running in background",
"session.background.tasksRunning.one": "{{count}} background task running",
"session.background.tasksRunning.other": "{{count}} background tasks running",
"session.background.combine": "{{first}} and {{second}}",
"session.background.shell.one": "{{count}} shell",
"session.background.shell.other": "{{count}} shells",
@@ -955,40 +951,6 @@ export const dict = {
"settings.general.section.feed": "Feed",
"settings.general.section.display": "Display",
"settings.timeline.title": "Timeline",
"settings.timeline.detail": "Timeline detail",
"settings.timeline.description": "Choose how much activity appears in the timeline. Messages stay visible.",
"settings.timeline.preset.everything": "Everything",
"settings.timeline.preset.detailed": "Detailed",
"settings.timeline.preset.compact": "Compact",
"settings.timeline.preset.quiet": "Quiet",
"settings.timeline.preset.text-only": "Text only",
"settings.timeline.description.everything": "Show all activity separately. Expand shell output, edits, and thinking.",
"settings.timeline.description.detailed":
"Expand shell output and edits. Show subagents separately and group other activity in Used.",
"settings.timeline.description.compact": "Group all activity in Used with details collapsed.",
"settings.timeline.description.quiet": "Group edits and subagents in Used. Hide other activity.",
"settings.timeline.description.text-only": "Hide all activity. Show only messages.",
"settings.timeline.description.custom": "Use your selected placement and details for each activity category.",
"settings.timeline.custom": "Custom",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.advanced.explainer": "Grouped activity goes into Used. Details applies after opening the group.",
"settings.timeline.activity": "Activity",
"settings.timeline.category.shell": "Shell",
"settings.timeline.category.edit": "Edits",
"settings.timeline.category.thinking": "Thinking",
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.placement.title": "Placement",
"settings.timeline.placement.separate": "Separate",
"settings.timeline.placement.grouped": "Grouped",
"settings.timeline.placement.hidden": "Hidden",
"settings.timeline.expansion.title": "Details",
"settings.timeline.expansion.collapsed": "Collapsed",
"settings.timeline.expansion.expanded": "Expanded",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
"settings.general.row.shell.title": "Terminal shell",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nueva sesión",
"command.session.import": "Importar sesión",
"command.file.open": "Abrir archivo",
"command.tab.close": "Cerrar pestaña",
"command.tab.reopenClosed": "Reabrir pestaña cerrada",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Tsükli keel",
"command.language.set": "Kasuta keelt: {{language}}",
"command.session.new": "Uus seanss",
"command.session.import": "Impordi seanss",
"command.file.open": "Ava fail",
"command.tab.close": "Sule vahekaart",
"command.tab.reopenClosed": "Ava suletud vaheleht uuesti",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "زبان چرخه",
"command.language.set": "استفاده از زبان: {{language}}",
"command.session.new": "جلسه جدید",
"command.session.import": "وارد کردن جلسه",
"command.file.open": "باز کردن فایل",
"command.tab.close": "بستن برگه",
"command.tab.reopenClosed": "برگه بسته را دوباره باز کنید",
-1
View File
@@ -40,7 +40,6 @@ export const dict = {
"command.language.cycle": "Vaihda kieltä",
"command.language.set": "Käytä kieltä: {{language}}",
"command.session.new": "Uusi istunto",
"command.session.import": "Tuo istunto",
"command.file.open": "Avaa tiedosto",
"command.tab.close": "Sulje välilehti",
"command.tab.reopenClosed": "Avaa suljettu välilehti uudelleen",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Súkklumál",
"command.language.set": "Brúka mál: {{language}}",
"command.session.new": "Nýggj setan",
"command.session.import": "Innflyt setan",
"command.file.open": "Opna fíluna",
"command.tab.close": "Lat flipan aftur",
"command.tab.reopenClosed": "Opna aftur stongdan flipan",
-1
View File
@@ -141,7 +141,6 @@ export const dict = {
"command.language.cycle": "Changer de langue",
"command.language.set": "Utiliser la langue : {{language}}",
"command.session.new": "Nouvelle session",
"command.session.import": "Importer une session",
"command.file.open": "Ouvrir un fichier",
"command.tab.close": "Fermer l'onglet",
"command.tab.reopenClosed": "Rouvrir l'onglet fermé",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "מעבר לשפה הבאה",
"command.language.set": "השתמש בשפה: {{language}}",
"command.session.new": "הפעלה חדשה",
"command.session.import": "ייבוא הפעלה",
"command.file.open": "פתח את הקובץ",
"command.tab.close": "סגור כרטיסייה",
"command.tab.reopenClosed": "פתח מחדש את הכרטיסייה הסגורה",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "भाषा बदलें",
"command.language.set": "भाषा का प्रयोग करें: {{language}}",
"command.session.new": "नया सेशन",
"command.session.import": "सेशन आयात करें",
"command.file.open": "फ़ाइल खोलें",
"command.tab.close": "टैब बंद करें",
"command.tab.reopenClosed": "बंद टैब पुनः खोलें",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Promijeni jezik",
"command.language.set": "Koristite jezik: {{language}}",
"command.session.new": "Nova sesija",
"command.session.import": "Uvezi sesiju",
"command.file.open": "Otvori datoteku",
"command.tab.close": "Zatvori karticu",
"command.tab.reopenClosed": "Ponovno otvori zatvorenu karticu",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Nyelv váltása",
"command.language.set": "Nyelv használata: {{language}}",
"command.session.new": "Új munkamenet",
"command.session.import": "Munkamenet importálása",
"command.file.open": "Nyissa meg a fájlt",
"command.tab.close": "Lap bezárása",
"command.tab.reopenClosed": "Nyissa meg újra a bezárt lapot",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Ցիկլի լեզու",
"command.language.set": "Օգտագործել լեզուն՝ {{language}}",
"command.session.new": "Նոր նիստ",
"command.session.import": "Ներմուծել նիստը",
"command.file.open": "Բացել ֆայլ",
"command.tab.close": "Փակել ներդիրը",
"command.tab.reopenClosed": "Վերաբացել փակ ներդիրը",
-1
View File
@@ -147,7 +147,6 @@ export const dict = {
"command.language.set": "Gunakan bahasa: {{language}}",
"command.session.new": "Sesi baru",
"command.session.import": "Impor sesi",
"command.file.open": "Buka berkas",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka kembali tab yang ditutup",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Skipta um tungumál",
"command.language.set": "Notaðu tungumál: {{language}}",
"command.session.new": "Ný seta",
"command.session.import": "Flytja inn setu",
"command.file.open": "Opna skrá",
"command.tab.close": "Loka flipa",
"command.tab.reopenClosed": "Opnaðu aftur lokaðan flipa",
-1
View File
@@ -41,7 +41,6 @@ export const dict = {
"command.language.cycle": "Cambia lingua",
"command.language.set": "Usa la lingua: {{language}}",
"command.session.new": "Nuova sessione",
"command.session.import": "Importa sessione",
"command.file.open": "Apri file",
"command.tab.close": "Chiudi scheda",
"command.tab.reopenClosed": "Riapri la scheda chiusa",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "言語の切り替え",
"command.language.set": "言語を使用: {{language}}",
"command.session.new": "新しいセッション",
"command.session.import": "セッションをインポート",
"command.file.open": "ファイルを開く",
"command.tab.close": "タブを閉じる",
"command.tab.reopenClosed": "閉じたタブを再度開く",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ციკლის ენა",
"command.language.set": "გამოიყენე ენა: {{language}}",
"command.session.new": "ახალი სესია",
"command.session.import": "სესიის იმპორტი",
"command.file.open": "გახსენით ფაილი",
"command.tab.close": "ჩანართის დახურვა",
"command.tab.reopenClosed": "დახურული ჩანართის ხელახლა გახსნა",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ភាសាវដ្ត",
"command.language.set": "ប្រើភាសា៖ {{language}}",
"command.session.new": "សម័យថ្មី។",
"command.session.import": "នាំចូលសម័យ",
"command.file.open": "បើកឯកសារ",
"command.tab.close": "បិទផ្ទាំង",
"command.tab.reopenClosed": "បើកផ្ទាំងបិទឡើងវិញ",
-1
View File
@@ -37,7 +37,6 @@ export const dict = {
"command.language.cycle": "언어 순환",
"command.language.set": "언어 사용: {{language}}",
"command.session.new": "새 세션",
"command.session.import": "세션 가져오기",
"command.file.open": "파일 열기",
"command.tab.close": "탭 닫기",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "ພາສາຮອບວຽນ",
"command.language.set": "ໃຊ້ພາສາ: {{language}}",
"command.session.new": "ເຊດຊັນໃໝ່",
"command.session.import": "ນຳເຂົ້າເຊດຊັນ",
"command.file.open": "ເປີດໄຟລ໌",
"command.tab.close": "ປິດແຖບ",
"command.tab.reopenClosed": "ເປີດແຖບປິດຄືນໃໝ່",
-1
View File
@@ -137,7 +137,6 @@ export const dict = {
"command.language.cycle": "Perjungti kalbą",
"command.language.set": "Naudokite kalbą: {{language}}",
"command.session.new": "Naujas seansas",
"command.session.import": "Importuoti seansą",
"command.file.open": "Atidaryti failą",
"command.tab.close": "Uždaryti skirtuką",
"command.tab.reopenClosed": "Iš naujo atidaryti uždarytą skirtuką",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Mainīt valodu",
"command.language.set": "Izmantot valodu: {{language}}",
"command.session.new": "Jauna sesija",
"command.session.import": "Importēt sesiju",
"command.file.open": "Atvērt failu",
"command.tab.close": "Aizvērt cilni",
"command.tab.reopenClosed": "Atvērt aizvērtu cilni",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Јазик на циклус",
"command.language.set": "Користете јазик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесија",
"command.file.open": "Отворете ја датотеката",
"command.tab.close": "Затвори ја картичката",
"command.tab.reopenClosed": "Повторно отворете го затворениот таб",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "Циклийн хэл",
"command.language.set": "Хэл ашиглах: {{language}}",
"command.session.new": "Шинэ сесс",
"command.session.import": "Сесс импортлох",
"command.file.open": "Файлыг нээх",
"command.tab.close": "Табыг хаах",
"command.tab.reopenClosed": "Хаагдсан табыг дахин нээнэ үү",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Tukar bahasa",
"command.language.set": "Guna bahasa: {{language}}",
"command.session.new": "Sesi baharu",
"command.session.import": "Import sesi",
"command.file.open": "Buka fail",
"command.tab.close": "Tutup tab",
"command.tab.reopenClosed": "Buka semula tab tertutup",
-1
View File
@@ -135,7 +135,6 @@ export const dict = {
"command.language.cycle": "စက်ဝိုင်းဘာသာစကား",
"command.language.set": "ဘာသာစကားကို အသုံးပြုပါ- {{language}}",
"command.session.new": "စက်ရှင်အသစ်",
"command.session.import": "စက်ရှင် တင်သွင်းရန်",
"command.file.open": "ဖိုင်ကိုဖွင့်ပါ။",
"command.tab.close": "တဘ်ကို ပိတ်ပါ။",
"command.tab.reopenClosed": "ပိတ်ထားသော တက်ဘ်ကို ပြန်ဖွင့်ပါ။",
-1
View File
@@ -134,7 +134,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "साइकल भाषा",
"command.language.set": "भाषा प्रयोग गर्नुहोस्: {{language}}",
"command.session.new": "नयाँ सत्र",
"command.session.import": "सत्र आयात गर्नुहोस्",
"command.file.open": "फाइल खोल्नुहोस्",
"command.tab.close": "ट्याब बन्द गर्नुहोस्",
"command.tab.reopenClosed": "बन्द ट्याब पुन: खोल्नुहोस्",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Volgende taal",
"command.language.set": "Gebruik taal: {{language}}",
"command.session.new": "Nieuwe sessie",
"command.session.import": "Sessie importeren",
"command.file.open": "Bestand openen",
"command.tab.close": "Tabblad sluiten",
"command.tab.reopenClosed": "Gesloten tabblad opnieuw openen",
-1
View File
@@ -146,7 +146,6 @@ export const dict = {
"command.language.set": "Bruk språk: {{language}}",
"command.session.new": "Ny sesjon",
"command.session.import": "Importer sesjon",
"command.file.open": "Åpne fil",
"command.tab.close": "Lukk fane",
"command.context.addSelection": "Legg til markering i kontekst",
-1
View File
@@ -139,7 +139,6 @@ export const dict = {
"command.language.cycle": "اگلی بولی ورتو",
"command.language.set": "بولی ورتو: {{language}}",
"command.session.new": "نواں سیشن",
"command.session.import": "سیشن درآمد کرو",
"command.file.open": "فائل کھولو",
"command.tab.close": "ٹیب بند کرو",
"command.tab.reopenClosed": "بند ٹیب دوبارہ کھولو",
-1
View File
@@ -140,7 +140,6 @@ export const dict = {
"command.language.cycle": "Przełącz język",
"command.language.set": "Użyj języka: {{language}}",
"command.session.new": "Nowa sesja",
"command.session.import": "Importuj sesję",
"command.file.open": "Otwórz plik",
"command.tab.close": "Zamknij kartę",
"command.tab.reopenClosed": "Otwórz ponownie zamkniętą kartę",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Schimbă limba",
"command.language.set": "Folosește limba: {{language}}",
"command.session.new": "Sesiune nouă",
"command.session.import": "Importă sesiunea",
"command.file.open": "Deschide fișier",
"command.tab.close": "Închide fila",
"command.tab.reopenClosed": "Redeschide fila închisă",
-1
View File
@@ -146,7 +146,6 @@ export const dict = {
"command.language.set": "Использовать язык: {{language}}",
"command.session.new": "Новая сессия",
"command.session.import": "Импортировать сессию",
"command.file.open": "Открыть файл",
"command.tab.close": "Закрыть вкладку",
"command.tab.reopenClosed": "Повторно открыть закрытую вкладку",
-1
View File
@@ -133,7 +133,6 @@ export const dict: Record<string, string> = {
"command.language.cycle": "චක්‍ර භාෂාව",
"command.language.set": "භාෂාව භාවිතා කරන්න: {{language}}",
"command.session.new": "නව සැසිය",
"command.session.import": "සැසිය ආනයනය කරන්න",
"command.file.open": "ගොනුව විවෘත කරන්න",
"command.tab.close": "ටැබ් එක වසන්න",
"command.tab.reopenClosed": "වසා දැමූ ටැබය නැවත විවෘත කරන්න",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Prepnúť jazyk",
"command.language.set": "Použiť jazyk: {{language}}",
"command.session.new": "Nová relácia",
"command.session.import": "Importovať reláciu",
"command.file.open": "Otvoriť súbor",
"command.tab.close": "Zavrieť kartu",
"command.tab.reopenClosed": "Obnoviť zatvorenú kartu",
-1
View File
@@ -133,7 +133,6 @@ export const dict = {
"command.language.cycle": "Jezik cikla",
"command.language.set": "Uporabi jezik: {{language}}",
"command.session.new": "Nova seja",
"command.session.import": "Uvozi sejo",
"command.file.open": "Odpri datoteko",
"command.tab.close": "Zapri zavihek",
"command.tab.reopenClosed": "Ponovno odpri zaprt zavihek",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "Gjuha e ciklit",
"command.language.set": "Përdorni gjuhën: {{language}}",
"command.session.new": "Sesion i ri",
"command.session.import": "Importo sesionin",
"command.file.open": "Hap skedarin",
"command.tab.close": "Mbyll skedën",
"command.tab.reopenClosed": "Rihap skedën e mbyllur",
-1
View File
@@ -134,7 +134,6 @@ export const dict = {
"command.language.cycle": "језик циклуса",
"command.language.set": "Користи језик: {{language}}",
"command.session.new": "Нова сесија",
"command.session.import": "Увези сесију",
"command.file.open": "Отворите датотеку",
"command.tab.close": "Затвори картицу",
"command.tab.reopenClosed": "Поново отворите затворену картицу",

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