mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 06:33:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5752d74cae |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
---
|
||||
|
||||
Add form reply and cancellation operations that reconcile terminal forms in the local TUI projection.
|
||||
@@ -27,6 +27,7 @@ jobs:
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
bun-version: 1.4.0
|
||||
bun-version: canary # Bun 1.4 until its stable release is published
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
id: build
|
||||
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
|
||||
env:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.0
|
||||
BUN_COMPILE_RELEASE: canary
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
@@ -195,33 +195,9 @@ jobs:
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-app-archive:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build app archive
|
||||
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache/app-archive.bin
|
||||
if-no-files-found: error
|
||||
|
||||
build-node-cli:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
needs: version
|
||||
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -234,7 +210,6 @@ jobs:
|
||||
host: macos-26
|
||||
- target: windows-arm64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
bun_install_flags: --cpu=*
|
||||
- target: windows-x64
|
||||
host: blacksmith-4vcpu-windows-2025
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
@@ -246,19 +221,14 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
with:
|
||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
||||
install-flags: --os=* --cpu=*
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "26.4.0"
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-node-app-archive
|
||||
path: packages/cli/.cache
|
||||
|
||||
- name: Build
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
|
||||
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
@@ -573,7 +543,6 @@ jobs:
|
||||
- version
|
||||
- build-cli
|
||||
- sign-cli-macos
|
||||
- build-node-app-archive
|
||||
- build-node-cli
|
||||
- sign-cli-windows
|
||||
- build-electron
|
||||
|
||||
@@ -84,11 +84,6 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/codemode
|
||||
run: bun run script/publish.ts --dry-run
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-LvDHCOm8OAZfvb0I0L6AbdOevRoQmEJnnqrSgAiNHv8=",
|
||||
"aarch64-linux": "sha256-O0L0iHjb4cwl9xWHIna8VFHyQzoAKzfY8oMpVNayMOg=",
|
||||
"aarch64-darwin": "sha256-ETP8FE71NqufYDUbR7tBdsMEOVQ44wLmsZBeZiSRBRY=",
|
||||
"x86_64-darwin": "sha256-WUcoLldDriT3QxcdlnBQhuPrxDNub0EDvvZXk/pDMpY="
|
||||
"x86_64-linux": "sha256-V1G3RuuwtlVx166oDVCqQ9v7ELWRr5JDYitcCMitbNA=",
|
||||
"aarch64-linux": "sha256-nm/N60X7kdkNIa2oCgqyNp9ewrBrWvr6ES0qhxzypQc=",
|
||||
"aarch64-darwin": "sha256-7UQtp4KrtxuT6MMrz7YYcJcF9/b5ECobkSwzI+yLe0Q=",
|
||||
"x86_64-darwin": "sha256-jrn0GOa7qDskowio97bvhl7rsideXqHVbygSi3M1cf0="
|
||||
}
|
||||
}
|
||||
|
||||
+11
-10
@@ -39,19 +39,19 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.111",
|
||||
"@effect/platform-node": "4.0.0-rc.111",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.111",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.111",
|
||||
"@effect/opentelemetry": "4.0.0-rc.110",
|
||||
"@effect/platform-node": "4.0.0-rc.110",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.110",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.110",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.7",
|
||||
"@opentui/keymap": "0.5.7",
|
||||
"@opentui/solid": "0.5.7",
|
||||
"@opentui/core": "0.5.6",
|
||||
"@opentui/keymap": "0.5.6",
|
||||
"@opentui/solid": "0.5.6",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -71,9 +71,9 @@
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"diff": "8.0.2",
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.111",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -166,6 +166,7 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
|
||||
@@ -213,7 +213,7 @@ Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `t
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `local_shell_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
|
||||
@@ -41,7 +41,6 @@ const SSE_EVENTS = new Set([
|
||||
"content_block_start",
|
||||
"content_block_delta",
|
||||
"content_block_stop",
|
||||
"ping",
|
||||
"error",
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
@@ -54,7 +53,7 @@ export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({ readonly type: "enabled"; readonly display?: "summarized" | "omitted" } & (
|
||||
| ({ readonly type: "enabled" } & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
@@ -205,18 +204,14 @@ const AnthropicTool = Schema.Struct({
|
||||
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
|
||||
|
||||
const AnthropicToolChoice = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["auto", "any", "none"]),
|
||||
disable_parallel_tool_use: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String, disable_parallel_tool_use: Schema.optional(Schema.Boolean) }),
|
||||
Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }),
|
||||
Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }),
|
||||
])
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
@@ -373,26 +368,10 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, {
|
||||
auto: () => ({
|
||||
type: "auto" as const,
|
||||
...(toolChoice.disableParallelToolUse === undefined
|
||||
? {}
|
||||
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
|
||||
}),
|
||||
auto: () => ({ type: "auto" as const }),
|
||||
none: () => ({ type: "none" as const }),
|
||||
required: () => ({
|
||||
type: "any" as const,
|
||||
...(toolChoice.disableParallelToolUse === undefined
|
||||
? {}
|
||||
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
|
||||
}),
|
||||
tool: (name) => ({
|
||||
type: "tool" as const,
|
||||
name,
|
||||
...(toolChoice.disableParallelToolUse === undefined
|
||||
? {}
|
||||
: { disable_parallel_tool_use: toolChoice.disableParallelToolUse }),
|
||||
}),
|
||||
required: () => ({ type: "any" as const }),
|
||||
tool: (name) => ({ type: "tool" as const, name }),
|
||||
})
|
||||
|
||||
const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
@@ -635,12 +614,15 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive")
|
||||
if (input.type === "adaptive") {
|
||||
const display =
|
||||
input.display === "summarized"
|
||||
? ("summarized" as const)
|
||||
: input.display === "omitted"
|
||||
? ("omitted" as const)
|
||||
: undefined
|
||||
return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) }
|
||||
}
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "enabled") return undefined
|
||||
const budget =
|
||||
@@ -651,7 +633,7 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, ...(display === undefined ? {} : { display }) }
|
||||
return { type: "enabled" as const, budget_tokens: budget }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
@@ -812,8 +794,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
const block = event.content_block
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
|
||||
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
@@ -821,7 +802,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, event.index, {
|
||||
id: block.id,
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
input:
|
||||
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||
@@ -833,7 +814,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: block.id,
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||
}),
|
||||
@@ -1023,28 +1004,15 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
|
||||
}
|
||||
|
||||
const onError = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
}),
|
||||
)
|
||||
new AIError({
|
||||
module: ADAPTER,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
|
||||
if (event.type === "content_block_start") {
|
||||
const block = event.content_block
|
||||
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
|
||||
if (event.index === undefined)
|
||||
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
|
||||
if (!block.id)
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
|
||||
)
|
||||
}
|
||||
return Effect.succeed(onContentBlockStart(state, event))
|
||||
}
|
||||
if (event.type === "content_block_start") return Effect.succeed(onContentBlockStart(state, event))
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
@@ -1083,10 +1051,7 @@ export const route = Route.make({
|
||||
provider: "anthropic",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol,
|
||||
endpoint: Endpoint.path(
|
||||
(input) => (input.request.model.provider === "anthropic" ? `${PATH}?beta=true` : PATH),
|
||||
{ baseURL: DEFAULT_BASE_URL },
|
||||
),
|
||||
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
|
||||
auth: Auth.none,
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -41,13 +41,6 @@ const requiresThoughtSignatureFallback = (modelID: string) => {
|
||||
// so their tool-result attachments lower as a separate user turn instead.
|
||||
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
|
||||
|
||||
// Blacklist: Gemini 1.x/2.x ignore or reject explicit function call ids.
|
||||
// Every other model id (Gemini 3+, gemma, anything unrecognized) gets them.
|
||||
const omitsFunctionCallIds = (modelID: string) => {
|
||||
const match = /^gemini(?:-live)?-(\d+)/i.exec(modelID)
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
@@ -82,15 +75,10 @@ export type ProviderOptionsInput = OptionsInput
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
// Gemini is known to send explicit `null` for optional streaming fields
|
||||
// (usage counts, flags, whole subtrees), so every response-side optional uses
|
||||
// `optionalNull` instead of bare `Schema.optional`. The same part/content
|
||||
// schemas lower the outbound request body; encoding drops `undefined` keys,
|
||||
// so the shared schemas stay safe there.
|
||||
const GeminiTextPart = Schema.Struct({
|
||||
text: Schema.String,
|
||||
thought: optionalNull(Schema.Boolean),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thought: Schema.optional(Schema.Boolean),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiInlineDataPart = Schema.Struct({
|
||||
@@ -103,11 +91,11 @@ type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
|
||||
|
||||
const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: optionalNull(Schema.String),
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: optionalNull(Schema.String),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiFunctionResponsePart = Schema.Struct({
|
||||
@@ -127,8 +115,8 @@ const GeminiContentPart = Schema.Union([
|
||||
])
|
||||
|
||||
const GeminiContent = Schema.Struct({
|
||||
role: optionalNull(Schema.Literals(["user", "model"])),
|
||||
parts: optionalNull(Schema.Array(GeminiContentPart)),
|
||||
role: Schema.Literals(["user", "model"]),
|
||||
parts: Schema.Array(GeminiContentPart),
|
||||
})
|
||||
type GeminiContent = Schema.Schema.Type<typeof GeminiContent>
|
||||
|
||||
@@ -191,33 +179,33 @@ const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
|
||||
const GeminiUsage = Schema.Struct({
|
||||
cachedContentTokenCount: optionalNull(Schema.Number),
|
||||
thoughtsTokenCount: optionalNull(Schema.Number),
|
||||
promptTokenCount: optionalNull(Schema.Number),
|
||||
candidatesTokenCount: optionalNull(Schema.Number),
|
||||
totalTokenCount: optionalNull(Schema.Number),
|
||||
cachedContentTokenCount: Schema.optional(Schema.Number),
|
||||
thoughtsTokenCount: Schema.optional(Schema.Number),
|
||||
promptTokenCount: Schema.optional(Schema.Number),
|
||||
candidatesTokenCount: Schema.optional(Schema.Number),
|
||||
totalTokenCount: Schema.optional(Schema.Number),
|
||||
})
|
||||
type GeminiUsage = Schema.Schema.Type<typeof GeminiUsage>
|
||||
|
||||
const GeminiCandidate = Schema.Struct({
|
||||
content: optionalNull(GeminiContent),
|
||||
finishReason: optionalNull(Schema.String),
|
||||
content: Schema.optional(GeminiContent),
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: optionalNull(Schema.String),
|
||||
blockReasonMessage: optionalNull(Schema.String),
|
||||
safetyRatings: optionalNull(Schema.Unknown),
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalNull(Schema.Array(GeminiCandidate)),
|
||||
promptFeedback: optionalNull(GeminiPromptFeedback),
|
||||
usageMetadata: optionalNull(GeminiUsage),
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
|
||||
@@ -229,7 +217,6 @@ interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
readonly seenCallIds?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -287,14 +274,20 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, omitIds: boolean) => ({
|
||||
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
|
||||
const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
|
||||
const google = providerMetadata?.google
|
||||
return ProviderShared.isRecord(google) && typeof google.functionCallId === "string"
|
||||
? google.functionCallId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart) => ({
|
||||
functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
|
||||
thoughtSignature: thoughtSignature(part.providerMetadata),
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMRequest) {
|
||||
const contents: GeminiContent[] = []
|
||||
const omitCallIds = omitsFunctionCallIds(request.model.id)
|
||||
const legacyToolMedia = routesLegacyToolMedia(request.model.id)
|
||||
let pendingMedia: GeminiInlineDataPart[] | undefined
|
||||
const flushMedia = () => {
|
||||
@@ -310,8 +303,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const previous = contents.at(-1)
|
||||
// Gemini rejects a continuation whose function-response turn carries extra
|
||||
// parts, so an update after a tool result starts its own user turn.
|
||||
if (previous?.role === "user" && !(previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), { text: part.text }] }
|
||||
if (previous?.role === "user" && !previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, { text: part.text }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
}
|
||||
@@ -343,7 +336,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
const lowered = lowerToolCall(part, omitCallIds)
|
||||
const lowered = lowerToolCall(part)
|
||||
const signature = lowered.thoughtSignature
|
||||
parts.push({
|
||||
...lowered,
|
||||
@@ -368,7 +361,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (part.result.type !== "content") {
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -389,7 +382,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (legacyToolMedia && media.length > 0) (pendingMedia ??= []).push(...media)
|
||||
parts.push({
|
||||
functionResponse: {
|
||||
...(omitCallIds ? {} : { id: part.id }),
|
||||
id: functionCallId(part.providerMetadata),
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
@@ -402,8 +395,8 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
// Gemini requires every response to a parallel call batch in one user turn,
|
||||
// so consecutive tool results join the open function-response turn.
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user" && (previous.parts ?? []).some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...(previous.parts ?? []), ...parts] }
|
||||
if (previous?.role === "user" && previous.parts.some((item) => "functionResponse" in item))
|
||||
contents[contents.length - 1] = { role: "user", parts: [...previous.parts, ...parts] }
|
||||
else contents.push({ role: "user", parts })
|
||||
}
|
||||
|
||||
@@ -493,25 +486,21 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
const promptTokens = usage.promptTokenCount ?? undefined
|
||||
const cached = usage.cachedContentTokenCount ?? undefined
|
||||
const thoughts = usage.thoughtsTokenCount ?? undefined
|
||||
const visible = usage.candidatesTokenCount ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(promptTokens, cached)
|
||||
const cached = usage.cachedContentTokenCount
|
||||
const nonCached = ProviderShared.subtractTokens(usage.promptTokenCount, cached)
|
||||
// `candidatesTokenCount` is visible-only; sum with thoughts to produce the
|
||||
// inclusive `outputTokens` the contract expects. Only compute the total
|
||||
// when the visible component is reported — otherwise we'd fabricate an
|
||||
// inclusive number from a partial breakdown.
|
||||
const outputTokens = visible !== undefined ? visible + (thoughts ?? 0) : undefined
|
||||
const outputTokens =
|
||||
usage.candidatesTokenCount !== undefined ? usage.candidatesTokenCount + (usage.thoughtsTokenCount ?? 0) : undefined
|
||||
return new Usage({
|
||||
inputTokens: promptTokens,
|
||||
inputTokens: usage.promptTokenCount,
|
||||
outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
reasoningTokens: usage.thoughtsTokenCount,
|
||||
totalTokens: ProviderShared.totalTokens(usage.promptTokenCount, outputTokens, usage.totalTokenCount),
|
||||
providerMetadata: { google: usage },
|
||||
})
|
||||
}
|
||||
@@ -546,10 +535,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
// `?? undefined` normalizes an explicit `null` blockReason back to absent so
|
||||
// the "nothing to finish" check below keeps its meaning.
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
@@ -595,10 +581,8 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
let lifecycle = nextState.lifecycle
|
||||
let reasoningSignature = nextState.reasoningSignature
|
||||
let textSignature = nextState.textSignature
|
||||
// Supplier ids must be tracked across chunks of the same response, not just within one event's parts.
|
||||
const seenCallIds = new Set(nextState.seenCallIds)
|
||||
|
||||
for (const part of candidate.content.parts ?? []) {
|
||||
for (const part of candidate.content.parts) {
|
||||
const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined
|
||||
// Gemini attaches replay signatures to thought parts, visible text, or function calls;
|
||||
// each block kind must retain the signature attached to its own parts.
|
||||
@@ -634,14 +618,13 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
// Gemini 2.0+ supplies a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// Gemini 2.0+ and Vertex supply a unique function call ID on the part; when omitted (e.g. Gemini 1.5),
|
||||
// generate a globally unique ID rather than a per-request counter to prevent cross-request collisions in downstream registries.
|
||||
// A repeated supplier id would replay as two identical calls, so only the first occurrence keeps it.
|
||||
// A `null` supplier id normalizes to absent so the generated-id fallback applies.
|
||||
const supplied = part.functionCall.id ?? undefined
|
||||
const duplicate = supplied !== undefined && seenCallIds.has(supplied)
|
||||
if (supplied !== undefined) seenCallIds.add(supplied)
|
||||
const id = supplied !== undefined && !duplicate ? supplied : `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const id = part.functionCall.id ?? `tool_${crypto.randomUUID().replaceAll("-", "")}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
|
||||
}
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
@@ -654,8 +637,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
id,
|
||||
name: part.functionCall.name,
|
||||
input,
|
||||
providerMetadata:
|
||||
part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined,
|
||||
providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
|
||||
}),
|
||||
)
|
||||
hasToolCalls = true
|
||||
@@ -669,7 +651,6 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
lifecycle,
|
||||
reasoningSignature,
|
||||
textSignature,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
|
||||
@@ -8,4 +8,3 @@ export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenAIResponses from "./openai-responses.js"
|
||||
export * as OpenResponses from "./open-responses.js"
|
||||
export * as OpenResponsesChannel from "./open-responses-channel.js"
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from "../route/transport/index.js"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesContinuation } from "./open-responses-continuation.js"
|
||||
|
||||
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
|
||||
Schema.Record(Schema.String, Schema.Unknown),
|
||||
@@ -26,6 +25,11 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly driver?: (input: {
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
}) => WebSocketChannelDriver
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -112,8 +116,6 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
@@ -156,13 +158,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
url: yield* WebSocketTransport.toWebSocketUrl(options.url?.(parts.url) ?? parts.url),
|
||||
headers,
|
||||
rotateAfterMs: options.rotateAfterMs,
|
||||
driver: OpenResponsesContinuation.driver({
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
}),
|
||||
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
|
||||
}
|
||||
})
|
||||
: undefined
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
ProviderInternalReason,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
@@ -43,12 +42,8 @@ const OpenResponsesInputImage = Schema.Struct({
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
file_data: Schema.optional(Schema.String),
|
||||
file_url: Schema.optional(Schema.String),
|
||||
})
|
||||
const OpenResponsesInputVideo = Schema.Struct({
|
||||
type: Schema.tag("input_video"),
|
||||
video_url: Schema.String,
|
||||
file_data: Schema.String,
|
||||
mime_type: Schema.optional(Schema.String),
|
||||
})
|
||||
const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile])
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
@@ -59,14 +54,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -91,7 +81,6 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
|
||||
OpenResponsesInputText,
|
||||
OpenResponsesInputImage,
|
||||
OpenResponsesInputFile,
|
||||
OpenResponsesInputVideo,
|
||||
])
|
||||
|
||||
const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
@@ -248,7 +237,6 @@ const OpenResponsesErrorPayload = Schema.Struct({
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
@@ -313,6 +301,20 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
const RefusalEvent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.delta"),
|
||||
item_id: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.done"),
|
||||
item_id: Schema.String,
|
||||
refusal: Schema.String,
|
||||
}),
|
||||
])
|
||||
const isRefusalEvent = Schema.is(RefusalEvent)
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -321,6 +323,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -333,6 +336,7 @@ export interface ParserState {
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
@@ -362,7 +366,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
|
||||
type: "function" as const,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: inputSchema,
|
||||
parameters: ToolSchemaProjection.responses(inputSchema),
|
||||
// The common tool definition does not currently express Responses strict-schema policy.
|
||||
strict: false,
|
||||
}
|
||||
@@ -410,29 +414,26 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
}
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
return itemID(part.providerMetadata, providerMetadataKey)
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
target: "message" | "tool-result",
|
||||
) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
: undefined
|
||||
if (!media.mime.startsWith("image/")) {
|
||||
if (target === "tool-result" && media.mime.startsWith("video/"))
|
||||
return { type: "input_video" as const, video_url: url ?? media.dataUrl }
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
file_data: media.dataUrl,
|
||||
}
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
return { type: "input_image" as const, image_url: media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -441,17 +442,10 @@ const lowerUserContent = Effect.fnUntraced(function* (
|
||||
extension: Extension,
|
||||
) {
|
||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
|
||||
if (part.type === "media") return yield* lowerMedia(part, request, extension)
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
|
||||
})
|
||||
|
||||
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
|
||||
const lowered = yield* lowerMedia(part, request, extension, "message")
|
||||
if (lowered.type === "input_video")
|
||||
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
|
||||
return lowered
|
||||
})
|
||||
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
@@ -464,20 +458,6 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
"tool-result",
|
||||
)
|
||||
})
|
||||
|
||||
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
if (item.type === "text") return { type: "input_text" as const, text: item.text }
|
||||
return yield* lowerMessageMedia(
|
||||
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
|
||||
request,
|
||||
extension,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -530,7 +510,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -580,23 +560,17 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
if (store === false) {
|
||||
// The server is not storing this exchange, so the tool outcome has to
|
||||
// travel in the input. Non-content results degrade to their text form.
|
||||
const content: ReadonlyArray<Content> =
|
||||
part.result.type === "content"
|
||||
? part.result.value
|
||||
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
|
||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) =>
|
||||
lowerHostedToolResultContentItem(item, request, extension),
|
||||
),
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
})
|
||||
}
|
||||
if (id) hostedToolReferences.add(id)
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -633,7 +607,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
@@ -643,7 +616,7 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
|
||||
: {}),
|
||||
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.include ? { include: options.include } : {}),
|
||||
...(options.reasoningEffort || options.reasoningSummary
|
||||
? { reasoning: { effort: options.reasoningEffort, summary: options.reasoningSummary } }
|
||||
@@ -759,7 +732,7 @@ const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incompl
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta || !state.messageItems.has(id)) return [state, NO_EVENTS]
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
@@ -777,9 +750,10 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
if (!event.delta || !state.reasoningItems[itemID]) return [state, NO_EVENTS]
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const id = `${itemID}:${event.summary_index ?? 0}`
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -808,17 +782,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id) {
|
||||
const phase = messagePhase(item.phase)
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -857,9 +832,27 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
if (event.summary_index === 0) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
|
||||
if (event.summary_index === 0) {
|
||||
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:0`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
@@ -938,7 +931,7 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
) {
|
||||
if (!event.item_id || !event.delta || !state.tools[event.item_id]) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!event.item_id || !event.delta) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
state.id,
|
||||
state.tools,
|
||||
@@ -958,7 +951,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
@@ -1066,45 +1059,32 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// Build a single human-readable message from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
// to be indistinguishable from generic stream drops.
|
||||
const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code
|
||||
return message || code || fallback
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
// Keep the full raw payload on the error even when the message is a summary.
|
||||
const body = JSON.stringify(nested ?? event) ?? ""
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
const reason =
|
||||
event.type === "error" &&
|
||||
event.error === undefined &&
|
||||
event.response === undefined &&
|
||||
summary === undefined &&
|
||||
status === undefined
|
||||
? new ProviderInternalReason({ message })
|
||||
: classifyProviderFailure({ message, code, status, rawBody: body })
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
body,
|
||||
reason,
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1120,19 +1100,21 @@ export const step = (state: ParserState, event: Event) => {
|
||||
)
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string")
|
||||
return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
@@ -1146,10 +1128,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta")
|
||||
return event.item_id
|
||||
? onFunctionCallArgumentsDelta(state, event)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
@@ -1180,11 +1159,17 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -156,9 +156,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -207,16 +204,11 @@ const OpenAIChatDelta = Schema.StructWithRest(
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenAIChatChoice = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
// Moonshot streams usage on `choice.usage` instead of top-level `usage`.
|
||||
usage: optionalNull(OpenAIChatUsage),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const OpenAIChatChoice = Schema.Struct({
|
||||
delta: optionalNull(OpenAIChatDelta),
|
||||
finish_reason: optionalNull(Schema.String),
|
||||
native_finish_reason: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIChatError = Schema.Struct({
|
||||
code: optionalNull(Schema.Union([Schema.String, Schema.Number])),
|
||||
@@ -269,7 +261,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: inputSchema,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
})
|
||||
@@ -517,23 +509,11 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
// Anthropic via LiteLLM and Amazon Bedrock require `tools` to be present
|
||||
// whenever the conversation history contains tool calls/results. Send an
|
||||
// explicit empty array when we have history but no active tools.
|
||||
const hasToolHistory = (messages: ReadonlyArray<LLMRequest["messages"][number]>) => {
|
||||
for (const message of messages) {
|
||||
if (message.role === "tool") return true
|
||||
if (message.role === "assistant" && message.content.some((part) => part.type === "tool-call")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenAIOptions.resolve(request)
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.reasoningEffort ? { reasoning_effort: options.reasoningEffort } : {}),
|
||||
}
|
||||
}
|
||||
@@ -552,15 +532,12 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request, options),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
@@ -604,18 +581,11 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `AI.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached =
|
||||
(usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens ?? undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
@@ -721,11 +691,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}),
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const usage = mapUsage(event.usage) ?? state.usage
|
||||
const choice = event.choices?.[0]
|
||||
// Moonshot (and a few other OpenAI-compatible providers) attach usage to
|
||||
// `choice.usage` instead of the top-level `usage` field.
|
||||
const choiceUsage = (choice as unknown as { usage?: OpenAIChatEvent["usage"] })?.usage
|
||||
const usage = mapUsage(event.usage) ?? (choiceUsage ? mapUsage(choiceUsage) : undefined) ?? state.usage
|
||||
const rawFinishReason = choice?.finish_reason
|
||||
const finishReason =
|
||||
rawFinishReason !== undefined && rawFinishReason !== null
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { Effect, Option, Schema } from "effect"
|
||||
import * as ProviderShared from "./shared.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const PROTOCOL = "openai-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
@@ -161,4 +161,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
export const OpenAIResponsesChannel = { driver } as const
|
||||
@@ -5,13 +5,14 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenResponsesChannel, type Options } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -39,8 +40,20 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -54,6 +67,16 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
type: "input_file",
|
||||
filename: part.filename ?? "document.pdf",
|
||||
file_data: media.base64,
|
||||
mime_type: media.mime,
|
||||
}
|
||||
},
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
@@ -105,7 +128,46 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
|
||||
type HostedToolData = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies Record<string, { readonly name: string; readonly input: (item: HostedToolData) => unknown }>
|
||||
|
||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
||||
type HostedToolItem = HostedToolData & { readonly type: HostedToolType }
|
||||
|
||||
const isHostedToolItem = (item: OpenResponses.StreamItem): item is HostedToolItem =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: HostedToolItem) {
|
||||
const isError = item.error !== undefined && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
@@ -126,21 +188,32 @@ const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function*
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}), result: hostedToolResult },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function* (
|
||||
state: OpenResponses.ParserState,
|
||||
item: HostedToolItem,
|
||||
) {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
@@ -151,8 +224,8 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: 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)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
@@ -174,7 +247,11 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const channelTransport = OpenResponsesChannel.transport<OpenAIResponsesBody>
|
||||
export const channelTransport = (options: Omit<Options, "driver">) =>
|
||||
OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
...options,
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: options.id, name: options.name, ...input }),
|
||||
})
|
||||
export const transport = channelTransport({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
|
||||
@@ -24,17 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
|
||||
if (key === undefined) return undefined
|
||||
const chars = Array.from(key)
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming tool-call accumulator. Adapters that build a tool call across
|
||||
* multiple `tool-input-delta` chunks store the partial JSON input string here
|
||||
|
||||
@@ -28,11 +28,7 @@ export const ResponseIncludables = [
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number] | (string & {})
|
||||
export const ServiceTier = Schema.declare<ServiceTier>(
|
||||
(value): value is ServiceTier => typeof value === "string",
|
||||
{ title: "ServiceTier" },
|
||||
)
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
@@ -42,7 +38,7 @@ export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = ServiceTier
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export const AllowedTools = Schema.Struct({
|
||||
|
||||
@@ -9,8 +9,8 @@ export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = OpenResponsesOptions.ResponseIncludables
|
||||
export type OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludable
|
||||
export const OpenAIServiceTiers = [...OpenResponsesOptions.ServiceTiers, "scale"] as const
|
||||
export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] | (string & {})
|
||||
export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
|
||||
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
|
||||
|
||||
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMEvent, type AIError, type ToolResultPart } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { Lifecycle } from "./lifecycle.js"
|
||||
|
||||
export type Item = OpenResponses.StreamItem & {
|
||||
readonly id: string
|
||||
readonly status?: string
|
||||
readonly action?: unknown
|
||||
readonly queries?: unknown
|
||||
readonly results?: unknown
|
||||
readonly code?: string
|
||||
readonly container_id?: string
|
||||
readonly outputs?: unknown
|
||||
readonly server_label?: string
|
||||
readonly output?: unknown
|
||||
readonly result?: string
|
||||
readonly output_format?: "png" | "jpeg" | "webp"
|
||||
readonly error?: unknown
|
||||
}
|
||||
|
||||
export interface Definition {
|
||||
readonly name: string
|
||||
readonly input: (item: Item) => unknown
|
||||
readonly result?: (item: Item) => Effect.Effect<ToolResultPart["result"], AIError>
|
||||
}
|
||||
|
||||
export type Definitions = Readonly<Record<string, Definition>>
|
||||
|
||||
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,
|
||||
item: Item,
|
||||
tools: Definitions,
|
||||
) => Effect.Effect<OpenResponses.StepResult, AIError> = Effect.fn("ResponsesHostedTools.onDone")(function* (
|
||||
state,
|
||||
item,
|
||||
tools,
|
||||
) {
|
||||
const tool = tools[item.type]
|
||||
if (!tool) return [state, []] satisfies OpenResponses.StepResult
|
||||
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.toolCall({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
input: tool.input(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: tool.result
|
||||
? yield* tool.result(item)
|
||||
: item.error !== undefined && item.error !== null
|
||||
? { type: "error", value: item.error }
|
||||
: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, lifecycle }, events] satisfies OpenResponses.StepResult
|
||||
})
|
||||
|
||||
export * as ResponsesHostedTools from "./responses-hosted-tools.js"
|
||||
@@ -2,6 +2,20 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
||||
|
||||
const removeNullSchemas = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(removeNullSchemas)
|
||||
if (!isRecord(value)) return value
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => key !== "anyOf")
|
||||
.map(([key, field]) => [key, removeNullSchemas(field)]),
|
||||
)
|
||||
if (!Array.isArray(value.anyOf)) return fields
|
||||
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
|
||||
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
|
||||
return { ...fields, anyOf: variants }
|
||||
}
|
||||
|
||||
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
||||
const projected = items.map(moonshotNode)
|
||||
if (projected.length === 0) return {}
|
||||
@@ -31,7 +45,24 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
|
||||
return isRecord(projected) ? projected : {}
|
||||
}
|
||||
|
||||
const openAI = (schema: JsonSchema): JsonSchema => schema
|
||||
const openAI = (schema: JsonSchema): JsonSchema => {
|
||||
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
|
||||
const flattened =
|
||||
variants.length === 0
|
||||
? { ...schema, type: "object" }
|
||||
: {
|
||||
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
|
||||
type: "object",
|
||||
properties: variants.reduce(
|
||||
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
|
||||
{},
|
||||
),
|
||||
additionalProperties: false,
|
||||
}
|
||||
const normalized = removeNullSchemas(flattened)
|
||||
return isRecord(normalized) ? normalized : { type: "object" }
|
||||
}
|
||||
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
} satisfies OpenResponses.Extension
|
||||
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
} as const satisfies ResponsesHostedTools.Definitions
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: 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)
|
||||
return OpenResponses.step(state, event)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: OpenResponses.protocol.body,
|
||||
stream: {
|
||||
event: OpenResponses.protocol.stream.event,
|
||||
initial: (request) => OpenResponses.initial(request, extension),
|
||||
step,
|
||||
terminal: OpenResponses.terminal,
|
||||
},
|
||||
})
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
@@ -80,9 +80,6 @@ export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||
// that the summary message does not carry. Not shown to users.
|
||||
readonly rawBody?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
@@ -92,13 +89,11 @@ export interface ProviderFailure {
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const body = input.http?.body ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
const text = body || input.message
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
|
||||
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, contents, labels }
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { OpenResponsesOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
export type OpenAIOptionsInput = OpenResponsesOptionsInput
|
||||
|
||||
export type OpenAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
|
||||
export const profile = OpenAICompatibleProfiles.profiles.openrouter
|
||||
export const id = ProviderID.make(profile.provider)
|
||||
@@ -115,12 +115,11 @@ export const protocol = Protocol.make({
|
||||
reasoning_details: reasoningDetails,
|
||||
}
|
||||
})
|
||||
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions),
|
||||
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -5,8 +5,7 @@ import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
@@ -35,9 +34,9 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: OpenAICompatibleProfiles.profiles.xai.baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
transport: OpenAIResponses.channelTransport({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
|
||||
@@ -153,9 +153,6 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
// Raw provider payload as a string, so classified failures never lose the
|
||||
// original error detail even when the pretty message is a summary.
|
||||
body: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
|
||||
@@ -255,7 +255,6 @@ export namespace ToolDefinition {
|
||||
export class ToolChoice extends Schema.Class<ToolChoice>("LLM.ToolChoice")({
|
||||
type: Schema.Literals(["auto", "none", "required", "tool"]),
|
||||
name: Schema.optional(Schema.String),
|
||||
disableParallelToolUse: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
export namespace ToolChoice {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:azure",
|
||||
"provider:azure"
|
||||
],
|
||||
"name": "azure/chat-streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:53.198Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiden-azury-group.openai.azure.com/openai/v1/chat/completions?api-version=v1",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.6-luna\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"medium\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"choices\":[],\"created\":0,\"id\":\"\",\"model\":\"\",\"object\":\"\",\"prompt_filter_results\":[{\"prompt_index\":0,\"content_filter_results\":{}}]}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"\",\"refusal\":null,\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"Mxr\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{\"content\":\"hello\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[{\"content_filter_results\":{},\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"WyZa5AY1CaCeFdS\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1787505712,\"id\":\"chatcmpl-EG6BEiYSfrcTSI2WX8PqNzERZDcPc\",\"latency_checkpoint\":{\"engine_tbt_ms\":20,\"engine_ttft_ms\":106,\"engine_ttlt_ms\":206,\"pre_inference_ms\":89,\"service_tbt_ms\":20,\"service_ttft_ms\":480,\"service_ttlt_ms\":576,\"total_duration_ms\":491,\"user_visible_ttft_ms\":391},\"model\":\"gpt-5.6-luna-2026-07-09\",\"obfuscation\":\"6\",\"object\":\"chat.completion.chunk\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"usage\":{\"completion_tokens\":5,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"audio_tokens\":0,\"reasoning_tokens\":0,\"rejected_prediction_tokens\":0},\"prompt_tokens\":13,\"prompt_tokens_details\":{\"audio_tokens\":0,\"cache_write_tokens\":0,\"cached_tokens\":0},\"total_tokens\":18}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
-31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris? Use the lookup_weather tool.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3.5-flash:streamGenerateContent?alt=sse",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Reply with exactly one word: hello\"}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -106,20 +106,3 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
|
||||
})
|
||||
expect(reason._tag).toBe("InvalidRequest")
|
||||
expect(reason).toMatchObject({ classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("extracts nested codes from the raw payload", () => {
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,4 +4,10 @@ import { GoogleVertexChat } from "../../src/providers.js"
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "priority" } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { serviceTier: "premium" },
|
||||
})
|
||||
|
||||
@@ -8,8 +8,6 @@ LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffo
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "scale" } })
|
||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { serviceTier: "future-tier" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
|
||||
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { Azure } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const resourceName = process.env.AZURE_OPENAI_RESOURCE_NAME ?? "aiden-azury-group"
|
||||
|
||||
const chatModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).chat("gpt-5.6-luna")
|
||||
|
||||
const responsesModel = Azure.configure({
|
||||
resourceName,
|
||||
apiKey: process.env.AZURE_OPENAI_API_KEY ?? "fixture",
|
||||
}).responses("gpt-5.6-luna")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "azure",
|
||||
provider: "azure",
|
||||
requires: ["AZURE_OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Azure OpenAI recorded", () => {
|
||||
recorded.effect("chat streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: chatModel, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: responsesModel, prompt: "Reply with exactly one word: bonjour" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("bonjour")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("responses continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: responsesModel,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -156,13 +156,14 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
|
||||
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -200,8 +201,8 @@ describe("Gemini route", () => {
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { name: "lookup", args: { query: "time" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: undefined, name: "lookup", args: { query: "time" } } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,12 +210,14 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "noon" },
|
||||
},
|
||||
@@ -225,104 +228,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers function call ids for gemini 3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits function call ids entirely for pre-gemini-3 models", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
]
|
||||
const legacy = yield* compileRequest(LLM.request({ model, messages }))
|
||||
const older = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-1.5-flash" }),
|
||||
messages,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(legacy.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
|
||||
},
|
||||
])
|
||||
expect(JSON.stringify(legacy.body.contents)).not.toContain('"id"')
|
||||
expect(JSON.stringify(older.body.contents)).not.toContain('"id"')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("includes function call ids for non-gemini model ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemma-3-27b-it" }),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ functionResponse: { id: "call_1", name: "lookup", response: { name: "lookup", content: "done" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -514,16 +419,13 @@ describe("Gemini route", () => {
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "call_image", name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" },
|
||||
],
|
||||
parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } }, thoughtSignature: "sig_1" }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "call_image",
|
||||
name: "read",
|
||||
response: { name: "read", content: "Image read successfully" },
|
||||
parts: [{ inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
|
||||
@@ -947,7 +849,7 @@ describe("Gemini route", () => {
|
||||
})
|
||||
expect(toolCall).toMatchObject({
|
||||
id: "provider_call",
|
||||
providerMetadata: { google: { thoughtSignature: "tool_sig" } },
|
||||
providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
|
||||
})
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
@@ -955,7 +857,7 @@ describe("Gemini route", () => {
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
@@ -971,6 +873,7 @@ describe("Gemini route", () => {
|
||||
name: "lookup",
|
||||
result: "done",
|
||||
resultType: "text",
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -1074,7 +977,7 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1084,7 +987,7 @@ describe("Gemini route", () => {
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "tool_0",
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
@@ -1120,15 +1023,15 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_2", name: "lookup", args: { query: "sports" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
@@ -1156,11 +1059,11 @@ describe("Gemini route", () => {
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: "tool_1", name: "lookup", args: { query: "news" } },
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
@@ -1311,6 +1214,7 @@ describe("Gemini route", () => {
|
||||
id: "call_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "call_0" } },
|
||||
})
|
||||
expect(response.toolCalls[1]).toMatchObject({
|
||||
type: "tool-call",
|
||||
@@ -1326,37 +1230,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces repeated supplier ids with fresh fallback ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "weather" } } },
|
||||
{ functionCall: { id: "dup_call", name: "lookup", args: { query: "news" } } },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls[0]).toMatchObject({
|
||||
id: "dup_call",
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(response.toolCalls[1].id).toMatch(/^tool_[0-9a-zA-Z]+$/)
|
||||
expect(response.toolCalls[1].id).not.toBe(response.toolCalls[0].id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assigns distinct unique fallback ids across separate requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
@@ -1492,73 +1365,6 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives explicit null usage counts", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: [{ content: { role: "model", parts: [{ text: "Hi" }] } }] },
|
||||
{ usageMetadata: { promptTokenCount: null, candidatesTokenCount: 5 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(response.usage).toMatchObject({ outputTokens: 5, totalTokens: 5 })
|
||||
expect(response.usage?.inputTokens).toBeUndefined()
|
||||
expect(response.usage?.nonCachedInputTokens).toBeUndefined()
|
||||
expect(response.usage?.cacheReadInputTokens).toBeUndefined()
|
||||
expect(response.usage?.reasoningTokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("survives null candidates, content, parts, and finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ candidates: null },
|
||||
{ candidates: [{ content: { role: "model", parts: null } }] },
|
||||
{ candidates: [{ content: null, finishReason: null }] },
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Hello" }] }, finishReason: "STOP" as const },
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null thought flag on a text part as visible output", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "Visible", thought: null }] }, finishReason: "STOP" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const reasoningStart = response.events.find((event) => event.type === "reasoning-start")
|
||||
|
||||
expect(reasoningStart).toBeUndefined()
|
||||
expect(response.reasoning ?? "").toBe("")
|
||||
expect(response.text).toBe("Visible")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails invalid stream events", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
|
||||
import { LLM, Message, ToolDefinition, ToolCallPart } from "../../src/index.js"
|
||||
import { GoogleVertex } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = GoogleVertex.configure({
|
||||
apiKey: process.env.GOOGLE_VERTEX_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.5-flash")
|
||||
|
||||
const lookupWeather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-vertex",
|
||||
provider: "google-vertex",
|
||||
protocol: "gemini",
|
||||
requires: ["GOOGLE_VERTEX_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Vertex Gemini recorded", () => {
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Reply with exactly one word: hello" }),
|
||||
)
|
||||
|
||||
expect(response.text.toLowerCase()).toContain("hello")
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("calls a tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "What is the weather in Paris? Use the lookup_weather tool.",
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
const call = response.toolCalls.find((part) => part.name === "lookup_weather")
|
||||
expect(call).toBeDefined()
|
||||
expect(call?.input).toMatchObject({ city: "Paris" })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("continues after a tool result", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("What is the weather in Paris?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_paris_1", name: "lookup_weather", input: { city: "Paris" } }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_paris_1",
|
||||
name: "lookup_weather",
|
||||
result: "18C, light rain",
|
||||
resultType: "text",
|
||||
}),
|
||||
],
|
||||
tools: [lookupWeather],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -75,53 +75,6 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
}).model("gemini-3.5-flash"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
result: "sunny",
|
||||
resultType: "text",
|
||||
providerMetadata: { google: { functionCallId: "provider_call_1" } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||
expect(prepared.body.contents).toMatchObject([
|
||||
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
@@ -237,7 +190,6 @@ describe("Google Vertex providers", () => {
|
||||
})
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello." },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
|
||||
@@ -342,7 +342,6 @@ describe("OpenAI Chat route", () => {
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
|
||||
],
|
||||
tools: [],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
@@ -78,45 +78,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses data URLs for embedded PDF messages and tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const pdf = "data:application/pdf;base64,JVBERi0xLjQ="
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([{ type: "media", mediaType: "application/pdf", data: pdf, filename: "input.pdf" }]),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "read", input: {} }),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: pdf, mime: "application/pdf", name: "result.pdf" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "input.pdf", file_data: pdf }],
|
||||
},
|
||||
{ type: "function_call", call_id: "call_1", name: "read", arguments: "{}" },
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: [{ type: "input_file", filename: "result.pdf", file_data: pdf }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -132,7 +93,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -152,14 +113,7 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
],
|
||||
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -233,7 +187,6 @@ describe("Open Responses-compatible route", () => {
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
serviceTier: "provider-tier",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
@@ -258,7 +211,6 @@ describe("Open Responses-compatible route", () => {
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
service_tier: "provider-tier",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
|
||||
@@ -28,7 +28,7 @@ import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -68,7 +68,7 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
return OpenAIResponsesChannel.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
@@ -188,15 +188,17 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes through provider-defined service tiers", () =>
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { serviceTier: "scale" } }))
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { serviceTier: "unsupported" } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.service_tier).toBe("scale")
|
||||
expect(prepared.body).not.toHaveProperty("service_tier")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves function schemas", () =>
|
||||
it.effect("flattens top-level object unions in function schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
@@ -236,22 +238,13 @@ describe("OpenAI Responses route", () => {
|
||||
strict: false,
|
||||
parameters: {
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
limit: { type: "integer", maximum: 2000 },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
|
||||
required: ["resource"],
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
reference: { type: "string" },
|
||||
limit: { type: "integer", maximum: 2000 },
|
||||
resource: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -321,10 +314,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_1" },
|
||||
}),
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
@@ -405,38 +394,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: { id: "resp_alive", usage: { input_tokens: 1, output_tokens: 1 } },
|
||||
}),
|
||||
]),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
)
|
||||
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
|
||||
"gpt-4.1-mini",
|
||||
)
|
||||
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "hi" }), { webSocket }).pipe(
|
||||
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||
)
|
||||
expect(response.finishReason?.normalized).toBe("stop")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call with only the new tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
@@ -1348,7 +1305,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses standard inline file encoding for xAI PDF tool results", () =>
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1376,7 +1333,8 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1406,60 +1364,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers remote tool-result media URLs without base64 wrapping", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "fetch",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "file", uri: "https://example.com/image.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/report.pdf", mime: "application/pdf", name: "report.pdf" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers tool-result videos as input_video", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "record", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "record",
|
||||
resultType: "content",
|
||||
result: [
|
||||
{ type: "file", uri: "data:video/mp4;base64,AAECAw==", mime: "video/mp4" },
|
||||
{ type: "file", uri: "https://example.com/demo.mp4", mime: "video/mp4" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_video", video_url: "data:video/mp4;base64,AAECAw==" },
|
||||
{ type: "input_video", video_url: "https://example.com/demo.mp4" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares the composed native continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1669,7 +1573,6 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("parses text and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
|
||||
{
|
||||
@@ -1931,67 +1834,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores deltas without a matching output item", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_missing", delta: "orphaned text" },
|
||||
{ type: "response.refusal.delta", item_id: "refusal_missing", delta: "orphaned refusal" },
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_missing",
|
||||
summary_index: 0,
|
||||
delta: "orphaned reasoning",
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_part.added",
|
||||
item_id: "rs_still_missing",
|
||||
summary_index: 0,
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_still_missing",
|
||||
summary_index: 0,
|
||||
delta: "still orphaned reasoning",
|
||||
},
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
item_id: "fc_missing",
|
||||
delta: '{"orphaned":true}',
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("")
|
||||
expect(response.message.content).toEqual([])
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects function argument deltas without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.function_call_arguments.delta", delta: "{}" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.function_call_arguments.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
@@ -2047,10 +1889,8 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ 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_2" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
@@ -2061,10 +1901,10 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "First", providerMetadata: undefined },
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1", providerMetadata: undefined },
|
||||
{ type: "text-start", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second", providerMetadata: undefined },
|
||||
{ type: "text-delta", id: "msg_2", text: "Second" },
|
||||
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||
])
|
||||
}),
|
||||
@@ -2073,9 +1913,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("parses reasoning summary stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
@@ -2087,22 +1925,18 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "rs_1:0" },
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "thinking" },
|
||||
{ type: "reasoning-start", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
@@ -2114,7 +1948,6 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
@@ -2134,7 +1967,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
id: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
}),
|
||||
)
|
||||
@@ -2275,7 +2108,6 @@ describe("OpenAI Responses route", () => {
|
||||
})
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
@@ -2387,44 +2219,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted tool results with their text form", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Search."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: false },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||
Effect.gen(function* () {
|
||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||
@@ -2805,47 +2599,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes computer_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "computer_call",
|
||||
id: "computer_1",
|
||||
call_id: "call_1",
|
||||
status: "completed",
|
||||
action: { type: "click", x: 100, y: 200 },
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-call" || event.type === "tool-result")).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
input: { type: "click", x: 100, y: 200 },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "computer_1",
|
||||
name: "computer_use",
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "computer_1" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
@@ -2967,7 +2720,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses standard inline file encoding for xAI user PDFs", () =>
|
||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -2990,7 +2743,8 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "report.pdf",
|
||||
file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
|
||||
file_data: "JVBERi0xLjQ=",
|
||||
mime_type: "application/pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -3023,37 +2777,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers remote user media URLs without base64 wrapping", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "https://example.com/report.pdf",
|
||||
filename: "report.pdf",
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_url: "https://example.com/report.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed rate limit for provider error frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
@@ -3210,42 +2933,36 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the raw payload when error is null", () =>
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies a detail-free error event as a transient provider failure", () =>
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", sequence_number: 2 }))),
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const targets: ReadonlyArray<{
|
||||
id: "xai",
|
||||
name: "xAI Grok 4.5",
|
||||
provider: "xai",
|
||||
protocol: "xai-responses",
|
||||
protocol: "openai-responses",
|
||||
requires: "XAI_API_KEY",
|
||||
filename: "verification.pdf",
|
||||
maxTokens: 40,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("extends the Open Responses baseline directly", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
expect(prepared.protocol).toBe("xai-responses")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI reasoning text events", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "reasoning_1" },
|
||||
},
|
||||
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
|
||||
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
|
||||
type: "reasoning",
|
||||
text: "Considering.",
|
||||
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "response_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "x_search_1",
|
||||
name: "x_search",
|
||||
input: { query: "news" },
|
||||
providerExecuted: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -191,7 +191,7 @@ describe("LLMClient tools", () => {
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("hello"),
|
||||
})
|
||||
const providerMetadata = { google: { thoughtSignature: "provider_sig" } }
|
||||
const providerMetadata = { google: { functionCallId: "provider_call" } }
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{ tool },
|
||||
LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
|
||||
|
||||
@@ -50,7 +50,32 @@ describe("tool schema projections", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("applies model compatibility without changing schema semantics", () =>
|
||||
test("openai keeps one flat object top-level schema", () => {
|
||||
expect(
|
||||
ToolSchemaProjection.openAI({
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
{ type: "object", properties: { resource: { type: "string" } } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
maybe: { type: "string" },
|
||||
resource: { type: "string" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("applies model compatibility before protocol projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
@@ -82,15 +107,11 @@ describe("tool schema projections", () => {
|
||||
|
||||
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
|
||||
linked: { $ref: "#/$defs/Linked" },
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
|
||||
linked: { $ref: "#/$defs/Linked" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,12 +19,6 @@
|
||||
|
||||
- Always prefer `createStore` over multiple `createSignal` calls
|
||||
|
||||
## Typography
|
||||
|
||||
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
|
||||
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
|
||||
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
|
||||
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
|
||||
|
||||
@@ -259,22 +259,6 @@ export function event(
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function compactionStarted(data: Extract<OpenCodeEvent, { type: "session.compaction.started" }>["data"]) {
|
||||
return makeEvent("session.compaction.started", data)
|
||||
}
|
||||
|
||||
export function compactionDelta(data: Extract<OpenCodeEvent, { type: "session.compaction.delta" }>["data"]) {
|
||||
return makeEvent("session.compaction.delta", data)
|
||||
}
|
||||
|
||||
export function compactionEnded(data: Extract<OpenCodeEvent, { type: "session.compaction.ended" }>["data"]) {
|
||||
return makeEvent("session.compaction.ended", data)
|
||||
}
|
||||
|
||||
export function compactionFailed(data: Extract<OpenCodeEvent, { type: "session.compaction.failed" }>["data"]) {
|
||||
return makeEvent("session.compaction.failed", data)
|
||||
}
|
||||
|
||||
export function toolInputStarted(data: Extract<OpenCodeEvent, { type: "session.tool.input.started" }>["data"]) {
|
||||
return makeEvent("session.tool.input.started", data)
|
||||
}
|
||||
@@ -899,26 +883,8 @@ function provider() {
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
{
|
||||
id: "company-gateway",
|
||||
name: "Company Gateway",
|
||||
models: {
|
||||
"fast-nano": {
|
||||
id: "fast-nano",
|
||||
api: { id: "openai/gpt-5.4-nano" },
|
||||
name: "GPT-5.4 nano",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
"long-context": {
|
||||
id: "long-context",
|
||||
api: { id: "company/long-context" },
|
||||
name: "Company Gateway Extra Long Context Model for Narrow Timeline Layouts",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode", "company-gateway"],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,37 +53,6 @@ benchmark.describe("performance: first navigation paint", () => {
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page, { draftID })
|
||||
await page.goto("/")
|
||||
|
||||
const draftHref = stressDraftHref(draftID)
|
||||
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
|
||||
await expect(draftTab).toHaveCount(1)
|
||||
await draftTab.click()
|
||||
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
|
||||
await expect(sessionTab).toHaveCount(1)
|
||||
const result = await measureFirstNavigation(page, {
|
||||
href,
|
||||
destinationPath: href,
|
||||
sourceSelector: '[data-component="new-session"]',
|
||||
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
|
||||
contentSelector,
|
||||
navigate: async () => {
|
||||
await sessionTab.click()
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
},
|
||||
})
|
||||
report(result)
|
||||
expect(result.summary.blankSamples).toBe(0)
|
||||
expect(result.summary.unknownSamples).toBe(0)
|
||||
})
|
||||
|
||||
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
|
||||
await setup(page)
|
||||
const href = stressSessionHref(fixture.childID)
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("renames, exports, and deletes a home session from its context menu", async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await page.route("**/api/session/*/rename", async (route) => {
|
||||
const sessionID = new URL(route.request().url()).pathname.split("/").at(-2)
|
||||
const session = sessions.find((item) => item.id === sessionID)
|
||||
const payload: unknown = route.request().postDataJSON()
|
||||
if (!payload || typeof payload !== "object" || !("title" in payload) || typeof payload.title !== "string")
|
||||
throw new Error("Invalid rename payload")
|
||||
if (session) session.title = payload.title
|
||||
await route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
const container = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
|
||||
const titleBox = await container.locator('[data-component="home-session-title"]').boundingBox()
|
||||
const avatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
await expect(container.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||
|
||||
await row.focus()
|
||||
await row.press("Shift+F10")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeHidden()
|
||||
await expect(row).toBeFocused()
|
||||
|
||||
const rowBox = await row.boundingBox()
|
||||
await row.click({ button: "right", position: { x: 48, y: 12 } })
|
||||
await expect(page).toHaveURL("/")
|
||||
await expect(page.getByRole("menuitem", { name: "Rename" })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Export..." })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete..." })).toBeVisible()
|
||||
const menuBox = await page.locator('[data-component="menu-v2-content"]').boundingBox()
|
||||
expect(Math.abs((menuBox?.x ?? 0) - (rowBox?.x ?? 0) - 48)).toBeLessThan(4)
|
||||
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click()
|
||||
const title = page.locator('[data-component="home-session-rename"]')
|
||||
await expect(title).toBeFocused()
|
||||
await expect(title).toHaveValue(fixture.expected.targetTitle)
|
||||
const editorBox = await title.boundingBox()
|
||||
const editingAvatarBox = await container.locator('[data-component="project-avatar-v2"]').boundingBox()
|
||||
expect(editorBox?.x).toBe(titleBox?.x)
|
||||
expect(editingAvatarBox).toEqual(avatarBox)
|
||||
expect(
|
||||
await title.evaluate((element) => ({
|
||||
outline: getComputedStyle(element).outlineStyle,
|
||||
shadow: getComputedStyle(element).boxShadow,
|
||||
})),
|
||||
).toEqual({ outline: "none", shadow: "none" })
|
||||
expect(await container.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe("none")
|
||||
await title.fill("Renamed from Home")
|
||||
const renamed = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname.endsWith("/rename"),
|
||||
)
|
||||
await title.press("Enter")
|
||||
expect((await renamed).postDataJSON()).toEqual({ title: "Renamed from Home" })
|
||||
let renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${fixture.targetID}$`))
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] a').filter({ hasText: "Renamed from Home" })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home" }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
renamedRow = page.locator('[data-component="home-session-row"]').filter({ hasText: "Renamed from Home" })
|
||||
await expect(renamedRow).toBeVisible()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
const download = page.waitForEvent("download")
|
||||
const exportItem = page.getByRole("menuitem", { name: "Export..." })
|
||||
await exportItem.click()
|
||||
expect((await download).suggestedFilename()).toBe("renamed-from-home.json")
|
||||
await expect(exportItem).toBeHidden()
|
||||
|
||||
await renamedRow.click({ button: "right" })
|
||||
await page.getByRole("menuitem", { name: "Delete..." }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText('Delete session "Renamed from Home"?')
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname.endsWith(`/${fixture.targetID}`),
|
||||
)
|
||||
await dialog.getByRole("button", { name: "Delete session" }).click()
|
||||
await removed
|
||||
await expect(renamedRow).toBeHidden()
|
||||
})
|
||||
@@ -45,17 +45,21 @@ test("opens the comment editor for a line number range", async ({ page }) => {
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
|
||||
})
|
||||
|
||||
test("shows a comment button when a diff line is hovered", async ({ page }) => {
|
||||
test("shows a comment button when a line number is hovered", async ({ page }) => {
|
||||
const review = page.locator('[data-component="session-review"]')
|
||||
const line = review.getByText("export const first = 1", { exact: true })
|
||||
await expectAppVisible(line)
|
||||
const lineNumber = review.locator('[data-column-number="1"]').last()
|
||||
await expectAppVisible(lineNumber)
|
||||
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
|
||||
await expect(comment).toHaveCount(1)
|
||||
await line.dispatchEvent("pointermove", { pointerType: "mouse", bubbles: true, composed: true })
|
||||
await expect(comment).toBeVisible()
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.dispatchEvent("click")
|
||||
const comment = review.getByRole("button", { name: "Comment", exact: true })
|
||||
await expect(async () => {
|
||||
await lineNumber.hover()
|
||||
await expect(lineNumber).toHaveAttribute("data-hovered", "")
|
||||
await expect(comment).toHaveCount(1)
|
||||
await expect(comment).toHaveCSS("pointer-events", "auto")
|
||||
await comment.focus()
|
||||
await expect(comment).toBeFocused()
|
||||
}).toPass({ timeout: 10_000 })
|
||||
await comment.press("Enter")
|
||||
await expect(review.getByRole("textbox")).toBeVisible()
|
||||
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
|
||||
})
|
||||
|
||||
@@ -179,7 +179,6 @@ async function expectMountedTree(page: Page, total: number) {
|
||||
}
|
||||
|
||||
async function expectSideGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!.getBoundingClientRect()
|
||||
@@ -189,20 +188,15 @@ async function expectSideGeometry(page: Page) {
|
||||
terminalLeft: terminal.left,
|
||||
terminalRight: terminal.right,
|
||||
terminalTop: terminal.top,
|
||||
terminalBottom: terminal.bottom,
|
||||
reviewTop: review.top,
|
||||
reviewBottom: review.bottom,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1)
|
||||
expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7)
|
||||
expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectBottomGeometry(page: Page) {
|
||||
await expectPanelGap(page, 8)
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
@@ -232,30 +226,6 @@ async function expectBottomGeometry(page: Page) {
|
||||
expect(geometry.sidebar).toBeGreaterThanOrEqual(240)
|
||||
}
|
||||
|
||||
async function expectPanelGap(page: Page, expected: number) {
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
const gap = terminal.top - review.bottom
|
||||
return gap
|
||||
})
|
||||
})
|
||||
.toBeGreaterThanOrEqual(expected - 1)
|
||||
await expect
|
||||
.poll(() => {
|
||||
return page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
})
|
||||
})
|
||||
.toBeLessThanOrEqual(expected + 1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
expect(siblingProbe).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
@@ -82,9 +82,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const id = "prt_file_projection_anchored_patch"
|
||||
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, {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage([userText("Preceding context ".repeat(120))]),
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
@@ -105,6 +105,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
|
||||
]),
|
||||
],
|
||||
viewport: { width: 1200, height: 600 },
|
||||
@@ -112,21 +113,7 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
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
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(1)
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
|
||||
})
|
||||
@@ -134,28 +121,11 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(0.5)
|
||||
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, -20)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
|
||||
.toBeGreaterThan(0)
|
||||
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
|
||||
await trigger.dispatchEvent("pointerdown")
|
||||
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
|
||||
await trigger.click()
|
||||
await trigger.dispatchEvent("click")
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element, collapsed) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
const allocatedHeight = element.getBoundingClientRect().height
|
||||
return {
|
||||
grew: allocatedHeight > collapsed + 1,
|
||||
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
|
||||
}
|
||||
}, collapsedHeight),
|
||||
)
|
||||
.toEqual({ grew: true, measured: true })
|
||||
await expect
|
||||
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
|
||||
.toBeLessThanOrEqual(5)
|
||||
@@ -163,10 +133,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
|
||||
await scroller.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
|
||||
.toBeGreaterThan(50)
|
||||
await timeline.settle(40)
|
||||
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
|
||||
expect(scrolled).toBeGreaterThan(50)
|
||||
expect(scrolled).toBeLessThan(400)
|
||||
|
||||
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
|
||||
@@ -181,16 +150,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
|
||||
|
||||
await trigger.click()
|
||||
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
row.evaluate((element) => {
|
||||
const measured = element.querySelector<HTMLElement>("[data-index]")
|
||||
return measured
|
||||
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
|
||||
: Number.POSITIVE_INFINITY
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 20
|
||||
const messagePageSize = 200
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
compactionDelta,
|
||||
compactionEnded,
|
||||
compactionFailed,
|
||||
compactionStarted,
|
||||
event,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { event, session, sessionID, setupTimeline, toolPart } from "../performance/timeline-stability/fixture"
|
||||
|
||||
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
|
||||
|
||||
@@ -82,102 +73,23 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
|
||||
test("renders a compaction summary while it streams and after completion", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(
|
||||
compactionStarted({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
|
||||
const compaction = page.locator('[data-component="session-compaction-message"]')
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
|
||||
await timeline.send(
|
||||
compactionDelta({
|
||||
sessionID,
|
||||
text: "## Checkpoint\n\nStreamed implementation details.",
|
||||
}),
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
|
||||
await timeline.send(
|
||||
compactionEnded({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "## Checkpoint\n\nFinal implementation details.",
|
||||
recent: "",
|
||||
}),
|
||||
)
|
||||
await expect(compaction).toContainText("Final implementation details.")
|
||||
await expect(compaction).not.toContainText("Streamed implementation details.")
|
||||
})
|
||||
|
||||
test("updates running compactions to failed and cancelled boundaries", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { sessionMessages: [user, assistant(true)] })
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "auto", recent: "" }))
|
||||
await timeline.send(compactionDelta({ sessionID, text: "Partial summary that should be discarded." }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: {
|
||||
type: "compaction.failed",
|
||||
message: 'Error: {"error":{"type":"ProviderError","message":"The provider rejected the summary."}}',
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const compactions = page.locator('[data-component="session-compaction-message"]')
|
||||
const failed = compactions.filter({ hasText: "The provider rejected the summary." })
|
||||
await expect(failed.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(failed.getByText("ProviderError: The provider rejected the summary.", { exact: true })).toBeVisible()
|
||||
await expect(failed).not.toContainText("Partial summary that should be discarded.")
|
||||
|
||||
await timeline.send(compactionStarted({ sessionID, reason: "manual", recent: "" }))
|
||||
await timeline.send(
|
||||
compactionFailed({
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancellation detail should stay hidden." },
|
||||
}),
|
||||
)
|
||||
|
||||
await expect(compactions).toHaveCount(2)
|
||||
const cancelled = compactions.filter({ hasNotText: "The provider rejected the summary." })
|
||||
await expect(cancelled.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(cancelled).not.toContainText("Cancellation detail should stay hidden.")
|
||||
})
|
||||
|
||||
test("shows a delegating row while subagent input streams", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
...assistant(false),
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_subagent",
|
||||
name: "subagent",
|
||||
state: { status: "streaming", input: "" },
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
content: [toolPart("call_subagent", "subagent", "streaming", {})],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const delegating = page.locator('[data-component="task-tool-delegating"]')
|
||||
await expect(delegating).toBeVisible()
|
||||
const shimmer = delegating.locator('[data-component="text-shimmer"]')
|
||||
await expect(shimmer).toHaveAttribute("aria-label", "Delegating agent...")
|
||||
await expect(shimmer).toHaveCSS("line-height", "16px")
|
||||
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Delegating agent...",
|
||||
)
|
||||
const icon = delegating.locator('[data-slot="icon-svg"]')
|
||||
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
|
||||
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
|
||||
@@ -215,11 +127,11 @@ test("renders the moved location notice in its compact timeline style", async ({
|
||||
await expect(notice).toHaveCSS("padding-bottom", "4px")
|
||||
await expect(label).toHaveCSS("font-size", "13px")
|
||||
await expect(label).toHaveCSS("font-weight", "530")
|
||||
await expect(label).toHaveCSS("line-height", "16px")
|
||||
await expect(label).toHaveCSS("line-height", "13px")
|
||||
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("font-size", "13px")
|
||||
await expect(value).toHaveCSS("font-weight", "440")
|
||||
await expect(value).toHaveCSS("line-height", "16px")
|
||||
await expect(value).toHaveCSS("line-height", "13px")
|
||||
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
|
||||
await expect(value).toHaveCSS("text-overflow", "ellipsis")
|
||||
await expect(value).toHaveCSS("white-space", "nowrap")
|
||||
|
||||
@@ -250,43 +250,6 @@ test.describe("session timeline projection", () => {
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders aliased and long custom model notices", async ({ page }) => {
|
||||
const shortName = "GPT-5.4 nano"
|
||||
const longName = "Company Gateway Extra Long Context Model for Narrow Timeline Layouts"
|
||||
await setupTimeline(page, {
|
||||
viewport: { width: 420, height: 700 },
|
||||
sessionMessages: [
|
||||
{
|
||||
id: "msg_model_fast_nano",
|
||||
type: "model-switched",
|
||||
time: { created: 1700000000000 },
|
||||
model: { providerID: "company-gateway", id: "fast-nano", variant: "xhigh" },
|
||||
},
|
||||
{
|
||||
id: "msg_model_long_context",
|
||||
type: "model-switched",
|
||||
time: { created: 1700000001000 },
|
||||
model: { providerID: "company-gateway", id: "long-context" },
|
||||
},
|
||||
userMessage(),
|
||||
assistantMessage(),
|
||||
],
|
||||
})
|
||||
|
||||
const shortNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: shortName })
|
||||
const longNotice = page.locator('[data-slot="session-timeline-notice"]').filter({ hasText: longName })
|
||||
await expect(shortNotice).toBeVisible()
|
||||
await expect(shortNotice.getByText(`Switched to ${shortName}`, { exact: true })).toBeVisible()
|
||||
await expect(shortNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveText("xhigh")
|
||||
await expect(page.getByText("fast-nano", { exact: true })).toHaveCount(0)
|
||||
await expect(shortNotice.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(longNotice).toBeVisible()
|
||||
await expect(longNotice.locator('[data-component="provider-icon"]')).toBeVisible()
|
||||
await expect(longNotice.locator('[data-slot="session-timeline-notice-variant"]')).toHaveCount(0)
|
||||
await expect(longNotice.locator("[title]")).toHaveAttribute("title", `Switched to ${longName}`)
|
||||
await expect.poll(() => longNotice.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
|
||||
@@ -98,9 +98,9 @@ test("labels completed searches with result counts", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
await expect(rows.filter({ hasText: "Glob" })).toContainText("(1 match)")
|
||||
await expect(rows.filter({ hasText: "Grep" })).toContainText("(12 matches)")
|
||||
const rows = group.locator('[data-component="tool-trigger"]')
|
||||
await expect(rows.nth(0)).toContainText("(1 match)")
|
||||
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||
})
|
||||
|
||||
test("labels read tools from their path input", async ({ page }) => {
|
||||
@@ -111,11 +111,7 @@ test("labels read tools from their path input", async ({ page }) => {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(
|
||||
group
|
||||
.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
|
||||
.filter({ hasText: "Read" }),
|
||||
).toContainText("a.ts")
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
|
||||
})
|
||||
|
||||
test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
@@ -125,20 +121,19 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(pending, "skill", "running", { id: "frontend-design" }),
|
||||
toolPart(pending, "skill", "running", { id: "sample-skill" }),
|
||||
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
for (const [id, name] of [
|
||||
[pending, "frontend-design"],
|
||||
[pending, "sample-skill"],
|
||||
[completed, "OpenCode"],
|
||||
] as const) {
|
||||
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||
const loaded = skill.locator('[data-component="tool-loaded-item"]')
|
||||
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
|
||||
await expect(loaded).toHaveCSS("line-height", "16px")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
|
||||
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
|
||||
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
|
||||
|
||||
@@ -26,42 +26,6 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("returns to the parent session with Escape", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
})
|
||||
|
||||
test("shows parent lineage while the child timeline loads", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.pathname === `/api/session/${childID}/message` &&
|
||||
url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
|
||||
expect(page.locator('[data-slot="session-title-child"]')).toHaveText(childTitle),
|
||||
]).finally(() => release.resolve())
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
})
|
||||
|
||||
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
const requested = Promise.withResolvers<void>()
|
||||
@@ -86,44 +50,6 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
})
|
||||
|
||||
test("keeps the parent tab selected while a loaded child session resolves", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await page.goBack()
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
|
||||
const parentTab = page.locator("[data-titlebar-tab-slot]", {
|
||||
has: page.locator('[data-slot="tab-title"]', { hasText: parentTitle }),
|
||||
})
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
expect(parentTab).toHaveAttribute("data-active", "true"),
|
||||
expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(parentTitle),
|
||||
]).finally(() => release.resolve())
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
const home = page.getByRole("button", { name: "Home" })
|
||||
await home.click()
|
||||
await expect(page).toHaveURL("/")
|
||||
const childTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${sessionHref(childID)}"]`)
|
||||
await expect(childTab).toHaveCount(1)
|
||||
await childTab.click()
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(childID)), expectSessionTitle(page, taskDescription)])
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
|
||||
@@ -10,13 +10,11 @@ const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const ptyInput: string[] = []
|
||||
let sendPtyOutput: ((data: string) => void) | undefined
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
ptyInput.length = 0
|
||||
sendPtyOutput = undefined
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -76,7 +74,6 @@ test.beforeEach(async ({ page }) => {
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||
ws.onMessage((message) => ptyInput.push(message.toString()))
|
||||
sendPtyOutput = (data) => ws.send(data)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,33 +90,6 @@ test("clears the terminal line with Command+Delete", async ({ page }) => {
|
||||
await expect.poll(() => ptyInput.join("")).toBe("\x15")
|
||||
})
|
||||
|
||||
test("hides the native contenteditable caret", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toHaveAttribute("contenteditable", "true")
|
||||
await expect(terminal).toHaveCSS("caret-color", "rgba(0, 0, 0, 0)")
|
||||
})
|
||||
|
||||
test("reveals the terminal after its first server output renders", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toHaveAttribute("contenteditable", "true")
|
||||
await expect(terminal).toHaveCSS("opacity", "0")
|
||||
await expect.poll(() => sendPtyOutput).toBeDefined()
|
||||
|
||||
sendPtyOutput?.("\x1b[?25h")
|
||||
await expect(terminal).toHaveCSS("opacity", "0")
|
||||
|
||||
sendPtyOutput?.("ready")
|
||||
await expect(terminal).toHaveCSS("opacity", "1")
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
@@ -243,8 +213,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
|
||||
await page.getByRole("button", { name: "New terminal" }).click()
|
||||
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||
const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`)
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
function seedCachedTerminal(page: Page) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => {
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -42,16 +42,6 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/animation.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/api/pty*", (route) =>
|
||||
@@ -104,454 +94,24 @@ test("animates review and terminal panels while caching hidden terminal content"
|
||||
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
const reviewToggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectWidthMotions(page, 1)
|
||||
await expectReviewWidthStable(page)
|
||||
await expectLogicalSideAlignment(page, "ltr")
|
||||
await page.evaluate(() => (document.documentElement.dir = "rtl"))
|
||||
await expectLogicalSideAlignment(page, "rtl")
|
||||
await page.evaluate(() => (document.documentElement.dir = "ltr"))
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const panel = page.locator("#terminal-panel")
|
||||
const terminalContent = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await expectHeightMotions(page, "session-side-region", 1)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 1)
|
||||
await expectStackedGeometry(page)
|
||||
await expectPanelGapHeld(page)
|
||||
|
||||
await resetTerminalTopMotion(page)
|
||||
await resetTerminalBottomMotion(page)
|
||||
await resetTerminalAnchorGaps(page)
|
||||
await resetPanelGaps(page)
|
||||
const reviewContent = page.locator('[data-component="session-review-v2"]')
|
||||
await reviewContent.evaluate((element) => element.setAttribute("data-cache-probe", "original"))
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectHeightMotions(page, "session-side-region", 2)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 2)
|
||||
await expectTerminalTopMotion(page)
|
||||
await expectTerminalBottomFixed(page)
|
||||
await expectTerminalTopAnchored(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expect(reviewContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectHeightMotions(page, "session-side-region", 3)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 3)
|
||||
|
||||
await resetTerminalContentSizes(page)
|
||||
await resetPanelGaps(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip")
|
||||
await expectHeightMotions(page, "session-side-region", 4)
|
||||
await expectHeightMotions(page, "session-side-terminal-region", 4)
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectTerminalContentCachedSize(page)
|
||||
await expectStackPainted(page)
|
||||
await expectPanelGapHeld(page)
|
||||
await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px")
|
||||
|
||||
await reviewToggle.click()
|
||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||
await expectWidthMotions(page, 2)
|
||||
await expectSideSlideSettled(page, 2)
|
||||
await expectHiddenSideAligned(page)
|
||||
|
||||
await resetHeightMotions(page)
|
||||
await resetHorizontalScrolls(page)
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "false")
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
await expectWidthMotions(page, 3)
|
||||
await expectSideSlideSettled(page, 3)
|
||||
await expectNoHeightMotion(page)
|
||||
await expectNoHorizontalScroll(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 4)
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(terminalContent).toBeVisible()
|
||||
await expect(terminalContent).toHaveAttribute("data-cache-probe", "original")
|
||||
await expectWidthMotions(page, 5)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeHidden()
|
||||
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }),
|
||||
)
|
||||
})
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, title)
|
||||
await installMotionProbe(page)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expectAnimation(page, "terminal-panel-size-in")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expectAnimation(page, "terminal-panel-size-out")
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeAttached()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
type MotionProbe = {
|
||||
widths: number
|
||||
widthEnds: number
|
||||
horizontalScrolls: number[]
|
||||
reviewWidths: number[]
|
||||
paintGaps: { review: number; terminalSurface: number }[]
|
||||
terminalContentSizes: { width: number; height: number }[]
|
||||
terminalAnchorGaps: number[]
|
||||
resetAnchorOnMotion: boolean
|
||||
panelGaps: number[]
|
||||
terminalTops: number[]
|
||||
terminalBottoms: number[]
|
||||
heights: string[]
|
||||
animations: string[]
|
||||
}
|
||||
|
||||
async function installMotionProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe: MotionProbe = {
|
||||
widths: 0,
|
||||
widthEnds: 0,
|
||||
horizontalScrolls: [],
|
||||
reviewWidths: [],
|
||||
paintGaps: [],
|
||||
terminalContentSizes: [],
|
||||
terminalAnchorGaps: [],
|
||||
resetAnchorOnMotion: false,
|
||||
panelGaps: [],
|
||||
terminalTops: [],
|
||||
terminalBottoms: [],
|
||||
heights: [],
|
||||
animations: [],
|
||||
}
|
||||
const observed = new WeakSet<Element>()
|
||||
const observers: ResizeObserver[] = []
|
||||
const observeReview = () => {
|
||||
const review = document.querySelector('[data-component="session-review-v2"]')
|
||||
if (!review || observed.has(review)) return
|
||||
observed.add(review)
|
||||
const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width))
|
||||
observer.observe(review)
|
||||
observers.push(observer)
|
||||
}
|
||||
const observedRegions = new WeakSet<Element>()
|
||||
const observeStack = () => {
|
||||
const reviewRegion = document.querySelector<HTMLElement>('[data-slot="session-side-region"]')
|
||||
const terminalRegion = document.querySelector<HTMLElement>('[data-slot="session-side-terminal-region"]')
|
||||
if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return
|
||||
observedRegions.add(reviewRegion)
|
||||
const observer = new ResizeObserver(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")
|
||||
const terminalContent = document.querySelector<HTMLElement>('[data-slot="terminal-panel-content"]')
|
||||
const panelGap = document.querySelector<HTMLElement>('[data-slot="session-side-panel-gap"]')
|
||||
if (!terminal || !terminalContent) return
|
||||
probe.terminalTops.push(terminal.getBoundingClientRect().top)
|
||||
probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom)
|
||||
probe.terminalContentSizes.push({
|
||||
width: terminalContent.getBoundingClientRect().width,
|
||||
height: terminalContent.getBoundingClientRect().height,
|
||||
})
|
||||
const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top)
|
||||
if (probe.resetAnchorOnMotion) {
|
||||
if (anchorGap > 8) return
|
||||
probe.terminalAnchorGaps = []
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height),
|
||||
terminalSurface: Math.abs(
|
||||
terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height,
|
||||
),
|
||||
})
|
||||
})
|
||||
observer.observe(reviewRegion)
|
||||
observer.observe(terminalRegion)
|
||||
observers.push(observer)
|
||||
}
|
||||
new MutationObserver(() => {
|
||||
observeReview()
|
||||
observeStack()
|
||||
}).observe(document.body, { childList: true, subtree: true })
|
||||
observeReview()
|
||||
observeStack()
|
||||
document.addEventListener("transitionrun", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const slot = event.target.getAttribute("data-slot")
|
||||
if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++
|
||||
if (event.propertyName === "height" && slot) {
|
||||
probe.heights.push(slot)
|
||||
}
|
||||
})
|
||||
document.addEventListener("transitionend", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
if (event.propertyName === "width" && event.target.getAttribute("data-slot") === "session-chat-panel")
|
||||
probe.widthEnds++
|
||||
})
|
||||
document.addEventListener("animationstart", (event) => {
|
||||
if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return
|
||||
probe.animations.push(event.animationName)
|
||||
})
|
||||
window.addEventListener("scroll", () => probe.horizontalScrolls.push(window.scrollX))
|
||||
;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe
|
||||
})
|
||||
}
|
||||
|
||||
async function expectWidthMotions(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function resetHeightMotions(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.heights = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectSideSlideSettled(page: Page, count: number) {
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widthEnds ?? 0))
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectNoHeightMotion(page: Page) {
|
||||
const heights = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [],
|
||||
)
|
||||
expect(heights).toEqual([])
|
||||
}
|
||||
|
||||
async function expectHiddenSideAligned(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const chat = document.querySelector<HTMLElement>('[data-slot="session-chat-panel"]')
|
||||
const side = document.querySelector<HTMLElement>('[data-slot="session-side-panel-presence"]')
|
||||
if (!chat?.parentElement || !side) return Number.POSITIVE_INFINITY
|
||||
const row = chat.parentElement.getBoundingClientRect()
|
||||
const hidden = side.getBoundingClientRect()
|
||||
return Math.max(
|
||||
Math.abs(row.top - hidden.top),
|
||||
Math.abs(row.right - hidden.right),
|
||||
Math.abs(row.bottom - hidden.bottom),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetHorizontalScrolls(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.horizontalScrolls = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectNoHorizontalScroll(page: Page) {
|
||||
const scrolls = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.horizontalScrolls ?? [],
|
||||
)
|
||||
expect(Math.max(0, ...scrolls)).toBe(0)
|
||||
expect(await page.evaluate(() => window.scrollX)).toBe(0)
|
||||
}
|
||||
|
||||
async function expectReviewWidthStable(page: Page) {
|
||||
const side = page.locator('[data-slot="session-side-panel-presence"]')
|
||||
await expect
|
||||
.poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished")))
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0),
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
const widths = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [],
|
||||
)
|
||||
expect(new Set(widths).size).toBe(1)
|
||||
}
|
||||
|
||||
async function expectStackedGeometry(page: Page) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.POSITIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(9)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")?.getBoundingClientRect()
|
||||
if (!review || !terminal) return Number.NEGATIVE_INFINITY
|
||||
return terminal.top - review.bottom
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(7)
|
||||
}
|
||||
|
||||
async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate((direction) => {
|
||||
const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect()
|
||||
const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect()
|
||||
if (!frame || !content) return Number.POSITIVE_INFINITY
|
||||
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
|
||||
}, direction),
|
||||
)
|
||||
.toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function expectStackPainted(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1)
|
||||
expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalTopMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalTops = []
|
||||
})
|
||||
}
|
||||
|
||||
async function resetTerminalBottomMotion(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalBottoms = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalBottomFixed(page: Page) {
|
||||
const bottoms = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [],
|
||||
)
|
||||
expect(bottoms.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
async function resetTerminalAnchorGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.resetAnchorOnMotion = true
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPanelGaps(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.panelGaps = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectPanelGapHeld(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6)
|
||||
expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.max(...gaps)).toBeLessThanOrEqual(9)
|
||||
}
|
||||
|
||||
async function expectTerminalTopAnchored(page: Page) {
|
||||
const gaps = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [],
|
||||
)
|
||||
expect(gaps.length).toBeGreaterThan(0)
|
||||
expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8)
|
||||
}
|
||||
|
||||
async function resetTerminalContentSizes(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion
|
||||
if (probe) probe.terminalContentSizes = []
|
||||
})
|
||||
}
|
||||
|
||||
async function expectTerminalContentCachedSize(page: Page) {
|
||||
const sizes = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [],
|
||||
)
|
||||
expect(sizes.length).toBeGreaterThan(0)
|
||||
expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100)
|
||||
expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100)
|
||||
}
|
||||
|
||||
async function expectTerminalTopMotion(page: Page) {
|
||||
const tops = await page.evaluate(
|
||||
() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [],
|
||||
)
|
||||
const unique = [...new Set(tops)]
|
||||
const range = Math.max(...unique) - Math.min(...unique)
|
||||
const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index])))
|
||||
expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6)
|
||||
expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3)
|
||||
}
|
||||
|
||||
async function expectHeightMotions(page: Page, slot: string, count: number) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(slot) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot)
|
||||
.length ?? 0,
|
||||
slot,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(count)
|
||||
}
|
||||
|
||||
async function expectAnimation(page: Page, name: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(name) =>
|
||||
(window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false,
|
||||
name,
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
@@ -358,8 +358,7 @@ test.describe("smoke: session timeline", () => {
|
||||
const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]')
|
||||
const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]')
|
||||
await expect(shellSubtitle).toHaveCount(0)
|
||||
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("bun typecheck")
|
||||
await expect(shell.locator('[data-slot="bash-result"]')).not.toContainText("bun typecheck")
|
||||
await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck")
|
||||
await shellTrigger.click()
|
||||
await expect(shellTrigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(shellSubtitle).toHaveText("bun typecheck")
|
||||
|
||||
@@ -501,7 +501,7 @@ function currentModels(value: unknown) {
|
||||
return [
|
||||
{
|
||||
id: model.id,
|
||||
modelID: record(model.api) && typeof model.api.id === "string" ? model.api.id : model.id,
|
||||
modelID: model.id,
|
||||
providerID: provider.id,
|
||||
name: model.name,
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
"@dnd-kit/helpers": "0.5.0",
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
@@ -69,7 +68,6 @@
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@solid-primitives/event-listener": "2.4.5",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/keyed": "1.5.3",
|
||||
"@solid-primitives/media": "2.3.3",
|
||||
"@solid-primitives/resize-observer": "2.1.5",
|
||||
"@solid-primitives/scheduled": "1.5.3",
|
||||
|
||||
+13
-13
@@ -105,16 +105,14 @@ export function AppInterface(props: {
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
@@ -125,9 +123,11 @@ export function AppInterface(props: {
|
||||
servers={props.servers}
|
||||
>
|
||||
<SettingsProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
<AppRoutes />
|
||||
</Dynamic>
|
||||
<GlobalProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
<AppRoutes />
|
||||
</Dynamic>
|
||||
</GlobalProvider>
|
||||
</SettingsProvider>
|
||||
</ServersProvider>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
@@ -42,10 +41,6 @@ export type ComposerSelection = {
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
handoff?: {
|
||||
set: (message: SessionMessageUser) => void
|
||||
clear: (messageID: string) => void
|
||||
}
|
||||
api: {
|
||||
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
|
||||
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
|
||||
@@ -56,7 +51,6 @@ export type ComposerSession = {
|
||||
location: { command: Pick<Data["location"]["command"], "list"> }
|
||||
session: {
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
|
||||
setStatus: Data["session"]["setStatus"]
|
||||
}
|
||||
}
|
||||
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
@@ -83,7 +77,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
|
||||
start: (
|
||||
selection: ComposerSelection,
|
||||
submission: ReturnType<typeof createComposerSubmission>,
|
||||
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
|
||||
) => Promise<ComposerSession | undefined>
|
||||
}
|
||||
|
||||
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
|
||||
|
||||
@@ -29,7 +29,7 @@ export function Composer(props: {
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
variantControlVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
attachShortcut={command.keybind("file.attach")}
|
||||
modelControl={
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ComposerEditorProps = {
|
||||
borderUnderlay?: boolean
|
||||
class?: string
|
||||
modelControl?: JSX.Element
|
||||
modelControlsVisible?: boolean
|
||||
variantControlVisible?: boolean
|
||||
attachKeybind?: string[]
|
||||
attachShortcut?: string
|
||||
}
|
||||
@@ -233,19 +233,17 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.modelControlsVisible ?? true}>
|
||||
{props.modelControl}
|
||||
<Show when={view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
{props.modelControl}
|
||||
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<ComposerEditorSubmitButton
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState } from "./state"
|
||||
@@ -70,8 +69,6 @@ function submitInput(
|
||||
function session(input: {
|
||||
calls: string[]
|
||||
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
|
||||
handoff?: ComposerSession["handoff"]
|
||||
statuses?: ("idle" | "running")[]
|
||||
current?: ComposerSession["current"]
|
||||
admitted?: (messageID: string) => boolean
|
||||
shell?: () => Promise<unknown>
|
||||
@@ -80,7 +77,6 @@ function session(input: {
|
||||
return {
|
||||
id: "session-1",
|
||||
directory: "C:/repo",
|
||||
handoff: input.handoff,
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
@@ -96,7 +92,6 @@ function session(input: {
|
||||
data: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
setStatus: (_sessionID, status) => input.statuses?.push(status),
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
await input.prompt(value)
|
||||
@@ -145,12 +140,10 @@ describe("Composer submission", () => {
|
||||
|
||||
test("starts and promotes a New Session once before admitting its first prompt", async () => {
|
||||
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
|
||||
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
|
||||
const promoted = createMemoryComposerState().capture()
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
|
||||
const cleanupReady = Promise.withResolvers<void>()
|
||||
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
|
||||
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
@@ -163,70 +156,18 @@ describe("Composer submission", () => {
|
||||
async start(_selection, submission) {
|
||||
calls.push("start")
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: cleanupReady.promise }
|
||||
},
|
||||
}
|
||||
|
||||
const submitted = submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
|
||||
expect(statuses).toEqual(["running"])
|
||||
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
|
||||
cleanupReady.resolve()
|
||||
await submitted
|
||||
|
||||
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("hands off image-only first prompts before admission", async () => {
|
||||
const draft = createMemoryComposerState().capture()
|
||||
draft.set([
|
||||
{ type: "text", content: "", start: 0, end: 0 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
])
|
||||
const handedOff = Promise.withResolvers<SessionMessageUser>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
handoff: { set: handedOff.resolve, clear() {} },
|
||||
prompt: async () => undefined,
|
||||
})
|
||||
const adapter: NewSessionComposerAdapter = {
|
||||
kind: "new-session",
|
||||
state: draft,
|
||||
ready: () => true,
|
||||
controls,
|
||||
working: () => false,
|
||||
submitted() {},
|
||||
async start() {
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
await submitInput(adapter).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(await handedOff.promise).toMatchObject({
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
|
||||
name: "image.png",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("first prompt")
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
})
|
||||
|
||||
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
|
||||
@@ -292,7 +233,7 @@ describe("Composer submission", () => {
|
||||
submitted() {},
|
||||
async start(_selection, submission) {
|
||||
submission.retarget(promoted)
|
||||
return { session: target, cleanupReady: Promise.resolve() }
|
||||
return target
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +250,10 @@ describe("Composer submission", () => {
|
||||
test("reuses the message ID when an unacknowledged admission is retried", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
|
||||
const attempts: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
throw new Error("network unavailable")
|
||||
@@ -344,7 +283,6 @@ describe("Composer submission", () => {
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
@@ -66,43 +65,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const comments = input.comments.capture()
|
||||
|
||||
try {
|
||||
const started =
|
||||
const session =
|
||||
input.adapter.kind === "active-session"
|
||||
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
|
||||
? input.adapter.session()
|
||||
: await input.adapter.start(value.selection, submission)
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
if (!session) return
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
|
||||
if (value.mode === "normal" && !command) {
|
||||
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
const sending = sendPrompt(session, value).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sending.then((result) => {
|
||||
if (!result.ok)
|
||||
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await started.cleanupReady
|
||||
input.adapter.submitted()
|
||||
|
||||
if (value.mode === "shell") {
|
||||
@@ -111,6 +82,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
const command = findCommand(session, value.text)
|
||||
if (command) {
|
||||
clearSubmission(input, submission)
|
||||
void sendCommand(session, value, command).catch((error) =>
|
||||
@@ -119,6 +91,14 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
submission.context
|
||||
.filter((item) => !!item.comment?.trim())
|
||||
.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
void sendPrompt(session, value).catch((error) =>
|
||||
failSubmission(input, session, "prompt", error, restore, value.id),
|
||||
)
|
||||
} finally {
|
||||
submitting.delete(input.adapter.state)
|
||||
}
|
||||
@@ -130,29 +110,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
|
||||
return {
|
||||
id: value.id,
|
||||
type: "user",
|
||||
text: value.text,
|
||||
files: value.images.map((image) => ({
|
||||
data: "",
|
||||
mime: image.mime,
|
||||
source: { type: "uri", uri: image.blob.url },
|
||||
name: image.sourcePath ?? image.filename,
|
||||
})),
|
||||
metadata: {
|
||||
displayText: value.text,
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
...value.selection.model,
|
||||
...(value.selection.variant ? { variant: value.selection.variant } : {}),
|
||||
},
|
||||
},
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
}
|
||||
|
||||
function readSubmission(
|
||||
input: ComposerSubmitInput,
|
||||
prompt: Prompt,
|
||||
@@ -356,11 +313,8 @@ function failSubmission(
|
||||
error: unknown,
|
||||
restore: () => boolean,
|
||||
messageID?: string,
|
||||
rollback?: () => void,
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
if (messageID) session.handoff?.clear(messageID)
|
||||
rollback?.()
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./runtime/platform/file-picker"
|
||||
export { useCommand } from "./shell/commands/command"
|
||||
export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language"
|
||||
export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform"
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
|
||||
@@ -42,10 +42,6 @@ const clearAuthToken = () => {
|
||||
|
||||
const web = createWebPlatform(pkg.version)
|
||||
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
|
||||
}
|
||||
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
|
||||
@@ -30,7 +30,6 @@ export function HomeCommandPalette(props: {
|
||||
opened: server.projects.list,
|
||||
stored: () => server.sync.data.project,
|
||||
load: (search, signal) => server.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||
get: (sessionID, signal) => server.sdk.api.session.get({ sessionID }, { signal }),
|
||||
untitled: () => language.t("command.session.new"),
|
||||
category: () => language.t("command.category.session"),
|
||||
})
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { skipToken, useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
|
||||
import type { LocalProject } from "@/shell/state/layout"
|
||||
@@ -15,10 +11,7 @@ import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { sessionHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
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 { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/session/commands/export"
|
||||
import { sessionLabel, sessionTitle } from "@/session/title"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { archiveHomeSession } from "./archive"
|
||||
import type { HomeController } from "../model"
|
||||
@@ -42,8 +35,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const [removed, setRemoved] = createStore({ keys: [] as string[] })
|
||||
const projectDirectories = createMemo(() => {
|
||||
const selected = home.selection.value().directory
|
||||
if (!selected) return
|
||||
@@ -68,13 +59,9 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
})
|
||||
const indexedSessions = createMemo(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
if (!ctx || !conn) return []
|
||||
const server = ServerConnection.key(conn)
|
||||
if (!ctx) return []
|
||||
return retainHomeSessions(
|
||||
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
|
||||
(session) => !removed.keys.includes(`${server}\0${session.id}`),
|
||||
),
|
||||
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
)
|
||||
@@ -136,119 +123,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
},
|
||||
])
|
||||
|
||||
const rename = async (server: ServerConnection.Key, session: SessionInfo, title: string) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!conn || !ctx) return false
|
||||
const next = title.trim()
|
||||
if (!next || next === sessionLabel(session)) return true
|
||||
return ctx.sdk.api.session
|
||||
.rename({ sessionID: session.id, title: next })
|
||||
.then(() => {
|
||||
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
|
||||
// Rename advances time.updated server-side; re-sync the canonical
|
||||
// record so date grouping and ordering do not go stale.
|
||||
ctx.data.session.invalidate(session.id)
|
||||
void ctx.data.session.sync(session.id).catch(() => {})
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.map((item) => (item.id === session.id ? { ...item, title: next } : item)),
|
||||
)
|
||||
return true
|
||||
})
|
||||
.catch((cause) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(cause, language.t("common.requestFailed")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
const exportSession = async (server: ServerConnection.Key, session: SessionInfo) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!ctx) return
|
||||
try {
|
||||
const data = await fetchSessionExport({ sessionID: session.id, api: ctx.sdk.api })
|
||||
const filename = sessionExportFilename(data.info)
|
||||
downloadSessionExport(filename, data)
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("toast.session.export.success.title"),
|
||||
description: language.t("toast.session.export.success.description", { filename }),
|
||||
})
|
||||
} catch (cause) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.session.export.failed.title"),
|
||||
description: cause instanceof Error ? cause.message : language.t("toast.session.export.failed.description"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (server: ServerConnection.Key, session: SessionInfo) => {
|
||||
const conn = home.server.list().find((item) => ServerConnection.key(item) === server)
|
||||
const ctx = conn ? home.server.context(conn) : undefined
|
||||
if (!conn || !ctx) return false
|
||||
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
|
||||
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
return ctx.sdk.api.session
|
||||
.remove({ sessionID: session.id })
|
||||
.then(() => {
|
||||
const removedIDs = new Set(ids)
|
||||
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
|
||||
current?.filter((item) => !removedIDs.has(item.id)),
|
||||
)
|
||||
notifySessionTabsRemoved({
|
||||
server: ServerConnection.key(conn),
|
||||
directory: session.location.directory,
|
||||
sessionIDs: ids,
|
||||
})
|
||||
return true
|
||||
})
|
||||
.catch((cause) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(cause, language.t("session.delete.failed.title")),
|
||||
})
|
||||
return false
|
||||
})
|
||||
.finally(() => {
|
||||
// Always refetch: the pre-mutation cancel may have aborted an
|
||||
// in-flight index fetch, and a failed delete must not leave the
|
||||
// index unloaded either.
|
||||
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
})
|
||||
}
|
||||
|
||||
function DeleteDialog(props: { server: ServerConnection.Key; session: SessionInfo }) {
|
||||
const name = () => sessionTitle(props.session.title) ?? language.t("command.session.new")
|
||||
const confirm = async () => {
|
||||
await remove(props.server, props.session)
|
||||
dialog.close()
|
||||
}
|
||||
return (
|
||||
<Dialog fit>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitleGroup
|
||||
title={language.t("session.delete.title")}
|
||||
description={language.t("session.delete.confirm", { name: name() })}
|
||||
/>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={confirm}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
copy: {
|
||||
language,
|
||||
@@ -263,17 +137,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
showProjectName: () => !home.project.selected(),
|
||||
server: () => home.selection.value().server,
|
||||
canCreate: () => !!home.project.newSession(),
|
||||
lookup: async (sessionID: string) => {
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
const result = await ctx.sdk.api.session.get({ sessionID })
|
||||
if (result.time.archived) return
|
||||
return buildHomeSessionRecords({
|
||||
sessions: () => [result],
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
})[0]
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
open: (session: SessionInfo, options?: OpenSessionOptions) => {
|
||||
const directoryKey = pathKey(session.location.directory)
|
||||
@@ -319,10 +182,6 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
}),
|
||||
})
|
||||
},
|
||||
rename,
|
||||
export: exportSession,
|
||||
showDelete: (server: ServerConnection.Key, session: SessionInfo) =>
|
||||
dialog.show(() => <DeleteDialog server={server} session={session} />),
|
||||
},
|
||||
tab: {
|
||||
isOpen: (record: HomeSessionRecord) =>
|
||||
|
||||
@@ -27,9 +27,6 @@ export function HomeSessions(props: {
|
||||
onCreateSession={props.sessions.session.create}
|
||||
onOpenSession={props.sessions.session.open}
|
||||
onArchiveSession={props.sessions.session.archive}
|
||||
onRenameSession={props.sessions.session.rename}
|
||||
onExportSession={props.sessions.session.export}
|
||||
onDeleteSession={props.sessions.session.showDelete}
|
||||
onSetHoverTarget={props.scroll.viewport.setHoverTarget}
|
||||
onSetThumbTrack={props.scroll.viewport.setThumbTrack}
|
||||
onSetContent={props.scroll.header.setContent}
|
||||
|
||||
@@ -8,21 +8,13 @@ import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { HomeController } from "../model"
|
||||
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./controller"
|
||||
import { looksLikeSessionID } from "@/session/search"
|
||||
|
||||
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
|
||||
|
||||
export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
value: "",
|
||||
focused: false,
|
||||
highlighted: "",
|
||||
exact: undefined as HomeSessionRecord | undefined,
|
||||
lookingUp: false,
|
||||
})
|
||||
let lookup = 0
|
||||
const [state, setState] = createStore({ value: "", focused: false, highlighted: "" })
|
||||
let root: HTMLDivElement | undefined
|
||||
let input: HTMLInputElement | undefined
|
||||
let list: HTMLDivElement | undefined
|
||||
@@ -30,11 +22,9 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
const results = createMemo(() => {
|
||||
const value = query().toLowerCase()
|
||||
if (!value) return []
|
||||
const records = sessions.data
|
||||
return sessions.data
|
||||
.searchRecords()
|
||||
.filter((record) => `${sessionLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
|
||||
if (!state.exact || records.some((record) => record.session.id === state.exact?.session.id)) return records
|
||||
return [state.exact, ...records]
|
||||
})
|
||||
const active = createMemo(() => {
|
||||
const records = results()
|
||||
@@ -60,7 +50,6 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
close()
|
||||
}),
|
||||
)
|
||||
onCleanup(() => lookup++)
|
||||
|
||||
command.register("home.search", () => [
|
||||
{
|
||||
@@ -78,26 +67,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
}
|
||||
|
||||
function close() {
|
||||
lookup++
|
||||
setState({ value: "", focused: false, exact: undefined, lookingUp: false })
|
||||
}
|
||||
|
||||
function update(value: string) {
|
||||
const current = ++lookup
|
||||
const sessionID = value.trim()
|
||||
setState({ value, highlighted: "", exact: undefined, lookingUp: false })
|
||||
if (!looksLikeSessionID(sessionID)) return
|
||||
setState("lookingUp", true)
|
||||
void sessions.session.lookup(sessionID).then(
|
||||
(record) => {
|
||||
if (current !== lookup) return
|
||||
setState({ exact: record, lookingUp: false })
|
||||
},
|
||||
() => {
|
||||
if (current !== lookup) return
|
||||
setState("lookingUp", false)
|
||||
},
|
||||
)
|
||||
setState({ value: "", focused: false })
|
||||
}
|
||||
|
||||
function select(record: HomeSessionRecord, options?: { background?: boolean }) {
|
||||
@@ -111,11 +81,11 @@ export function createHomeSessionSearchController(home: HomeController, sessions
|
||||
placeholder,
|
||||
open,
|
||||
focus,
|
||||
input: update,
|
||||
input: (value: string) => setState({ value, highlighted: "" }),
|
||||
close,
|
||||
},
|
||||
result: {
|
||||
loading: () => sessions.data.loading() || state.lookingUp,
|
||||
loading: sessions.data.loading,
|
||||
list: results,
|
||||
active,
|
||||
noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }),
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { createMemo, For, Show, Suspense } from "solid-js"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -26,7 +22,6 @@ import {
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
|
||||
const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results"
|
||||
const HOME_SESSION_LONG_PRESS_MS = 500
|
||||
|
||||
// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session
|
||||
// tab in the background without navigating, matching browser conventions.
|
||||
@@ -59,9 +54,6 @@ export type HomeSessionsViewProps = {
|
||||
onCreateSession: () => void
|
||||
onOpenSession: (session: SessionInfo, options?: OpenSessionOptions) => void
|
||||
onArchiveSession: (session: SessionInfo) => Promise<void>
|
||||
onRenameSession: (server: ServerConnection.Key, session: SessionInfo, title: string) => Promise<boolean>
|
||||
onExportSession: (server: ServerConnection.Key, session: SessionInfo) => Promise<void>
|
||||
onDeleteSession: (server: ServerConnection.Key, session: SessionInfo) => void
|
||||
onSetHoverTarget: (element: HTMLElement) => void
|
||||
onSetThumbTrack: (element: HTMLDivElement) => void
|
||||
onSetContent: (element: HTMLDivElement) => void
|
||||
@@ -79,16 +71,7 @@ export type HomeSessionsViewProps = {
|
||||
onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void
|
||||
}
|
||||
|
||||
// Session store updates recreate row components, so row-local state would
|
||||
// close an open context menu or drop an in-progress rename. Keep both keyed
|
||||
// by session ID at the view root, like the projects list does.
|
||||
type HomeSessionRowUI = {
|
||||
menu: { id: string; x: number; y: number } | undefined
|
||||
editor: { id: string; draft: string; renaming: boolean } | undefined
|
||||
}
|
||||
|
||||
export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
const [rowUI, setRowUI] = createStore<HomeSessionRowUI>({ menu: undefined, editor: undefined })
|
||||
return (
|
||||
<section
|
||||
ref={props.onSetHoverTarget}
|
||||
@@ -139,29 +122,23 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
|
||||
}
|
||||
>
|
||||
<div ref={props.onSetContent} class="flex flex-col pt-3 pr-3 pb-16">
|
||||
{/* Index keeps group subtrees mounted when the group arrays are
|
||||
rebuilt, so store updates cannot recreate rows mid-gesture. */}
|
||||
<Index each={props.groups}>
|
||||
<For each={props.groups}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<HomeSessionGroupHeader
|
||||
title={group().title}
|
||||
titleOpacity={props.titleOpacity(group().id)}
|
||||
onSetRef={(element) => props.onSetHeader(group().id, element)}
|
||||
elevated={index === 0}
|
||||
title={group.title}
|
||||
titleOpacity={props.titleOpacity(group.id)}
|
||||
onSetRef={(element) => props.onSetHeader(group.id, element)}
|
||||
elevated={index() === 0}
|
||||
/>
|
||||
<div class={`flex min-w-0 flex-col gap-px pt-4 ${index === props.groups.length - 1 ? "" : "mb-6"}`}>
|
||||
{/* Rows key by session ID: session.sync replaces the
|
||||
stored session object wholesale, so reference-keyed
|
||||
rows would be disposed mid-interaction whenever a
|
||||
sync response lands. */}
|
||||
<Key each={group().sessions} by={(record) => record.session.id}>
|
||||
{(record) => <HomeSessionRow {...props} record={record()} rowUI={rowUI} setRowUI={setRowUI} />}
|
||||
</Key>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === props.groups.length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<For each={group.sessions}>{(record) => <HomeSessionRow {...props} record={record} />}</For>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Index>
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Suspense>
|
||||
@@ -435,263 +412,45 @@ function HomeSessionGroupHeader(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionRow(
|
||||
props: HomeSessionsViewProps & {
|
||||
record: HomeSessionRecord
|
||||
rowUI: HomeSessionRowUI
|
||||
setRowUI: SetStoreFunction<HomeSessionRowUI>
|
||||
},
|
||||
) {
|
||||
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
|
||||
const title = createMemo(() => sessionLabel(props.record.session))
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
const sessionID = () => props.record.session.id
|
||||
const menu = () => (props.rowUI.menu?.id === sessionID() ? props.rowUI.menu : undefined)
|
||||
const editor = () => (props.rowUI.editor?.id === sessionID() ? props.rowUI.editor : undefined)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let longPressStart: { x: number; y: number } | undefined
|
||||
let suppressClick = false
|
||||
let menuInteractedOutside = false
|
||||
|
||||
// Focus targets are looked up by session ID: session store updates recreate
|
||||
// row components, so instance refs can point at detached nodes by the time
|
||||
// deferred focus runs.
|
||||
const rowSelector = () => `[data-component="home-session-row-container"][data-session-id="${sessionID()}"]`
|
||||
const rowButton = () =>
|
||||
document.querySelector<HTMLButtonElement>(`${rowSelector()} [data-component="home-session-row"]`)
|
||||
const renameInput = () =>
|
||||
document.querySelector<HTMLInputElement>(`${rowSelector()} [data-component="home-session-rename"]`)
|
||||
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimer !== undefined) clearTimeout(longPressTimer)
|
||||
longPressTimer = undefined
|
||||
longPressStart = undefined
|
||||
}
|
||||
onCleanup(clearLongPress)
|
||||
|
||||
const openMenu = (element: HTMLElement, clientX: number, clientY: number) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
props.setRowUI("menu", { id: sessionID(), x: clientX - bounds.left, y: clientY - bounds.top })
|
||||
}
|
||||
|
||||
const openEditor = () => {
|
||||
props.setRowUI("editor", { id: sessionID(), draft: title(), renaming: false })
|
||||
requestAnimationFrame(() => {
|
||||
const input = renameInput()
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
const closeEditor = () => {
|
||||
if (editor()?.renaming) return
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? undefined : value))
|
||||
}
|
||||
const saveEditor = async () => {
|
||||
const current = editor()
|
||||
if (!current || current.renaming) return
|
||||
props.setRowUI("editor", { ...current, renaming: true })
|
||||
const saved = await props.onRenameSession(props.server, props.record.session, current.draft)
|
||||
// Disabling the input during the request drops focus to the body; restore
|
||||
// it unless the user focused another control while the rename was pending.
|
||||
const restore = document.activeElement === document.body || document.activeElement === renameInput()
|
||||
props.setRowUI("editor", (value) => {
|
||||
if (value?.id !== sessionID()) return value
|
||||
return saved ? undefined : { ...value, renaming: false }
|
||||
})
|
||||
if (!restore) return
|
||||
requestAnimationFrame(() => {
|
||||
if (saved) {
|
||||
rowButton()?.focus()
|
||||
return
|
||||
}
|
||||
renameInput()?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="home-session-row-container"
|
||||
data-session-id={props.record.session.id}
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px] outline-none focus:outline-none focus-visible:outline-none"
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
onContextMenu={(event) => {
|
||||
// While renaming, keep the native menu so paste and spelling work.
|
||||
if (editor()) return
|
||||
event.preventDefault()
|
||||
openMenu(event.currentTarget, event.clientX, event.clientY)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!editor()}
|
||||
fallback={
|
||||
<div class="flex h-10 min-w-0 w-full flex-1 items-center gap-2 py-3 pl-3 pr-10">
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={false}
|
||||
/>
|
||||
<InlineInput
|
||||
data-component="home-session-rename"
|
||||
aria-label={props.language.t("common.rename")}
|
||||
dir="auto"
|
||||
value={editor()?.draft ?? ""}
|
||||
disabled={editor()?.renaming ?? false}
|
||||
class={`
|
||||
block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base
|
||||
[font-weight:530] field-sizing-content outline-none focus:outline-none focus-visible:outline-none
|
||||
${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}
|
||||
`}
|
||||
style={{ "--inline-input-shadow": "none", "text-align": "start" }}
|
||||
onInput={(event) => {
|
||||
const draft = event.currentTarget.value
|
||||
props.setRowUI("editor", (value) => (value?.id === sessionID() ? { ...value, draft } : value))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
// Enter and Escape during IME composition commit or cancel
|
||||
// the composition, not the rename. Safari can report the
|
||||
// composition-confirming keydown with isComposing false but
|
||||
// keyCode 229.
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveEditor()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
closeEditor()
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
onBlur={closeEditor}
|
||||
/>
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!menu()}
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
suppressClick = false
|
||||
if (event.pointerType !== "touch") return
|
||||
clearLongPress()
|
||||
const element = event.currentTarget
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
longPressStart = { x, y }
|
||||
longPressTimer = setTimeout(() => {
|
||||
suppressClick = true
|
||||
clearLongPress()
|
||||
openMenu(element, x, y)
|
||||
}, HOME_SESSION_LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!longPressStart) return
|
||||
if (Math.abs(event.clientX - longPressStart.x) <= 8 && Math.abs(event.clientY - longPressStart.y) <= 8)
|
||||
return
|
||||
clearLongPress()
|
||||
}}
|
||||
onPointerUp={clearLongPress}
|
||||
onPointerCancel={() => {
|
||||
clearLongPress()
|
||||
suppressClick = false
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "ContextMenu" && (event.key !== "F10" || !event.shiftKey)) return
|
||||
event.preventDefault()
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
openMenu(event.currentTarget, bounds.left + 12, bounds.bottom)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
// The flag stays set until the long-press compatibility click
|
||||
// arrives, however delayed; keyboard activation (detail 0) is
|
||||
// never that click and passes through.
|
||||
if (suppressClick) {
|
||||
suppressClick = false
|
||||
if (event.detail !== 0) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })
|
||||
}}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Menu
|
||||
modal={false}
|
||||
placement="bottom-start"
|
||||
gutter={2}
|
||||
open={!!menu()}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return
|
||||
props.setRowUI("menu", (value) => (value?.id === sessionID() ? undefined : value))
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`
|
||||
flex h-10 min-w-0 w-full flex-1 shrink-0 cursor-default items-center gap-2 rounded-[6px] border-0
|
||||
bg-transparent py-3 pl-3 pr-10 text-left text-v2-text-text-muted [font-weight:530]
|
||||
transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out
|
||||
hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none
|
||||
`}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
onClick={(event) => props.onOpenSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
onAuxClick={(event) => {
|
||||
if (!isBackgroundOpen(event)) return
|
||||
event.preventDefault()
|
||||
props.onOpenSession(props.record.session, { background: true })
|
||||
}}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="span"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
class="pointer-events-none absolute size-px"
|
||||
style={{ left: `${menu()?.x ?? 0}px`, top: `${menu()?.y ?? 0}px` }}
|
||||
<HomeSessionLeadingController
|
||||
server={props.server}
|
||||
isOpenTab={props.isOpenTab}
|
||||
record={props.record}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onInteractOutside={() => {
|
||||
menuInteractedOutside = true
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
// The trigger is an invisible positioning span, so Kobalte's
|
||||
// default close focus restore has no useful target. Skip the
|
||||
// row focus when the rename editor owns focus or the user
|
||||
// dismissed the menu by interacting elsewhere.
|
||||
event.preventDefault()
|
||||
const outside = menuInteractedOutside
|
||||
menuInteractedOutside = false
|
||||
if (outside || editor()) return
|
||||
requestAnimationFrame(() => rowButton()?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.Item onSelect={openEditor}>{props.language.t("common.rename")}</Menu.Item>
|
||||
<Menu.Item onSelect={() => void props.onExportSession(props.server, props.record.session)}>
|
||||
{props.language.t("common.export")}...
|
||||
</Menu.Item>
|
||||
<Menu.Separator />
|
||||
<Menu.Item onSelect={() => props.onDeleteSession(props.server, props.record.session)}>
|
||||
{props.language.t("common.delete")}...
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<HomeSessionTitle title={title()} showProjectName={!!showProjectName()} />
|
||||
<Show when={showProjectName()}>
|
||||
<HomeSessionProjectName name={props.record.projectName} />
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={SHOW_HOME_SESSION_ARCHIVE}>
|
||||
<div
|
||||
class={`
|
||||
@@ -722,7 +481,6 @@ function HomeSessionRow(
|
||||
function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) {
|
||||
return (
|
||||
<span
|
||||
data-component="home-session-title"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530]"
|
||||
classList={{
|
||||
"text-[13px] leading-4 tracking-[-0.04px]": !!props.search,
|
||||
@@ -751,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
|
||||
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
|
||||
<div
|
||||
class={`
|
||||
shrink-0 text-[13px] leading-text-compact tracking-[-0.04px]
|
||||
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
|
||||
text-v2-text-text-base [font-weight:530]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -17,14 +17,6 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("@ibm/plex/IBM-Plex-Mono/fonts/complete/woff2/IBMPlexMono-Text.woff2") format("woff2");
|
||||
font-weight: 440;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
/* WebKit excludes safe-area insets from dvh in installed apps. */
|
||||
#root {
|
||||
@@ -33,133 +25,6 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] {
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="terminal-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="true"] {
|
||||
animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="side-terminal-panel-presence"][data-opened="false"] {
|
||||
animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] {
|
||||
animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] {
|
||||
animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-in {
|
||||
from {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-terminal-panel-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes side-region-presence-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-in {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-size-out {
|
||||
from {
|
||||
height: var(--terminal-panel-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="getting-started"] {
|
||||
container-type: inline-size;
|
||||
container-name: getting-started;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory } from "@opencode-ai/util/path"
|
||||
import type { SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { startTransition } from "solid-js"
|
||||
import type { NewSessionComposerAdapter } from "@/composer/adapter"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -10,13 +9,11 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { usePermission } from "@/session/requests/permission"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useSessionKey } from "@/session/session-layout"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
draftID: string
|
||||
@@ -56,35 +53,26 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
if (!sessionDirectory) return
|
||||
|
||||
const created = data.session.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
const creation = created.request.then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => {
|
||||
const created = await serverSDK.api.session
|
||||
.create({
|
||||
agent: selection.agent,
|
||||
model: {
|
||||
id: selection.model.modelID,
|
||||
providerID: selection.model.providerID,
|
||||
variant: selection.variant,
|
||||
},
|
||||
location: { directory: sessionDirectory },
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(language, error),
|
||||
})
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
const afterCreation = async <T,>(run: () => Promise<T>) => {
|
||||
const result = await creation
|
||||
if (!result.ok) throw result.error
|
||||
return run()
|
||||
}
|
||||
const sessionKey = SessionStateKey.from(
|
||||
serverSDK.scope,
|
||||
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
|
||||
)
|
||||
const cleanupReady = startTransition(() => {
|
||||
})
|
||||
if (!created) return
|
||||
|
||||
data.session.remember(created)
|
||||
await startTransition(() => {
|
||||
tabs.updateDraft(props.draftID, { worktree: undefined })
|
||||
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
|
||||
permission.enableAutoAccept(created.id, sessionDirectory)
|
||||
@@ -104,32 +92,13 @@ export function createNewSessionComposerAdapter(props: {
|
||||
})
|
||||
|
||||
return {
|
||||
cleanupReady,
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
|
||||
api: {
|
||||
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
|
||||
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
|
||||
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
|
||||
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
|
||||
},
|
||||
data: {
|
||||
location: data.location,
|
||||
session: {
|
||||
setStatus: data.session.setStatus,
|
||||
prompt: (input) =>
|
||||
data.session.prompt({
|
||||
...input,
|
||||
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
current: () => data.session.get(created.id),
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
},
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
api: serverSDK.api.session,
|
||||
data,
|
||||
current: () => data.session.get(created.id) ?? created,
|
||||
admitted: (messageID) =>
|
||||
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -142,27 +111,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
|
||||
let unsubscribe: VoidFunction | undefined
|
||||
return {
|
||||
set(message: SessionMessageUser) {
|
||||
unsubscribe?.()
|
||||
setSessionMessageHandoff(key, message)
|
||||
unsubscribe = event.on("session.inbox.enqueued", (item) => {
|
||||
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, message.id)
|
||||
})
|
||||
},
|
||||
clear(messageID: string) {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, messageID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionDirectory(input: {
|
||||
projectDirectory: string
|
||||
worktree: string
|
||||
|
||||
@@ -139,7 +139,7 @@ function ProviderTip() {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
|
||||
@@ -190,7 +190,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
{(group) => (
|
||||
<Show when={group.items().length > 0}>
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{group.title}
|
||||
</div>
|
||||
<For each={group.items()}>
|
||||
@@ -198,7 +198,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
<button
|
||||
type="button"
|
||||
data-provider-id={provider.id}
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-text-compact tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
|
||||
onMouseEnter={() => setStore("active", provider.id)}
|
||||
disabled={store.connecting !== undefined}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -24,7 +23,6 @@ export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ collapsed: {} as Record<string, boolean> })
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
@@ -122,38 +120,16 @@ export const DialogManageModels: Component = () => {
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => {
|
||||
const searching = () => list.filter().length > 0
|
||||
const expanded = () => searching() || !store.collapsed[group.category]
|
||||
|
||||
return (
|
||||
<div
|
||||
class="settings-section"
|
||||
data-component="settings-models-provider"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
>
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={expanded()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, expanded())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
classList={{ "-rotate-90 rtl:rotate-90": !expanded() }}
|
||||
/>
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
{(group) => (
|
||||
<div class="settings-section" data-component="settings-models-provider">
|
||||
<div class="settings-models-group-header justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||
<h3 class="settings-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<Switch
|
||||
class="me-6"
|
||||
class="mr-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
@@ -161,28 +137,26 @@ export const DialogManageModels: Component = () => {
|
||||
{group.items[0].provider.name}
|
||||
</Switch>
|
||||
</div>
|
||||
<Show when={expanded()}>
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.name} description="">
|
||||
<div>
|
||||
<Switch
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { type Accessor, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
|
||||
type Store = {
|
||||
user: User[]
|
||||
recent: ModelKey[]
|
||||
variant?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
}
|
||||
|
||||
const createModelsPersistedState = () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
|
||||
return [store, setStore, ready] as const
|
||||
}
|
||||
|
||||
const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const providers = useProviders(() => directory())
|
||||
const models = useGlobal().models
|
||||
const store = models.store
|
||||
const setStore = models.set
|
||||
|
||||
const [store, setStore, ready] = createModelsPersistedState()
|
||||
|
||||
const available = createMemo(() =>
|
||||
providers.connected().flatMap((p) =>
|
||||
@@ -128,14 +148,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
setStore("variant", key, value)
|
||||
}
|
||||
|
||||
const [recentModels] = createResource(
|
||||
async () => {
|
||||
const recent = store.recent
|
||||
await ready.promise
|
||||
return recent
|
||||
},
|
||||
(p) => p,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
return {
|
||||
ready: models.ready,
|
||||
ready,
|
||||
list,
|
||||
find,
|
||||
visible,
|
||||
setVisibility,
|
||||
recent: {
|
||||
list: models.recent,
|
||||
list: () => recentModels()!,
|
||||
push,
|
||||
},
|
||||
variant: {
|
||||
|
||||
@@ -10,10 +10,9 @@ import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/d
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { TextInput } from "@opencode-ai/ui/text-input"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
@@ -21,8 +20,6 @@ import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -47,182 +44,70 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
class?: string
|
||||
onSelect: () => void
|
||||
action?: JSX.Element
|
||||
model?: ModelState
|
||||
}> = (props) => {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: props.onSelect,
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
search: "",
|
||||
active: "",
|
||||
collapsed: {} as Record<string, boolean>,
|
||||
})
|
||||
const models = createMemo(() => controller.models(store.search))
|
||||
const groups = createMemo(() => controller.groups(models()))
|
||||
const expanded = (provider: string) => store.search.length > 0 || !store.collapsed[provider]
|
||||
const visibleModels = () => models().filter((item) => expanded(item.provider.id))
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
|
||||
const setSearch = (value: string) => {
|
||||
const first = controller.models(value).find((item) => value.length > 0 || !store.collapsed[item.provider.id])
|
||||
setStore({ search: value, active: first ? modelKey(first) : "" })
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
const keys = visibleModels().map(modelKey)
|
||||
if (keys.length === 0) return
|
||||
const index = keys.indexOf(store.active)
|
||||
const start = index === -1 ? (delta > 0 ? -1 : 0) : index
|
||||
setStore("active", keys[(start + delta + keys.length) % keys.length])
|
||||
queueMicrotask(() => {
|
||||
scrollRef
|
||||
?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = visibleModels().find((item) => modelKey(item) === store.active)
|
||||
if (item) controller.select(item)
|
||||
}
|
||||
const models = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="shrink-0 px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={store.search}
|
||||
autofocus
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={store.search}>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-tab-search-clear"
|
||||
icon={<Icon name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
<List
|
||||
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
|
||||
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
|
||||
emptyMessage={language.t("dialog.model.empty")}
|
||||
key={(x) => `${x.provider.id}:${x.id}`}
|
||||
items={models}
|
||||
current={model.current()}
|
||||
filterKeys={["provider.name", "name", "id"]}
|
||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||
groupBy={(x) => x.provider.name}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const aProvider = a.items[0].provider.id
|
||||
const bProvider = b.items[0].provider.id
|
||||
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
|
||||
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
|
||||
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
|
||||
}}
|
||||
itemWrapper={(item, node) => (
|
||||
<Tooltip
|
||||
appearance="standard"
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)}
|
||||
onSelect={(x) => {
|
||||
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||
recent: true,
|
||||
})
|
||||
props.onSelect()
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="w-full flex items-center gap-x-2 text-13-regular">
|
||||
<span class="truncate">{i.name}</span>
|
||||
<Show when={isFree(i.provider.id, i.cost)}>
|
||||
<Badge appearance="standard">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={i.latest}>
|
||||
<Badge appearance="standard">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-4 pb-4">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => {
|
||||
const searching = () => store.search.length > 0
|
||||
const open = () => expanded(group.category)
|
||||
|
||||
return (
|
||||
<section class="settings-section" data-expanded={open() ? "" : undefined}>
|
||||
<h3 class="settings-models-group-header">
|
||||
<button
|
||||
type="button"
|
||||
class="settings-models-group-trigger"
|
||||
aria-expanded={open()}
|
||||
disabled={searching()}
|
||||
onClick={() => setStore("collapsed", group.category, open())}
|
||||
>
|
||||
<span class="settings-models-group-chevron">
|
||||
<Icon name="chevron-down" size="small" classList={{ "-rotate-90 rtl:rotate-90": !open() }} />
|
||||
</span>
|
||||
<span class="settings-models-group-label">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="shrink-0" />
|
||||
<span class="settings-section-title">{group.items[0].provider.name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<Show when={open()}>
|
||||
<SettingsList>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="settings-row"
|
||||
data-option-key={modelKey(item)}
|
||||
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => setStore("active", modelKey(item))}
|
||||
onMouseLeave={() => setStore("active", "")}
|
||||
onClick={() => controller.select(item)}
|
||||
>
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="flex items-center gap-2">
|
||||
<Tooltip
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
latest={item.latest}
|
||||
free={isFree(item.provider.id, item.cost)}
|
||||
v2
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.latest")}</Badge>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="settings-row-control" class="size-4">
|
||||
<Show when={controller.current() === modelKey(item)}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -546,25 +431,18 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="large" variant="settings">
|
||||
<DialogHeader hideClose closeLabel={language.t("common.close")}>
|
||||
<Dialog>
|
||||
<DialogHeader hideClose>
|
||||
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||
<Button icon="plus" onClick={provider}>
|
||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<DialogBody>
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<div class="shrink-0 border-t border-v2-border-border-muted px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.manage")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -570,13 +570,10 @@ export const dict = {
|
||||
"toast.update.action.notYet": "Not yet",
|
||||
|
||||
"error.page.title": "Something went wrong",
|
||||
"error.page.title.status": "Server request failed",
|
||||
"error.page.description": "An error occurred while loading the application.",
|
||||
"error.page.description.status": "The server returned an HTTP {{status}} response.",
|
||||
"error.page.description.localServerStartup": "An error occurred while starting the local server.",
|
||||
"error.page.details.label": "Error Details",
|
||||
"error.page.action.restart": "Restart",
|
||||
"error.page.action.reload": "Reload",
|
||||
"error.page.action.report": "Report Error",
|
||||
"error.page.action.reported": "Error Reported",
|
||||
"error.page.action.exportLogs": "Export Logs",
|
||||
@@ -951,8 +948,6 @@ export const dict = {
|
||||
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
|
||||
"settings.general.row.showStatus.title": "Server status",
|
||||
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
|
||||
"settings.general.row.showProjectIcon.title": "Project icon",
|
||||
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
|
||||
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
|
||||
"settings.general.row.mobileTitlebarBottom.description":
|
||||
"Place the title bar and session tabs at the bottom of the screen on mobile",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createOpenCodeEventSource, createServerTransport } from "./client"
|
||||
import { createOpenCodeEventSource } from "./client"
|
||||
|
||||
const permission = {
|
||||
id: "evt_permission",
|
||||
@@ -84,39 +84,3 @@ describe("server event stream", () => {
|
||||
second.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("rotates HTTP and PTY clients together", async () => {
|
||||
const requests: Array<{ url: string; authorization: string | null }> = []
|
||||
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ url: request.url, authorization: request.headers.get("authorization") })
|
||||
return Response.json({ healthy: true, version: "2.0.0-test", pid: 1 })
|
||||
}) as typeof globalThis.fetch
|
||||
const transport = createServerTransport({
|
||||
http: { url: "http://127.0.0.1:4100", username: "opencode", password: "first" },
|
||||
fetch,
|
||||
})
|
||||
const initialPty = transport.pty
|
||||
|
||||
await transport.api.health.get()
|
||||
const replacement = transport.update({
|
||||
url: "http://127.0.0.1:4200",
|
||||
username: "opencode",
|
||||
password: "second",
|
||||
})
|
||||
await transport.api.health.get()
|
||||
|
||||
expect(replacement).toBe(transport.api)
|
||||
expect(transport.pty).not.toBe(initialPty)
|
||||
expect(transport.url).toBe("http://127.0.0.1:4200")
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://127.0.0.1:4100/api/health",
|
||||
authorization: `Basic ${btoa("opencode:first")}`,
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:4200/api/health",
|
||||
authorization: `Basic ${btoa("opencode:second")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -72,12 +72,11 @@ type ServerSDKBase = {
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const transport = createServerTransport({ http: server.http, fetch: platform.fetch })
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const pty = createPtyClient(api, { url: server.http.url })
|
||||
const events = createOpenCodeEventSource()
|
||||
const reconnect = server.type === "sidecar" && server.variant === "base" ? server.reconnect : undefined
|
||||
|
||||
const connection = createClientConnection(transport.api, {
|
||||
reconnect: reconnect ? async (signal) => transport.update(await reconnect(signal)) : undefined,
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
@@ -86,7 +85,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed: !!reconnect, ...data })
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -94,48 +93,14 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
get url() {
|
||||
return transport.url
|
||||
},
|
||||
get api() {
|
||||
return transport.api
|
||||
},
|
||||
get pty() {
|
||||
return transport.pty
|
||||
},
|
||||
url: server.http.url,
|
||||
api,
|
||||
pty,
|
||||
connection,
|
||||
event: events.event,
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
|
||||
update(http: ServerConnection.HttpBase): ServerApi
|
||||
readonly url: string
|
||||
readonly api: ServerApi
|
||||
readonly pty: ReturnType<typeof createPtyClient>
|
||||
} {
|
||||
const build = (http: ServerConnection.HttpBase) => {
|
||||
const api = createApiForServer({ server: http, fetch: input.fetch })
|
||||
return { http, api, pty: createPtyClient(api, { url: http.url }) }
|
||||
}
|
||||
const state = { current: build(input.http) }
|
||||
return {
|
||||
update(http: ServerConnection.HttpBase) {
|
||||
state.current = build(http)
|
||||
return state.current.api
|
||||
},
|
||||
get url() {
|
||||
return state.current.http.url
|
||||
},
|
||||
get api() {
|
||||
return state.current.api
|
||||
},
|
||||
get pty() {
|
||||
return state.current.pty
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerSDK = ServerSDKBase & {
|
||||
ensureDirSdkContext: (directory: string) => ReturnType<typeof createDirSdkContext>
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ export namespace ServerConnection {
|
||||
http: HttpBase
|
||||
} & (
|
||||
| // Regular desktop server
|
||||
{ variant: "base"; reconnect?: (signal: AbortSignal) => Promise<HttpBase> }
|
||||
{ variant: "base" }
|
||||
// WSL server (windows only)
|
||||
| {
|
||||
variant: "wsl"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { Accessor, createEffect, createMemo, createRoot, getOwner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
@@ -10,8 +10,6 @@ import { createData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createServerPermissionState } from "@/session/requests/server-permission"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -26,8 +24,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
serverKey: undefined as ServerConnection.Key | undefined,
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
const notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -52,7 +48,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
if (existing) return existing
|
||||
const serverCtx = createRoot((dispose) => {
|
||||
serverCtxDisposers.set(key, dispose)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||
}, owner)
|
||||
serverCtxs.set(key, serverCtx)
|
||||
return serverCtx
|
||||
@@ -90,7 +86,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
},
|
||||
},
|
||||
models,
|
||||
ensureServerCtx(conn: ServerConnection.Any) {
|
||||
return ensureServerCtx(conn)
|
||||
},
|
||||
@@ -98,42 +93,10 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
|
||||
function createGlobalModels() {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model"),
|
||||
createStore<{
|
||||
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
|
||||
recent: Array<{ providerID: string; modelID: string }>
|
||||
variant?: Record<string, string | undefined>
|
||||
}>({
|
||||
user: [],
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
const [recent] = createResource(
|
||||
async () => {
|
||||
const value = store.recent
|
||||
await ready.promise
|
||||
return value
|
||||
},
|
||||
(value) => value,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
return {
|
||||
store,
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
}
|
||||
}
|
||||
|
||||
function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
|
||||
) {
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
@@ -148,7 +111,7 @@ function createServerController(
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
const permission = createServerPermissionState({ sdk, sync, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
|
||||
@@ -36,10 +36,10 @@ export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryFn: async () => [],
|
||||
})
|
||||
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: () => ServerApi) {
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
path: () => loadPathQuery(scope, null, serverAPI().location),
|
||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.api)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
const connected = () => serverSDK.connection.status() === "connected"
|
||||
|
||||
const [configQuery, pathQuery] = useQueries(() => ({
|
||||
|
||||
@@ -386,7 +386,7 @@
|
||||
padding: 48px 24px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export function createActiveComposerAdapter(input: {
|
||||
if (!id) throw new Error("Active Composer requires a Session ID")
|
||||
|
||||
const prompt = useComposerState()
|
||||
prompt.current()
|
||||
const state = prompt.capture()
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user