mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 09:41:36 -04:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9d35f29c3 | |||
| 6d4a1be23e | |||
| cc15c2a488 | |||
| ebc2504ef3 | |||
| c33c9bf2b9 | |||
| 2970b7a6a8 | |||
| 82d2c6133e | |||
| 22f2604ffa | |||
| a71884dfdf | |||
| 6f629c2a9d | |||
| 4651bd15de | |||
| ad7ebe84a0 | |||
| 38eeed56cd | |||
| b453c2016c | |||
| cb25ed8cc8 | |||
| 838d747514 | |||
| 879766aee7 | |||
| d6625397d9 | |||
| ab77fb080a | |||
| 2a7d0729d0 | |||
| e2d9376614 | |||
| f288d7e107 | |||
| afe4c5d23a | |||
| 98ad4465f8 | |||
| 5b1e8450e7 | |||
| 99490f289b | |||
| e37c7be434 | |||
| 39f4adb4dc | |||
| 393b43a881 | |||
| 394b5ac5fd | |||
| dbc7d0ee09 | |||
| 08f26a2d2e | |||
| a51622a0e7 | |||
| 20ff543ff2 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: plugins that shape the agent conversation do not observe title or compaction requests. Title requests gain the fork-aware session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: deploy-posts
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
paths:
|
||||
- packages/posts/**
|
||||
- bun.lock
|
||||
- .github/workflows/deploy-posts.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-posts-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/posts
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/posts
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-9IJxoe/MdL6nmoeKvxZop+77HJ/b3HbmpytNUvLqYkc=",
|
||||
"aarch64-linux": "sha256-KR1J102RDTRDZIkAD3jQfXeP1G2DN9Es7KkdKo+daec=",
|
||||
"aarch64-darwin": "sha256-HCgSoq1W6XU6m73Ck3wV8gjB687caUTM3w/EO+V7wsE=",
|
||||
"x86_64-darwin": "sha256-GrKTDvjg0XeDIWbOvayWQjj0SdJd8WPm2+q3IVS17Jg="
|
||||
"x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
|
||||
"aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
|
||||
"aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
|
||||
"x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -15,6 +15,7 @@
|
||||
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
|
||||
"dev:www": "bun run --cwd packages/www dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"bench:devex": "bun run --cwd packages/app test:bench:devex",
|
||||
"lint": "oxlint",
|
||||
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/util/src packages/core/src packages/server/src packages/protocol/src packages/cli/src",
|
||||
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
|
||||
@@ -37,10 +38,10 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@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",
|
||||
@@ -71,7 +72,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
|
||||
@@ -309,7 +309,7 @@ Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and
|
||||
|
||||
### Package-like entrypoints
|
||||
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Native catalog integrations load provider behavior through package-like entrypoints. These are export paths from the same `@opencode-ai/ai` npm package, not independently published packages. Each entrypoint exports the same `model(modelID, settings)` contract, and `settings` contains serializable provider configuration plus common `headers` and `body` overlays.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
@@ -317,7 +317,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ToolStream } from "./utils/tool-stream.js"
|
||||
const ADAPTER = "anthropic-messages"
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
export const DEFAULT_MAX_TOKENS = 32_000
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
@@ -624,7 +625,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
@@ -663,7 +663,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? outputLimit,
|
||||
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
|
||||
@@ -289,7 +289,9 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Gemini", message)
|
||||
const previous = contents.at(-1)
|
||||
if (previous?.role === "user")
|
||||
// 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 }] }
|
||||
else contents.push({ role: "user", parts: [{ text: part.text }] })
|
||||
continue
|
||||
|
||||
@@ -93,6 +93,8 @@ export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
@@ -101,6 +103,7 @@ export const InputItem = Schema.Union([
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
@@ -115,6 +118,8 @@ type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
@@ -128,8 +133,6 @@ type OpenResponsesReasoningInput = {
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
name: Schema.String,
|
||||
@@ -159,6 +162,14 @@ export const coreFields = {
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safety_identifier: Schema.optional(Schema.String),
|
||||
stream_options: Schema.optional(
|
||||
Schema.Struct({
|
||||
include_obfuscation: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
top_logprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
|
||||
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
@@ -179,6 +190,8 @@ export const coreFields = {
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
presence_penalty: Schema.optional(Schema.Number),
|
||||
frequency_penalty: Schema.optional(Schema.Number),
|
||||
}
|
||||
|
||||
const OpenResponsesBody = Schema.Struct({
|
||||
@@ -288,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
|
||||
@@ -353,34 +380,42 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
|
||||
tool: (toolName) => ({ type: "function" as const, name: toolName }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||
type: "function_call",
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
})
|
||||
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
|
||||
const metadata = providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
...(id ? { id } : {}),
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
|
||||
return undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (!ProviderShared.isRecord(metadata) || !id) return undefined
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: metadata.itemId,
|
||||
id,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
}
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
return ProviderShared.isRecord(metadata) && typeof metadata.itemId === "string" && metadata.itemId.length > 0
|
||||
? metadata.itemId
|
||||
: undefined
|
||||
return itemID(part.providerMetadata, providerMetadataKey)
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
@@ -465,24 +500,26 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
const groups = content.reduce<
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
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] })
|
||||
return groups
|
||||
}, [])
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
@@ -511,19 +548,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
reasoningItems[reasoning.id] = replay
|
||||
input.push(replay)
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -578,6 +610,12 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
return {
|
||||
...(options.instructions ? { instructions: options.instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
...(options.streamOptions?.includeObfuscation !== undefined
|
||||
? { stream_options: { include_obfuscation: options.streamOptions.includeObfuscation } }
|
||||
: {}),
|
||||
...(options.topLogprobs !== undefined ? { top_logprobs: options.topLogprobs } : {}),
|
||||
...(request.promptCacheKey ? { prompt_cache_key: request.promptCacheKey } : {}),
|
||||
...(options.include ? { include: options.include } : {}),
|
||||
...(options.reasoningEffort || options.reasoningSummary
|
||||
@@ -627,6 +665,8 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...lowerOptions(request),
|
||||
}
|
||||
})
|
||||
@@ -695,7 +735,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
|
||||
}
|
||||
@@ -924,7 +964,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
@@ -937,7 +977,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tools = state.tools[item.id]
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, { id: item.call_id, name: item.name })
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
providerMetadata: providerMetadata(state, { itemId: item.id }),
|
||||
})
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
@@ -988,11 +1032,19 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
})
|
||||
|
||||
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(state.id, state.tools)
|
||||
: { tools: state.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...pending.events]
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
|
||||
@@ -1004,8 +1056,8 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build a single human-readable message from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
@@ -1047,6 +1099,14 @@ export const step = (state: ParserState, event: Event) => {
|
||||
: onOutputTextDone(state, event, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
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: 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))
|
||||
@@ -1074,8 +1134,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return decodeKnownErrorEvent(event).pipe(
|
||||
|
||||
@@ -28,7 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "refusal", "tool_calls"])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
@@ -194,6 +194,7 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||
const OpenAIChatDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
refusal: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
@@ -709,6 +710,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const reasoning = reasoningDelta(delta, state.reasoningField)
|
||||
const hasLateContent =
|
||||
Boolean(delta?.content) ||
|
||||
Boolean(delta?.refusal) ||
|
||||
reasoning !== undefined ||
|
||||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
|
||||
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
|
||||
@@ -728,7 +730,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
!lifecycle.reasoning.has("reasoning-0") &&
|
||||
(Boolean(delta?.content) || toolDeltas.length > 0)
|
||||
(Boolean(delta?.content) || Boolean(delta?.refusal) || toolDeltas.length > 0)
|
||||
)
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
@@ -743,6 +745,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
for (const [position, tool] of toolDeltas.entries()) {
|
||||
|
||||
@@ -42,6 +42,8 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
|
||||
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)),
|
||||
|
||||
@@ -47,9 +47,17 @@ export const AllowedTools = Schema.Struct({
|
||||
})
|
||||
export type AllowedTools = typeof AllowedTools.Type
|
||||
|
||||
export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
instructions: Schema.optional(Schema.String),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
|
||||
@@ -4,11 +4,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
readonly limits?: {
|
||||
readonly context: number
|
||||
readonly input?: number
|
||||
readonly output: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface Definition<
|
||||
|
||||
@@ -90,7 +90,6 @@ const config = (settings: Settings): Config => {
|
||||
credentials: settings.credentials,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,6 @@ const config = (settings: Settings): Config => {
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
|
||||
@@ -75,7 +75,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -110,7 +110,6 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -80,7 +80,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -119,7 +119,6 @@ export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
|
||||
@@ -63,7 +63,6 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -74,7 +74,6 @@ export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsIn
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -124,7 +124,6 @@ const config = (settings: Settings): Config => {
|
||||
baseURL: settings.baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
}
|
||||
|
||||
@@ -200,6 +200,5 @@ export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptio
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -101,7 +101,6 @@ export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
limits: settings.limits,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
export const responses = provider.responses
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
LanguageModel,
|
||||
LanguageModelLimits,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputReason,
|
||||
ProviderID,
|
||||
@@ -74,7 +73,6 @@ export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
|
||||
|
||||
export interface RouteDefaults {
|
||||
readonly headers?: Record<string, string>
|
||||
readonly limits?: LanguageModelLimits
|
||||
readonly generation?: GenerationOptions
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions
|
||||
@@ -82,7 +80,6 @@ export interface RouteDefaults {
|
||||
|
||||
export interface RouteDefaultsInput {
|
||||
readonly headers?: Record<string, string>
|
||||
readonly limits?: LanguageModelLimits.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -119,7 +116,6 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
|
||||
...base,
|
||||
...patch,
|
||||
headers,
|
||||
limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
|
||||
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
|
||||
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
|
||||
http: mergeHttpOptions(
|
||||
|
||||
@@ -114,22 +114,7 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
|
||||
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||
}
|
||||
|
||||
export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
|
||||
context: Schema.optional(Schema.Number),
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
export namespace LanguageModelLimits {
|
||||
export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
|
||||
|
||||
/** Normalize model limit input into the canonical `LanguageModelLimits` class. */
|
||||
export const make = (input: Input | undefined) =>
|
||||
input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
|
||||
}
|
||||
|
||||
export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
|
||||
limits: Schema.optional(LanguageModelLimits),
|
||||
generation: Schema.optional(GenerationOptions),
|
||||
providerOptions: Schema.optional(ProviderOptions),
|
||||
http: Schema.optional(HttpOptions),
|
||||
@@ -139,7 +124,6 @@ export namespace LanguageModelDefaults {
|
||||
export type Input =
|
||||
| LanguageModelDefaults
|
||||
| {
|
||||
readonly limits?: LanguageModelLimits.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly http?: HttpOptions.Input
|
||||
@@ -149,7 +133,6 @@ export namespace LanguageModelDefaults {
|
||||
export const make = (input: Input) => {
|
||||
if (input instanceof LanguageModelDefaults) return input
|
||||
return new LanguageModelDefaults({
|
||||
limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
|
||||
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
|
||||
providerOptions: input.providerOptions,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
|
||||
@@ -270,21 +270,20 @@ describe("request option precedence", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses model output limits after route limits and before call maxTokens", () =>
|
||||
it.effect("uses the Anthropic default before call maxTokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const route = AnthropicMessages.route.with({
|
||||
endpoint: { baseURL: "https://api.anthropic.test/v1/" },
|
||||
auth: Auth.header("x-api-key", "test"),
|
||||
limits: { output: 128 },
|
||||
})
|
||||
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
|
||||
const model = route.model({ id: "claude-sonnet-4-5" })
|
||||
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
||||
const withMaxTokens = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 8_000 } }),
|
||||
)
|
||||
|
||||
expect(withoutMaxTokens.body.max_tokens).toBe(64)
|
||||
expect(withMaxTokens.body.max_tokens).toBe(32)
|
||||
expect(withoutMaxTokens.body.max_tokens).toBe(32_000)
|
||||
expect(withMaxTokens.body.max_tokens).toBe(8_000)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
+9
-5
File diff suppressed because one or more lines are too long
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "openai",
|
||||
"protocol": "openai-responses",
|
||||
"transport": "websocket",
|
||||
"model": "gpt-5.5",
|
||||
"tags": [
|
||||
"prefix:openai-responses-websocket",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"transport:websocket",
|
||||
"tool",
|
||||
"continuation"
|
||||
],
|
||||
"name": "openai-responses-websocket/continues-a-tool-call-over-one-socket",
|
||||
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "websocket",
|
||||
"connection": {
|
||||
"sequence": 0,
|
||||
"url": "wss://api.openai.com/v1/responses",
|
||||
"protocols": [],
|
||||
"close": {
|
||||
"code": 1000,
|
||||
"reason": ""
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.function_call_arguments.delta\",\"item_id\":\"fc_ws_weather\",\"delta\":\"{\\\"city\\\":\\\"Paris\\\"}\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",\"id\":\"fc_ws_weather\",\"call_id\":\"call_ws_weather\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_1\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_tool_2\",\"delta\":\"Paris is sunny.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_tool_2\",\"text\":\"Paris is sunny.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_tool_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Paris is sunny.\"}]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_tool_2\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "openai",
|
||||
"protocol": "openai-responses",
|
||||
"transport": "websocket",
|
||||
"model": "gpt-5.5",
|
||||
"tags": [
|
||||
"prefix:openai-responses-websocket",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"transport:websocket",
|
||||
"reconnect",
|
||||
"full-context"
|
||||
],
|
||||
"name": "openai-responses-websocket/reconstructs-full-context-after-reconnect",
|
||||
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "websocket",
|
||||
"connection": {
|
||||
"sequence": 0,
|
||||
"url": "wss://api.openai.com/v1/responses",
|
||||
"protocols": [],
|
||||
"close": {
|
||||
"code": 1000,
|
||||
"reason": ""
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_1\",\"delta\":\"Alpha.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_1\",\"text\":\"Alpha.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_1\"}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"transport": "websocket",
|
||||
"connection": {
|
||||
"sequence": 1,
|
||||
"url": "wss://api.openai.com/v1/responses",
|
||||
"protocols": [],
|
||||
"close": {
|
||||
"code": 1000,
|
||||
"reason": ""
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_reconnect_2\",\"delta\":\"Beta.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_reconnect_2\",\"text\":\"Beta.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_reconnect_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Beta.\"}]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_reconnect_2\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "openai",
|
||||
"protocol": "openai-responses",
|
||||
"transport": "websocket",
|
||||
"model": "gpt-5.5",
|
||||
"tags": [
|
||||
"prefix:openai-responses-websocket",
|
||||
"provider:openai",
|
||||
"protocol:openai-responses",
|
||||
"transport:websocket",
|
||||
"continuation",
|
||||
"recovery"
|
||||
],
|
||||
"name": "openai-responses-websocket/recovers-from-explicit-continuation-rejection",
|
||||
"recordedAt": "2026-08-20T00:00:00.000Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "websocket",
|
||||
"connection": {
|
||||
"sequence": 0,
|
||||
"url": "wss://api.openai.com/v1/responses",
|
||||
"protocols": [],
|
||||
"close": {
|
||||
"code": 1000,
|
||||
"reason": ""
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_1\",\"delta\":\"Ready.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_1\",\"text\":\"Ready.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_1\"}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"transport": "websocket",
|
||||
"connection": {
|
||||
"sequence": 1,
|
||||
"url": "wss://api.openai.com/v1/responses",
|
||||
"protocols": [],
|
||||
"close": {
|
||||
"code": 1000,
|
||||
"reason": ""
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"error\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response not found\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.created\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.added\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.delta\",\"item_id\":\"msg_ws_rejection_2\",\"delta\":\"Recovered.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_text.done\",\"item_id\":\"msg_ws_rejection_2\",\"text\":\"Recovered.\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"id\":\"msg_ws_rejection_2\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Recovered.\"}]}}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ws_rejection_2\"}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+6
-6
File diff suppressed because one or more lines are too long
Vendored
+57
File diff suppressed because one or more lines are too long
@@ -121,7 +121,6 @@ describe("llm constructors", () => {
|
||||
const model = chatRoute.model({
|
||||
id: "kimi-k2",
|
||||
defaults: {
|
||||
limits: { context: 128_000, output: 8_192 },
|
||||
generation: { maxTokens: 1_024, stop: ["END"] },
|
||||
providerOptions: { parallelToolCalls: false },
|
||||
http: { body: { extra_body: true } },
|
||||
@@ -130,7 +129,6 @@ describe("llm constructors", () => {
|
||||
})
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
|
||||
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
|
||||
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
|
||||
expect(request.model.defaults?.providerOptions).toEqual({ parallelToolCalls: false })
|
||||
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
|
||||
|
||||
@@ -43,7 +43,6 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://provider.example.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
@@ -58,7 +57,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||
expect(selected.route.defaults.limits).toEqual(settings.limits)
|
||||
}
|
||||
expect(openrouter.route.defaults.providerOptions).toEqual({ usage: true })
|
||||
expect(xai.route.defaults.providerOptions).toMatchObject({ reasoningEffort: "high", store: false })
|
||||
@@ -70,14 +68,12 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
unrelatedInheritedSetting: true,
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("openai-responses")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
})
|
||||
|
||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||
@@ -88,7 +84,6 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { reasoningEffort: "low", store: true },
|
||||
})
|
||||
|
||||
@@ -100,7 +95,6 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
|
||||
})
|
||||
|
||||
@@ -112,7 +106,6 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
providerOptions: { effort: "low" },
|
||||
})
|
||||
|
||||
@@ -124,7 +117,6 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ metadata: { user_id: "user_1" } })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ effort: "low" })
|
||||
})
|
||||
|
||||
@@ -185,7 +177,6 @@ describe("provider package entrypoints", () => {
|
||||
resourceName: "opencode-test",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
}
|
||||
|
||||
const responses = AzureResponses.model("deployment", settings)
|
||||
@@ -196,7 +187,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(responses.route.endpoint.baseURL).toBe("https://opencode-test.openai.azure.com/openai/v1")
|
||||
expect(responses.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(responses.route.defaults.http?.body).toEqual({ service_tier: "priority" })
|
||||
expect(responses.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
expect(chat.route.id).toBe("azure-openai-chat")
|
||||
})
|
||||
|
||||
@@ -228,7 +218,6 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
})
|
||||
|
||||
@@ -236,7 +225,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://generativelanguage.test/v1beta")
|
||||
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(selected.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinkingConfig: { thinkingBudget: 1_024 } })
|
||||
})
|
||||
|
||||
@@ -250,7 +238,6 @@ describe("provider package entrypoints", () => {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
limits: { context: 1_000_000, output: 65_536 },
|
||||
})
|
||||
const messages = GoogleVertexMessages.model("claude-sonnet-4-6", {
|
||||
accessToken: "fixture",
|
||||
@@ -274,7 +261,6 @@ describe("provider package entrypoints", () => {
|
||||
expect(gemini.route.endpoint.baseURL).toBe("https://aiplatform.googleapis.com/v1/publishers/google")
|
||||
expect(gemini.route.defaults.headers).toEqual({ "x-application": "opencode" })
|
||||
expect(gemini.route.defaults.http?.body).toEqual({ safetySettings: [] })
|
||||
expect(gemini.route.defaults.limits).toEqual({ context: 1_000_000, output: 65_536 })
|
||||
expect(
|
||||
GoogleVertex.model("gemini-3.5-flash", {
|
||||
accessToken: "fixture",
|
||||
|
||||
@@ -322,7 +322,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
|
||||
],
|
||||
stream: true,
|
||||
max_tokens: 4096,
|
||||
max_tokens: 32_000,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -139,6 +139,48 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps system updates separate from function responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "done", resultType: "text" }),
|
||||
Message.system("Update."),
|
||||
Message.system("Later update."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "done" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ text: "<system-update>\nUpdate.\n</system-update>" },
|
||||
{ text: "<system-update>\nLater update.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -664,6 +664,74 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves streamed refusals as ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ role: "assistant", refusal: "I can't" }),
|
||||
deltaChunk({ refusal: " help with that." }),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("I can't help with that.")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "I can't help with that." }])
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "I can't help with that." }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("orders metadata-only reasoning before refusal output", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: [] } }] },
|
||||
deltaChunk({ refusal: "I can't help with that." }),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: [] } } },
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins content and refusal deltas into ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ refusal: "No." }),
|
||||
deltaChunk({ content: " Alternative." }),
|
||||
deltaChunk({ refusal: " Still no." }),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("No. Alternative. Still no.")
|
||||
expect(response.events.filter(LLMEvent.is.textStart).map((event) => event.id)).toEqual(["text-0"])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.id)).toEqual(["text-0"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("Open Responses-compatible route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -113,11 +113,67 @@ describe("Open Responses-compatible route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
input: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves standard refusal content as ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Unsafe request" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "message", id: "msg_refusal", content: [] },
|
||||
},
|
||||
{
|
||||
type: "response.refusal.done",
|
||||
item_id: "msg_refusal",
|
||||
refusal: "I can't help with that.",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
content: [{ type: "refusal", refusal: "I can't help with that." }],
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
providerMetadata: { openresponses: { itemId: "msg_refusal" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard Open Responses options", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -126,6 +182,10 @@ describe("Open Responses-compatible route", () => {
|
||||
providerOptions: {
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
metadata: { environment: "test" },
|
||||
safetyIdentifier: "user_123",
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 3,
|
||||
truncation: "auto",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
@@ -136,6 +196,7 @@ describe("Open Responses-compatible route", () => {
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Think.",
|
||||
generation: { presencePenalty: 0.2, frequencyPenalty: -0.1 },
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
)
|
||||
@@ -143,6 +204,12 @@ describe("Open Responses-compatible route", () => {
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
store: true,
|
||||
metadata: { environment: "test" },
|
||||
safety_identifier: "user_123",
|
||||
stream_options: { include_obfuscation: false },
|
||||
top_logprobs: 3,
|
||||
presence_penalty: 0.2,
|
||||
frequency_penalty: -0.1,
|
||||
truncation: "auto",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { LLM, LLMRequest, Message, ToolRuntime } from "../../src/index.js"
|
||||
import {
|
||||
LLMClient,
|
||||
WebSocketTransport,
|
||||
type ChannelCheckpoint,
|
||||
type ChannelObservation,
|
||||
type WebSocketChannelExchange,
|
||||
type WebSocketChannelExecutor,
|
||||
type WebSocketConnection,
|
||||
} from "../../src/route.js"
|
||||
import { configure } from "../../src/providers/openai.js"
|
||||
import { decodeJson } from "../../src/protocols/shared.js"
|
||||
import { weatherRuntimeTool, weatherTool, weatherToolName } from "../recorded-scenarios.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const model = configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.5")
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-websocket",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
tags: ["transport:websocket"],
|
||||
metadata: { transport: "websocket", model: model.id },
|
||||
})
|
||||
|
||||
const observationFrame = (observation: ChannelObservation) => {
|
||||
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||
return Effect.succeed(observation.frame)
|
||||
return Effect.fail(observation.error)
|
||||
}
|
||||
|
||||
const terminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
// This deliberately models only sequential test traffic. Core owns production connection pooling and recovery.
|
||||
const makeChannel = Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
let connection: WebSocketConnection | undefined
|
||||
let checkpoint: ChannelCheckpoint | undefined
|
||||
let pending: ChannelCheckpoint | undefined
|
||||
let opens = 0
|
||||
const sent: unknown[] = []
|
||||
|
||||
const close = Effect.suspend(() => {
|
||||
const current = connection
|
||||
connection = undefined
|
||||
return current ? current.close : Effect.void
|
||||
})
|
||||
yield* Effect.addFinalizer(() => close)
|
||||
|
||||
const executor: WebSocketChannelExecutor = {
|
||||
execute: (exchange: WebSocketChannelExchange) =>
|
||||
Effect.gen(function* () {
|
||||
if (!connection) {
|
||||
connection = yield* WebSocketTransport.open(exchange.connect).pipe(
|
||||
Effect.provideService(Socket.WebSocketConstructor, constructor),
|
||||
)
|
||||
opens += 1
|
||||
}
|
||||
const current = connection
|
||||
const create = yield* exchange.driver.create(checkpoint)
|
||||
if (create.mode === "full") checkpoint = undefined
|
||||
pending = undefined
|
||||
sent.push(decodeJson(create.message))
|
||||
yield* current.sendText(create.message)
|
||||
const decoder = new TextDecoder()
|
||||
return {
|
||||
frames: current.messages.pipe(
|
||||
Stream.map((message) => WebSocketTransport.messageText(message, decoder)),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
if (!terminal(observation)) return
|
||||
pending = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (observation.type !== "completed") checkpoint = undefined
|
||||
}),
|
||||
),
|
||||
Stream.takeUntil(terminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
),
|
||||
complete: Effect.sync(() => {
|
||||
checkpoint = pending
|
||||
pending = undefined
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
return {
|
||||
executor,
|
||||
sent,
|
||||
opens: () => opens,
|
||||
reconnect: (preserveCheckpoint = false) =>
|
||||
close.pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
pending = undefined
|
||||
if (!preserveCheckpoint) checkpoint = undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
describe("OpenAI Responses WebSocket recorded", () => {
|
||||
recorded.effect.with("continues a tool call over one socket", { tags: ["tool", "continuation"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* makeChannel
|
||||
const request = LLM.request({
|
||||
id: "recorded_openai_responses_websocket_tool",
|
||||
model,
|
||||
system: "Call get_weather once, then reply exactly: Paris is sunny.",
|
||||
prompt: "What is the weather in Paris?",
|
||||
tools: [weatherTool],
|
||||
generation: { maxTokens: 50 },
|
||||
cache: "none",
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||
const call = first.toolCalls[0]
|
||||
if (!call) yield* Effect.die("Expected get_weather tool call")
|
||||
const result = yield* ToolRuntime.dispatch({ [weatherToolName]: weatherRuntimeTool }, call)
|
||||
const second = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: result.result }),
|
||||
],
|
||||
}),
|
||||
{ webSocket: channel.executor },
|
||||
)
|
||||
|
||||
expect(second.text).toBe("Paris is sunny.")
|
||||
expect(channel.opens()).toBe(1)
|
||||
expect(channel.sent).toHaveLength(2)
|
||||
expect(channel.sent[1]).toMatchObject({
|
||||
previous_response_id: expect.any(String),
|
||||
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("reconstructs full context after reconnect", { tags: ["reconnect", "full-context"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* makeChannel
|
||||
const request = LLM.request({
|
||||
id: "recorded_openai_responses_websocket_reconnect",
|
||||
model,
|
||||
system: "Follow the user's exact reply instruction.",
|
||||
prompt: "Reply exactly: Alpha.",
|
||||
generation: { maxTokens: 30 },
|
||||
cache: "none",
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||
yield* channel.reconnect()
|
||||
const second = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [...request.messages, first.message, Message.user("Reply exactly: Beta.")],
|
||||
}),
|
||||
{ webSocket: channel.executor },
|
||||
)
|
||||
|
||||
expect(first.text).toBe("Alpha.")
|
||||
expect(second.text).toBe("Beta.")
|
||||
expect(channel.opens()).toBe(2)
|
||||
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
|
||||
expect(channel.sent[1]).toMatchObject({
|
||||
input: [
|
||||
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("recovers from explicit continuation rejection", { tags: ["continuation", "recovery"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const channel = yield* makeChannel
|
||||
const request = LLM.request({
|
||||
id: "recorded_openai_responses_websocket_rejection",
|
||||
model,
|
||||
system: "Follow the user's exact reply instruction.",
|
||||
prompt: "Reply exactly: Ready.",
|
||||
generation: { maxTokens: 30 },
|
||||
cache: "none",
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket: channel.executor })
|
||||
const continuation = LLMRequest.update(request, {
|
||||
messages: [...request.messages, first.message, Message.user("Reply exactly: Recovered.")],
|
||||
})
|
||||
yield* channel.reconnect(true)
|
||||
const rejected = yield* LLMClient.generate(continuation, { webSocket: channel.executor }).pipe(Effect.flip)
|
||||
const recovered = yield* LLMClient.generate(continuation, { webSocket: channel.executor })
|
||||
|
||||
expect(rejected).toMatchObject({
|
||||
reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" },
|
||||
})
|
||||
expect(recovered.text).toBe("Recovered.")
|
||||
expect(channel.opens()).toBe(2)
|
||||
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
|
||||
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
|
||||
expect(channel.sent[2]).toMatchObject({
|
||||
input: [
|
||||
{ role: "system", content: "Follow the user's exact reply instruction." },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -263,7 +263,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -485,7 +485,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
|
||||
it.effect("continues store-false reasoning while retaining the output item ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
|
||||
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
|
||||
@@ -515,6 +515,7 @@ describe("OpenAI Responses route", () => {
|
||||
...firstInput,
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
@@ -1284,6 +1285,7 @@ describe("OpenAI Responses route", () => {
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
promptCacheKey: "session_123",
|
||||
generation: { presencePenalty: 0.25, frequencyPenalty: -0.25 },
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
|
||||
@@ -1293,6 +1295,10 @@ describe("OpenAI Responses route", () => {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
metadata: { environment: "test", tenant: "acme" },
|
||||
safetyIdentifier: "user_123",
|
||||
streamOptions: { includeObfuscation: false },
|
||||
topLogprobs: 5,
|
||||
truncation: "disabled",
|
||||
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
|
||||
maxToolCalls: 4,
|
||||
@@ -1306,6 +1312,12 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
||||
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
||||
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
|
||||
expect(prepared.body.safety_identifier).toBe("user_123")
|
||||
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
|
||||
expect(prepared.body.top_logprobs).toBe(5)
|
||||
expect(prepared.body.presence_penalty).toBe(0.25)
|
||||
expect(prepared.body.frequency_penalty).toBe(-0.25)
|
||||
expect(prepared.body.truncation).toBe("disabled")
|
||||
expect(prepared.body.tool_choice).toEqual({
|
||||
type: "allowed_tools",
|
||||
@@ -1473,7 +1485,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
@@ -1494,6 +1506,108 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves standard refusal content as ordinary assistant text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "message", id: "msg_refusal", content: [] },
|
||||
},
|
||||
{
|
||||
type: "response.content_part.added",
|
||||
item_id: "msg_refusal",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
part: { type: "refusal", refusal: "" },
|
||||
},
|
||||
{
|
||||
type: "response.refusal.delta",
|
||||
item_id: "msg_refusal",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: "I can't",
|
||||
},
|
||||
{
|
||||
type: "response.refusal.delta",
|
||||
item_id: "msg_refusal",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: " help with that.",
|
||||
},
|
||||
{
|
||||
type: "response.refusal.done",
|
||||
item_id: "msg_refusal",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
refusal: "I can't help with that.",
|
||||
},
|
||||
{
|
||||
type: "response.content_part.done",
|
||||
item_id: "msg_refusal",
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
part: { type: "refusal", refusal: "I can't help with that." },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
phase: "final_answer",
|
||||
content: [{ type: "refusal", refusal: "I can't help with that." }],
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("I can't help with that.")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: undefined })
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "I can't help with that.",
|
||||
providerMetadata: { openai: { itemId: "msg_refusal", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_refusal",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I can't help with that." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed refusal events", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.refusal.delta", output_index: 0, content_index: 0, delta: "missing item" },
|
||||
{ type: "response.refusal.delta", item_id: "msg_1", output_index: 0, content_index: 0 },
|
||||
{ type: "response.refusal.done", item_id: "msg_1", output_index: 0, content_index: 0 },
|
||||
]
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1532,33 +1646,39 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
providerMetadata: { openai: { itemId: "msg_null", phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_commentary",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_final",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_null",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
@@ -1652,12 +1772,12 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_1", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
{ type: "text-delta", id: "msg_1", text: "First" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "text-start", id: "msg_2" },
|
||||
{ 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" },
|
||||
{ type: "text-end", id: "msg_2" },
|
||||
{ type: "text-end", id: "msg_2", providerMetadata: { openai: { itemId: "msg_2" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1689,7 +1809,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: "Hello", providerMetadata: { openai: { itemId: "msg_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -1850,6 +1970,7 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||
},
|
||||
@@ -1857,7 +1978,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||
],
|
||||
})
|
||||
expect(body.input[1]).not.toHaveProperty("id")
|
||||
return input.respond(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||
@@ -1901,13 +2021,14 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -2039,6 +2160,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
@@ -2171,6 +2293,50 @@ describe("OpenAI Responses route", () => {
|
||||
usage,
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes a pending function call at response completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => LLMEvent.is.toolInputEnd(event) || LLMEvent.is.toolCall(event))).toEqual(
|
||||
[
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: {},
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
],
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2237,6 +2403,35 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains function call item metadata when output_item.added is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
id: "call_1",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Layer } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
@@ -16,7 +18,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -69,7 +71,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
...metadata,
|
||||
}
|
||||
if (recording) {
|
||||
if (process.env.CI !== undefined) throw new Error("Unset CI before recording HTTP cassettes")
|
||||
if (process.env.CI !== undefined) throw new Error("Unset CI before recording cassettes")
|
||||
HttpRecorder.removeCassetteSync(cassette, { directory: FIXTURES_DIR })
|
||||
}
|
||||
const requestExecutor = RequestExecutor.layer.pipe(
|
||||
@@ -81,10 +83,16 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
}),
|
||||
),
|
||||
)
|
||||
const webSocket = HttpRecorder.layerWebSocketConstructor(cassette, {
|
||||
...recorderOptions,
|
||||
directory: FIXTURES_DIR,
|
||||
metadata: recorderMetadata,
|
||||
}).pipe(Layer.provide(NodeSocket.layerWebSocketConstructorWS))
|
||||
return Layer.mergeAll(
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
webSocket,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,12 +2,34 @@
|
||||
|
||||
The app's high-volume performance diagnostics live under `packages/app/e2e/performance` and are excluded from normal local and CI Playwright discovery. The benchmark config builds the app and serves the production bundle before running scenarios serially.
|
||||
|
||||
The `devex` category is the explicit exception to the production-build rule. It measures development commands from submission through a user-visible ready state and has its own Playwright configuration.
|
||||
|
||||
Run the suite explicitly from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run test:bench
|
||||
```
|
||||
|
||||
Run the desktop development startup benchmark from the repository root:
|
||||
|
||||
```sh
|
||||
bun run bench:devex
|
||||
```
|
||||
|
||||
It runs five serial samples of the exact `bun dev:desktop` command. Each sample uses a fresh desktop profile, database, service configuration, service registration, and service process; the desktop selects an isolated ephemeral loopback endpoint. It removes desktop build output and the desktop Vite cache before every run; dependencies, Bun's package cache, and Electron remain installed. The harness stops only that sample's service; it does not stop or change the elected global OpenCode service. The measured endpoint is a visible Home page whose empty-state controls pass Playwright actionability checks. The command's Electron installation check remains inside the measured interval.
|
||||
|
||||
Set `DESKTOP_STARTUP_RUNS` only for focused diagnostics:
|
||||
|
||||
```sh
|
||||
DESKTOP_STARTUP_RUNS=1 bun run bench:devex
|
||||
```
|
||||
|
||||
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to capture the renderer's CDP trace from attachment through actionable Home:
|
||||
|
||||
```sh
|
||||
DESKTOP_STARTUP_RUNS=1 OPENCODE_PERFORMANCE_TRACE_DIR=/tmp/opencode-desktop-traces bun run bench:devex
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -18,9 +18,9 @@ const categories = [
|
||||
"disabled-by-default-v8.cpu_profiler",
|
||||
]
|
||||
|
||||
export async function startChromeTrace(page: Page, name: string) {
|
||||
export async function startChromeTrace(page: Page, name: string): Promise<undefined | (() => Promise<string>)> {
|
||||
const directory = process.env.OPENCODE_PERFORMANCE_TRACE_DIR
|
||||
if (!directory) return
|
||||
if (!directory) return undefined
|
||||
|
||||
const selectors = process.env.OPENCODE_PERFORMANCE_SELECTOR_TRACE === "1"
|
||||
const file = await prepareChromeTrace(directory, name, selectors)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { benchmark } from "../benchmark"
|
||||
import {
|
||||
desktopBenchmarkContext,
|
||||
runDesktopStartup,
|
||||
summarizeDesktopStartup,
|
||||
type DesktopStartupSample,
|
||||
} from "./desktop-startup"
|
||||
|
||||
benchmark.describe("devex: desktop startup", () => {
|
||||
benchmark("opens a cold desktop on Home", async ({ report }, testInfo) => {
|
||||
benchmark.setTimeout(15 * 60_000)
|
||||
const runs = Number(process.env.DESKTOP_STARTUP_RUNS ?? 5)
|
||||
if (!Number.isSafeInteger(runs) || runs < 1) throw new Error("DESKTOP_STARTUP_RUNS must be a positive integer")
|
||||
|
||||
const samples: DesktopStartupSample[] = []
|
||||
const context = await desktopBenchmarkContext(runs)
|
||||
for (let run = 1; run <= runs; run++) {
|
||||
const sample = await runDesktopStartup(run, testInfo).catch((error) => {
|
||||
report(samples.length ? { samples, summary: summarizeDesktopStartup(samples) } : { samples }, context)
|
||||
throw error
|
||||
})
|
||||
samples.push(sample)
|
||||
}
|
||||
report({ samples, summary: summarizeDesktopStartup(samples) }, context)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,526 @@
|
||||
import { Service } from "@opencode-ai/client/service"
|
||||
import { chromium, expect, type Browser, type Page, type TestInfo } from "@playwright/test"
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process"
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
import { startChromeTrace } from "../chrome-trace"
|
||||
|
||||
const repository = resolve(import.meta.dirname, "../../../../..")
|
||||
const milestones = [
|
||||
"bunRootScript",
|
||||
"bunDesktopScript",
|
||||
"desktopPrepared",
|
||||
"mainBundleReady",
|
||||
"preloadBundleReady",
|
||||
"rendererDevServerReady",
|
||||
"electronSpawnStarted",
|
||||
"debugEndpointReady",
|
||||
"electronStarted",
|
||||
"serviceEnsureStarted",
|
||||
"serviceSpawnRequested",
|
||||
"serviceReady",
|
||||
"backgroundLoadingReady",
|
||||
"rendererViteConnected",
|
||||
"rendererInitializationStarted",
|
||||
"rendererInitializationReady",
|
||||
"windowVisible",
|
||||
"homeReady",
|
||||
] as const
|
||||
const phases = [
|
||||
"desktopPreparation",
|
||||
"viteMainBundle",
|
||||
"vitePreloadBundle",
|
||||
"rendererServerStartup",
|
||||
"electronStartup",
|
||||
"serviceSpawnWait",
|
||||
"serviceProcessStartup",
|
||||
"rendererStartup",
|
||||
"visibleWindowToHome",
|
||||
] as const
|
||||
|
||||
type Milestone = (typeof milestones)[number]
|
||||
type Phase = (typeof phases)[number]
|
||||
type ServiceInfo = { id: string; version: string; url: string; pid: number }
|
||||
|
||||
export type DesktopStartupSample = {
|
||||
run: number
|
||||
commandToHomeReadyMs: number
|
||||
milestonesMs: Record<Milestone, number>
|
||||
phasesMs: Record<Phase, number>
|
||||
service: Omit<ServiceInfo, "id">
|
||||
}
|
||||
|
||||
export async function runDesktopStartup(run: number, testInfo: TestInfo) {
|
||||
const profile = await createColdProfile()
|
||||
const desktop = await Promise.resolve()
|
||||
.then(() => startDesktop(profile))
|
||||
.catch(async (error) => {
|
||||
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
throw error
|
||||
})
|
||||
try {
|
||||
const page = await desktop.open()
|
||||
const stopTrace = await startChromeTrace(page, `desktop-startup-${run}`)
|
||||
try {
|
||||
await startThemeObservation(page)
|
||||
await waitForHome(page, desktop.mark)
|
||||
await requireStableTheme(page)
|
||||
return await desktop.result(run)
|
||||
} finally {
|
||||
await stopTrace?.()
|
||||
}
|
||||
} finally {
|
||||
await desktop.close(testInfo, run)
|
||||
}
|
||||
}
|
||||
|
||||
export async function desktopBenchmarkContext(runs: number) {
|
||||
const pkg = JSON.parse(await readFile(join(repository, "packages/desktop/package.json"), "utf8"))
|
||||
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository })
|
||||
if (revision.status !== 0) throw new Error("Failed to read the benchmark Git revision")
|
||||
const status = spawnSync("git", ["status", "--porcelain"], { cwd: repository })
|
||||
if (status.status !== 0) throw new Error("Failed to read the benchmark Git status")
|
||||
const bun = spawnSync("bun", ["--version"], { cwd: repository })
|
||||
if (bun.status !== 0) throw new Error("Failed to read the benchmark Bun version")
|
||||
return {
|
||||
arch: process.arch,
|
||||
command: "bun dev:desktop",
|
||||
runs,
|
||||
profile: "fresh",
|
||||
service: "isolated-cold",
|
||||
install: "complete",
|
||||
viteCache: "cold",
|
||||
electronInstall: "present",
|
||||
bunVersion: bun.stdout.toString().trim(),
|
||||
electronVersion: pkg.devDependencies.electron,
|
||||
electronViteVersionRange: pkg.devDependencies["electron-vite"],
|
||||
gitCommit: revision.stdout.toString().trim(),
|
||||
gitDirty: status.stdout.length > 0,
|
||||
trace: Boolean(process.env.OPENCODE_PERFORMANCE_TRACE_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeDesktopStartup(samples: DesktopStartupSample[]) {
|
||||
return {
|
||||
commandToHomeReadyMs: statistics(samples.map((sample) => sample.commandToHomeReadyMs)),
|
||||
milestonesMs: Object.fromEntries(
|
||||
milestones.map((name) => [name, statistics(samples.map((sample) => sample.milestonesMs[name]))]),
|
||||
),
|
||||
phasesMs: Object.fromEntries(
|
||||
phases.map((name) => [name, statistics(samples.map((sample) => sample.phasesMs[name]))]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function milestoneForLine(line: string): Milestone | undefined {
|
||||
const text = stripAnsi(line)
|
||||
return milestonePatterns.find((item) => text.includes(item.text))?.name
|
||||
}
|
||||
|
||||
const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
|
||||
{ name: "bunRootScript", text: "$ bun --cwd packages/desktop dev" },
|
||||
{ name: "bunDesktopScript", text: "$ bun ./scripts/dev.ts" },
|
||||
{ name: "desktopPrepared", text: "Copied dev icons from" },
|
||||
{ name: "mainBundleReady", text: "electron main process built successfully" },
|
||||
{ name: "preloadBundleReady", text: "electron preload scripts built successfully" },
|
||||
{ name: "rendererDevServerReady", text: "dev server running for the electron renderer process at:" },
|
||||
{ name: "electronSpawnStarted", text: "starting electron app..." },
|
||||
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
|
||||
{ name: "electronStarted", text: "app starting" },
|
||||
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
|
||||
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
|
||||
{ name: "serviceReady", text: "v2 CLI background service ready" },
|
||||
{ name: "backgroundLoadingReady", text: "loading task finished" },
|
||||
{ name: "rendererViteConnected", text: "[vite] connected." },
|
||||
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
|
||||
{ name: "rendererInitializationReady", text: "server ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
]
|
||||
|
||||
async function createColdProfile() {
|
||||
await Promise.all(
|
||||
["packages/desktop/node_modules/.vite", "packages/desktop/out"].map((path) =>
|
||||
rm(join(repository, path), { recursive: true, force: true }),
|
||||
),
|
||||
)
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-desktop-startup-"))
|
||||
return initializeColdProfile(root).catch(async (error) => {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async function initializeColdProfile(root: string) {
|
||||
await Promise.all(
|
||||
["data", "config", "cache", "state", "desktop", "session", "home"].map((dir) =>
|
||||
mkdir(join(root, dir), { recursive: true }),
|
||||
),
|
||||
)
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(root, "desktop", "opencode.settings"),
|
||||
JSON.stringify({ firstLaunchOnboardingComplete: true }),
|
||||
),
|
||||
writeFile(join(root, "desktop", "opencode.global.dat"), JSON.stringify({ language: '{"locale":"en"}' })),
|
||||
])
|
||||
const registration = join(root, "desktop", "opencode", "service-local.json")
|
||||
await Service.stop({ file: registration })
|
||||
return { root, registration }
|
||||
}
|
||||
|
||||
function startDesktop(profile: Awaited<ReturnType<typeof createColdProfile>>) {
|
||||
const started = performance.now()
|
||||
const child = spawn("bun", ["dev:desktop"], {
|
||||
cwd: repository,
|
||||
detached: process.platform !== "win32",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_CONFIG_DIR: join(profile.root, "config"),
|
||||
OPENCODE_DB: join(profile.root, "data", "opencode.db"),
|
||||
OPENCODE_TEST_HOME: join(profile.root, "home"),
|
||||
OPENCODE_TEST_ONBOARDING: "0",
|
||||
OPENCODE_DESKTOP_TEST_ROOT: profile.root,
|
||||
OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT: "0",
|
||||
OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION: "1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
if (!child.pid || !child.stdout || !child.stderr) throw new Error("Failed to start the desktop command")
|
||||
const exited = childExit(child)
|
||||
const observed: Partial<Record<Milestone, number>> = {}
|
||||
const endpoint = Promise.withResolvers<string>()
|
||||
const pageErrors: string[] = []
|
||||
let browser: Browser | undefined
|
||||
let service: ServiceInfo | undefined
|
||||
const mark = (name: Milestone) => {
|
||||
observed[name] ??= elapsed(started)
|
||||
}
|
||||
const record = (line: string) => {
|
||||
const milestone = milestoneForLine(line)
|
||||
if (milestone) mark(milestone)
|
||||
const match = stripAnsi(line).match(/DevTools listening on (ws:\/\/\S+)/)
|
||||
if (match?.[1]) endpoint.resolve(match[1])
|
||||
}
|
||||
const stdout = observeOutput(child.stdout, record)
|
||||
const stderr = observeOutput(child.stderr, record)
|
||||
|
||||
return {
|
||||
mark,
|
||||
async open() {
|
||||
const url = await Promise.race([
|
||||
endpoint.promise,
|
||||
exited.then((code) => {
|
||||
throw new Error(`Desktop command exited with code ${code} before opening its debug endpoint`)
|
||||
}),
|
||||
sleep(120_000).then(() => {
|
||||
throw new Error("Timed out waiting for the desktop debug endpoint")
|
||||
}),
|
||||
])
|
||||
browser = await chromium.connectOverCDP(url, { timeout: 120_000 })
|
||||
const context = browser.contexts()[0]
|
||||
if (!context) throw new Error("Electron did not expose a browser context")
|
||||
await expect.poll(() => context.pages().length, { timeout: 120_000 }).toBeGreaterThan(0)
|
||||
const page = context.pages()[0]
|
||||
if (!page) throw new Error("Electron did not expose a renderer page")
|
||||
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message))
|
||||
return page
|
||||
},
|
||||
async result(run: number): Promise<DesktopStartupSample> {
|
||||
if (pageErrors.length) throw new Error(`Desktop renderer reported errors:\n\n${pageErrors.join("\n\n")}`)
|
||||
service = await readService(profile)
|
||||
const milestonesMs = requireMilestones(observed)
|
||||
return {
|
||||
run,
|
||||
commandToHomeReadyMs: milestonesMs.homeReady,
|
||||
milestonesMs,
|
||||
phasesMs: calculatePhases(milestonesMs),
|
||||
service: {
|
||||
version: service.version,
|
||||
url: service.url,
|
||||
pid: service.pid,
|
||||
},
|
||||
}
|
||||
},
|
||||
async close(testInfo: TestInfo, run: number) {
|
||||
const errors: unknown[] = []
|
||||
await browser?.close().catch(() => undefined)
|
||||
await stopProcessTree(child, exited).catch((error) => {
|
||||
errors.push(error)
|
||||
child.stdout?.destroy()
|
||||
child.stderr?.destroy()
|
||||
})
|
||||
const [stdoutText, stderrText] = await Promise.all([stdout, stderr]).catch((error) => {
|
||||
errors.push(error)
|
||||
return ["", ""]
|
||||
})
|
||||
await Promise.all([
|
||||
testInfo.attach(`desktop-startup-${run}-stdout`, { body: stdoutText, contentType: "text/plain" }),
|
||||
testInfo.attach(`desktop-startup-${run}-stderr`, { body: stderrText, contentType: "text/plain" }),
|
||||
pageErrors.length
|
||||
? testInfo.attach(`desktop-startup-${run}-page-errors`, {
|
||||
body: pageErrors.join("\n\n"),
|
||||
contentType: "text/plain",
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]).catch((error) => errors.push(error))
|
||||
await Service.stop({ file: profile.registration }).catch((error) => errors.push(error))
|
||||
if (service && processAlive(service.pid))
|
||||
errors.push(new Error(`Desktop service process ${service.pid} did not stop`))
|
||||
await rm(profile.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch((error) =>
|
||||
errors.push(error),
|
||||
)
|
||||
if (errors.length) throw new AggregateError(errors, "Desktop benchmark cleanup failed")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHome(page: Page, mark: (name: Milestone) => void) {
|
||||
await expect.poll(() => page.evaluate(() => document.visibilityState), { timeout: 120_000 }).toBe("visible")
|
||||
|
||||
const projects = page.getByRole("complementary", { name: "Projects", exact: true })
|
||||
const sessions = page.getByRole("region", { name: "Recent sessions", exact: true })
|
||||
const search = page.getByRole("textbox", { name: "Search sessions", exact: true })
|
||||
const addProject = projects.locator('button[data-action="home-add-project-row"]')
|
||||
await expect(projects).toBeVisible({ timeout: 120_000 })
|
||||
await expect(sessions).toBeVisible()
|
||||
await expect(search).toBeEditable()
|
||||
await expect(sessions.getByText("Nothing here yet", { exact: true })).toBeVisible()
|
||||
await expect(addProject).toHaveCount(1)
|
||||
await addProject.click({ trial: true })
|
||||
mark("homeReady")
|
||||
}
|
||||
|
||||
type ThemeWindow = Window & {
|
||||
__OPENCODE_THEME_STATES__?: string[]
|
||||
__OPENCODE_THEME_OBSERVER__?: MutationObserver
|
||||
}
|
||||
|
||||
async function startThemeObservation(page: Page) {
|
||||
await page.addInitScript(installThemeObservation)
|
||||
await page.evaluate(installThemeObservation)
|
||||
}
|
||||
|
||||
async function requireStableTheme(page: Page) {
|
||||
const states = await page.evaluate(() => {
|
||||
const target = window as ThemeWindow
|
||||
target.__OPENCODE_THEME_OBSERVER__?.disconnect()
|
||||
return target.__OPENCODE_THEME_STATES__ ?? []
|
||||
})
|
||||
if (states.length !== 1) throw new Error(`Desktop theme changed during startup: ${states.join(" -> ")}`)
|
||||
}
|
||||
|
||||
function installThemeObservation() {
|
||||
const target = window as ThemeWindow
|
||||
const observeRoot = () => {
|
||||
const root = document.documentElement
|
||||
if (!root) return false
|
||||
const state = () => {
|
||||
const theme = root.dataset.theme
|
||||
const scheme = root.dataset.colorScheme
|
||||
return theme && scheme ? `${theme}:${scheme}` : undefined
|
||||
}
|
||||
const initial = state()
|
||||
target.__OPENCODE_THEME_STATES__ = initial ? [initial] : []
|
||||
target.__OPENCODE_THEME_OBSERVER__ = new MutationObserver(() => {
|
||||
const next = state()
|
||||
if (!next) return
|
||||
if (target.__OPENCODE_THEME_STATES__?.at(-1) !== next) target.__OPENCODE_THEME_STATES__?.push(next)
|
||||
})
|
||||
target.__OPENCODE_THEME_OBSERVER__.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-color-scheme"],
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (observeRoot()) return
|
||||
const documentObserver = new MutationObserver(() => {
|
||||
if (!observeRoot()) return
|
||||
documentObserver.disconnect()
|
||||
})
|
||||
target.__OPENCODE_THEME_OBSERVER__ = documentObserver
|
||||
documentObserver.observe(document, { childList: true })
|
||||
}
|
||||
|
||||
async function observeOutput(stream: NodeJS.ReadableStream, record: (line: string) => void) {
|
||||
const decoder = new TextDecoder()
|
||||
const output: string[] = []
|
||||
let pending = ""
|
||||
for await (const chunk of stream) {
|
||||
const text = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
|
||||
output.push(text)
|
||||
pending += text
|
||||
const lines = pending.split(/\r?\n/)
|
||||
pending = lines.pop() ?? ""
|
||||
lines.forEach(record)
|
||||
}
|
||||
const final = decoder.decode()
|
||||
output.push(final)
|
||||
pending += final
|
||||
if (pending) record(pending)
|
||||
return output.join("")
|
||||
}
|
||||
|
||||
async function readService(profile: Awaited<ReturnType<typeof createColdProfile>>) {
|
||||
const value: unknown = JSON.parse(await readFile(profile.registration, "utf8"))
|
||||
if (!isServiceInfo(value)) throw new Error("Desktop service registration is invalid")
|
||||
const url = new URL(value.url)
|
||||
const port = Number(url.port)
|
||||
if (url.hostname !== "127.0.0.1" || !Number.isInteger(port) || port <= 0)
|
||||
throw new Error(`Desktop service used unexpected endpoint ${value.url}`)
|
||||
if (!value.version.startsWith("2.0.0-local-"))
|
||||
throw new Error(`Desktop service used unexpected version ${value.version}`)
|
||||
return value
|
||||
}
|
||||
|
||||
function isServiceInfo(value: unknown): value is ServiceInfo {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string" &&
|
||||
"version" in value &&
|
||||
typeof value.version === "string" &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"pid" in value &&
|
||||
typeof value.pid === "number"
|
||||
)
|
||||
}
|
||||
|
||||
function requireMilestones(observed: Partial<Record<Milestone, number>>) {
|
||||
const get = (name: Milestone) => {
|
||||
const value = observed[name]
|
||||
if (value === undefined) throw new Error(`Desktop startup did not report milestone: ${name}`)
|
||||
return round(value)
|
||||
}
|
||||
return {
|
||||
bunRootScript: get("bunRootScript"),
|
||||
bunDesktopScript: get("bunDesktopScript"),
|
||||
desktopPrepared: get("desktopPrepared"),
|
||||
mainBundleReady: get("mainBundleReady"),
|
||||
preloadBundleReady: get("preloadBundleReady"),
|
||||
rendererDevServerReady: get("rendererDevServerReady"),
|
||||
electronSpawnStarted: get("electronSpawnStarted"),
|
||||
debugEndpointReady: get("debugEndpointReady"),
|
||||
electronStarted: get("electronStarted"),
|
||||
serviceEnsureStarted: get("serviceEnsureStarted"),
|
||||
serviceSpawnRequested: get("serviceSpawnRequested"),
|
||||
serviceReady: get("serviceReady"),
|
||||
backgroundLoadingReady: get("backgroundLoadingReady"),
|
||||
rendererViteConnected: get("rendererViteConnected"),
|
||||
rendererInitializationStarted: get("rendererInitializationStarted"),
|
||||
rendererInitializationReady: get("rendererInitializationReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
homeReady: get("homeReady"),
|
||||
}
|
||||
}
|
||||
|
||||
function calculatePhases(value: Record<Milestone, number>): Record<Phase, number> {
|
||||
return {
|
||||
desktopPreparation: value.desktopPrepared,
|
||||
viteMainBundle: round(value.mainBundleReady - value.desktopPrepared),
|
||||
vitePreloadBundle: round(value.preloadBundleReady - value.mainBundleReady),
|
||||
rendererServerStartup: round(value.rendererDevServerReady - value.preloadBundleReady),
|
||||
electronStartup: round(value.electronStarted - value.electronSpawnStarted),
|
||||
serviceSpawnWait: round(value.serviceSpawnRequested - value.serviceEnsureStarted),
|
||||
serviceProcessStartup: round(value.serviceReady - value.serviceSpawnRequested),
|
||||
rendererStartup: round(value.homeReady - value.rendererViteConnected),
|
||||
visibleWindowToHome: round(value.homeReady - value.windowVisible),
|
||||
}
|
||||
}
|
||||
|
||||
function statistics(values: number[]) {
|
||||
if (!values.length) throw new Error("Cannot summarize an empty benchmark")
|
||||
const sorted = values.toSorted((left, right) => left - right)
|
||||
const median = medianOf(sorted)
|
||||
return {
|
||||
min: round(sorted[0]),
|
||||
median: round(median),
|
||||
max: round(sorted.at(-1)!),
|
||||
medianAbsoluteDeviation: round(medianOf(sorted.map((value) => Math.abs(value - median)).toSorted((a, b) => a - b))),
|
||||
}
|
||||
}
|
||||
|
||||
function medianOf(sorted: number[]) {
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
if (sorted.length % 2) return sorted[middle]
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2
|
||||
}
|
||||
|
||||
async function stopProcessTree(child: ChildProcess, exited: Promise<number | null>) {
|
||||
if (!child.pid) throw new Error("Desktop command has no process ID")
|
||||
if (process.platform !== "win32") return stopProcessGroup(child.pid, exited)
|
||||
if (child.exitCode !== null || (await exitsWithin(child, exited, 2_000))) return
|
||||
const kill = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
})
|
||||
await childExit(kill)
|
||||
if (await exitsWithin(child, exited, 10_000)) return
|
||||
if (!(await exitsWithin(child, exited, 5_000))) throw new Error(`Desktop command process ${child.pid} did not stop`)
|
||||
}
|
||||
|
||||
async function stopProcessGroup(pid: number, exited: Promise<number | null>) {
|
||||
await Promise.race([exited, sleep(2_000)])
|
||||
if (!processGroupAlive(pid)) return
|
||||
process.kill(-pid, "SIGTERM")
|
||||
if (await processGroupStopsWithin(pid, 10_000)) return
|
||||
process.kill(-pid, "SIGKILL")
|
||||
if (!(await processGroupStopsWithin(pid, 5_000))) throw new Error(`Desktop command process group ${pid} did not stop`)
|
||||
}
|
||||
|
||||
async function processGroupStopsWithin(pid: number, timeout: number) {
|
||||
const deadline = Date.now() + timeout
|
||||
while (Date.now() < deadline) {
|
||||
if (!processGroupAlive(pid)) return true
|
||||
await sleep(50)
|
||||
}
|
||||
return !processGroupAlive(pid)
|
||||
}
|
||||
|
||||
function processGroupAlive(pid: number) {
|
||||
try {
|
||||
process.kill(-pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function exitsWithin(child: ChildProcess, exited: Promise<number | null>, timeout: number) {
|
||||
if (child.exitCode !== null) return true
|
||||
const result = await Promise.race([exited.then(() => true), sleep(timeout).then(() => false)])
|
||||
return result
|
||||
}
|
||||
|
||||
function childExit(child: ChildProcess) {
|
||||
return new Promise<number | null>((resolve, reject) => {
|
||||
child.once("error", reject)
|
||||
child.once("exit", (code) => resolve(code))
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(milliseconds: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function processAlive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnsi(value: string) {
|
||||
return value.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
||||
}
|
||||
|
||||
function elapsed(started: number) {
|
||||
return round(performance.now() - started)
|
||||
}
|
||||
|
||||
function round(value: number) {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "@playwright/test"
|
||||
|
||||
process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "desktop-startup-benchmark.spec.ts",
|
||||
outputDir: "../../test-results/performance-devex",
|
||||
timeout: 15 * 60_000,
|
||||
expect: {
|
||||
timeout: 120_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [["html", { outputFolder: "../../playwright-report/performance-devex", open: "never" }], ["line"]],
|
||||
projects: [{ name: "desktop" }],
|
||||
})
|
||||
@@ -7,7 +7,7 @@ process.env.OPENCODE_PERFORMANCE_RUN_ID ??= `${new Date().toISOString().replace(
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testIgnore: "unit/**",
|
||||
testIgnore: ["unit/**", "devex/**"],
|
||||
outputDir: "../test-results/performance",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { inlineThemePreload } from "../../../vite.js"
|
||||
import { milestoneForLine, summarizeDesktopStartup, type DesktopStartupSample } from "../devex/desktop-startup"
|
||||
|
||||
describe("desktop startup benchmark", () => {
|
||||
test.each(["/oc-theme-preload.js", "./oc-theme-preload.js"])("inlines %s before the renderer runs", (path) => {
|
||||
const html = inlineThemePreload(`<script id="oc-theme-preload-script" src="${path}"></script>`)
|
||||
expect(html).not.toContain(" src=")
|
||||
expect(html).toContain("opencode-color-scheme")
|
||||
})
|
||||
|
||||
test("recognizes startup milestones in colored output", () => {
|
||||
const cases = [
|
||||
["bunRootScript", "$ bun --cwd packages/desktop dev"],
|
||||
["bunDesktopScript", "$ bun ./scripts/dev.ts"],
|
||||
["desktopPrepared", "Copied dev icons from"],
|
||||
["mainBundleReady", "electron main process built successfully"],
|
||||
["preloadBundleReady", "electron preload scripts built successfully"],
|
||||
["rendererDevServerReady", "dev server running for the electron renderer process at:"],
|
||||
["electronSpawnStarted", "starting electron app..."],
|
||||
["debugEndpointReady", "DevTools listening on ws://"],
|
||||
["electronStarted", "app starting"],
|
||||
["serviceEnsureStarted", "starting v2 background service"],
|
||||
["serviceSpawnRequested", "v2 CLI background service starting"],
|
||||
["serviceReady", "v2 CLI background service ready"],
|
||||
["backgroundLoadingReady", "loading task finished"],
|
||||
["rendererViteConnected", "[vite] connected."],
|
||||
["rendererInitializationStarted", "awaiting server ready"],
|
||||
["rendererInitializationReady", "server ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
] as const
|
||||
cases.forEach(([milestone, line]) => {
|
||||
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
|
||||
})
|
||||
expect(milestoneForLine("12:30:00.000 › v2 CLI background service ready {")).toBe("serviceReady")
|
||||
expect(milestoneForLine("unrelated output")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps raw samples and reports median absolute deviation", () => {
|
||||
const samples = [24, 20, 22, 28, 26].map((commandToHomeReadyMs, index) => sample(index + 1, commandToHomeReadyMs))
|
||||
expect(summarizeDesktopStartup(samples).commandToHomeReadyMs).toEqual({
|
||||
min: 20,
|
||||
median: 24,
|
||||
max: 28,
|
||||
medianAbsoluteDeviation: 2,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample {
|
||||
const milestonesMs = {
|
||||
bunRootScript: 1,
|
||||
bunDesktopScript: 2,
|
||||
desktopPrepared: 3,
|
||||
mainBundleReady: 4,
|
||||
preloadBundleReady: 5,
|
||||
rendererDevServerReady: 6,
|
||||
electronSpawnStarted: 7,
|
||||
debugEndpointReady: 8,
|
||||
electronStarted: 9,
|
||||
serviceEnsureStarted: 10,
|
||||
serviceSpawnRequested: 11,
|
||||
serviceReady: 12,
|
||||
backgroundLoadingReady: 13,
|
||||
rendererViteConnected: 14,
|
||||
rendererInitializationStarted: 15,
|
||||
rendererInitializationReady: 16,
|
||||
windowVisible: 17,
|
||||
homeReady: commandToHomeReadyMs,
|
||||
}
|
||||
return {
|
||||
run,
|
||||
commandToHomeReadyMs,
|
||||
milestonesMs,
|
||||
phasesMs: {
|
||||
desktopPreparation: 3,
|
||||
viteMainBundle: 1,
|
||||
vitePreloadBundle: 1,
|
||||
rendererServerStartup: 1,
|
||||
electronStartup: 2,
|
||||
serviceSpawnWait: 1,
|
||||
serviceProcessStartup: 1,
|
||||
rendererStartup: commandToHomeReadyMs - 14,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 17,
|
||||
},
|
||||
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
await expect.poll(() => input.evaluate((element) => getComputedStyle(element, "::before").content)).toBe(
|
||||
`"${String.fromCodePoint(0x200b)}"`,
|
||||
)
|
||||
await expectAppVisible(composer)
|
||||
|
||||
await input.fill("keep me")
|
||||
|
||||
@@ -19,7 +19,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await configureServers(page)
|
||||
|
||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
|
||||
const dialog = page.locator(".settings-v2-dialog")
|
||||
@@ -61,7 +61,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
@@ -78,7 +78,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
|
||||
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible()
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
@@ -208,7 +208,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
return json(route, [
|
||||
{
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
@@ -216,7 +216,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
])
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
@@ -237,7 +237,7 @@ function session(id: string, directory: string, title: string) {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
location: { directory },
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop": "./src/desktop.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./i18n/desktop-native": "./src/i18n/desktop-native.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
@@ -28,14 +29,15 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
|
||||
"test:bench:devex": "bun test ./e2e/performance/unit/desktop-startup.test.ts && playwright test --config e2e/performance/devex/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@playwright/test": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
@@ -44,9 +46,9 @@
|
||||
"happy-dom": "20.11.1",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
"vite-plugin-solid": "catalog:"
|
||||
"vite-plugin-solid": "2.11.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
@@ -85,6 +87,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "catalog:"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+20
-64
@@ -1,12 +1,10 @@
|
||||
import "@/index.css"
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Route, Router, useParams } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
@@ -20,30 +18,37 @@ import {
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, UiI18nBridge, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { TabsProvider } from "@/context/tabs"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { requireServerKey } from "./utils/session-route"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
import { ServerProvider } from "./context/server"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadDraftRoute = () => Promise.all([import("@/pages/draft-route"), File.preload()]).then(([module]) => module)
|
||||
const loadSessionRoute = () => Promise.all([import("@/pages/session"), File.preload()]).then(([module]) => module)
|
||||
const DraftRoute = lazy(() => loadDraftRoute().then((module) => ({ default: module.DraftRoute })))
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
@@ -60,45 +65,6 @@ function TargetServerRoute(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={tabs.ready() && <Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ServerProvider>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
@@ -167,22 +133,11 @@ function AppLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
return (
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppBaseProviders(
|
||||
props: ParentProps<{
|
||||
locale?: Locale
|
||||
onNativeTranslations?: Parameters<typeof LanguageProvider>[0]["onNativeTranslations"]
|
||||
onThemeApplied?: () => void
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
@@ -191,13 +146,14 @@ export function AppBaseProviders(
|
||||
<ThemeProvider
|
||||
onThemeApplied={(_, mode, scheme) => {
|
||||
void window.api?.setTitlebar?.({ mode, scheme })
|
||||
props.onThemeApplied?.()
|
||||
}}
|
||||
>
|
||||
<LanguageProvider locale={props.locale} onNativeTranslations={props.onNativeTranslations}>
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => {
|
||||
Sentry.captureException(error)
|
||||
void import("@sentry/solid").then(({ captureException }) => captureException(error))
|
||||
return <ErrorPage error={error} />
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -241,7 +241,11 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
sessionId: activeSession.id,
|
||||
}
|
||||
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
|
||||
void tabs.newDraft(
|
||||
{ server: sessionTab.server, directory: activeSession.location.directory },
|
||||
"",
|
||||
model,
|
||||
)
|
||||
return
|
||||
}
|
||||
case "draft": {
|
||||
@@ -249,7 +253,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
if (activeTab?.type !== "draft") return
|
||||
|
||||
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
|
||||
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
void tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
|
||||
return
|
||||
}
|
||||
case "home": {
|
||||
@@ -263,7 +267,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
projects?.list().find((item) => item.worktree === projects.last()) ??
|
||||
projects?.list()[0]
|
||||
if (conn && project) {
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||
void tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -467,11 +471,14 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
)
|
||||
}
|
||||
|
||||
const label = channel && ["local", "beta", "dev"].includes(channel) ? channel.toUpperCase() : undefined
|
||||
return (
|
||||
<Show when={["local", "beta", "dev"].includes(channel)}>
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
<Show when={label}>
|
||||
{(value) => (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{value()}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { ACCEPTED_FILE_EXTENSIONS } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language"
|
||||
export { type Platform, PlatformProvider } from "./context/platform"
|
||||
export { ServerConnection, useServers } from "./context/servers"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { createDraftStore } from "./utils/draft-store"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
@@ -1,4 +1,4 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { AppBaseProviders, AppInterface, preloadRoute } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServers as useServers } from "./context/servers"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Navigate, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { LocationProvider } from "@/context/location"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerProvider } from "@/context/server"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||
import NewSession from "@/pages/new-session"
|
||||
|
||||
export function DraftRoute() {
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
return (
|
||||
<Show
|
||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||
keyed
|
||||
fallback={tabs.ready() && <Navigate href="/" />}
|
||||
>
|
||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
const global = useGlobal()
|
||||
const conn = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === props.draft.server))
|
||||
|
||||
return (
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<Show when={conn()} keyed>
|
||||
{(conn) => (
|
||||
<ServerProvider conn={conn}>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ServerProvider>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
return (
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
|
||||
@@ -63,7 +61,11 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||
canRemove: (conn: ServerConnection.Any) => serverManagement.connection.canRemove(ServerConnection.key(conn)),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
edit: (conn: ServerConnection.Http) => {
|
||||
void import("@/components/settings-v2/dialog-server-v2").then(({ DialogServerV2 }) => {
|
||||
void dialog.show(() => <DialogServerV2 mode="edit" server={conn} />)
|
||||
})
|
||||
},
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { ToastRegion } from "@/utils/toast"
|
||||
|
||||
const DebugBar = lazy(() => import("@/components/debug-bar").then((module) => ({ default: module.DebugBar })))
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
const [state, setState] = createStore({ debugTools: false })
|
||||
|
||||
const update: TitlebarUpdate = {
|
||||
get version() {
|
||||
@@ -41,7 +42,9 @@ export default function Layout(props: ParentProps) {
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<DebugBar inline />
|
||||
<Suspense>
|
||||
<DebugBar inline />
|
||||
</Suspense>
|
||||
</Show>
|
||||
<ToastRegion />
|
||||
</div>
|
||||
|
||||
@@ -39,14 +39,20 @@ describe("new session workspace selection", () => {
|
||||
expect(normalizeNewSessionWorktree("main", "C:\\Repo\\", "c:/repo")).toBe("main")
|
||||
})
|
||||
|
||||
test("falls back to the local branch for main, create, and unknown worktrees", () => {
|
||||
test("resolves the branch from the active location", () => {
|
||||
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
|
||||
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
|
||||
expect(resolveNewSessionBranch({ worktree: "main", directory: "/project/feature", worktreeBranch: branch })).toBe(
|
||||
"feature",
|
||||
)
|
||||
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(
|
||||
resolveNewSessionBranch({ worktree: "create", directory: "/project/feature", worktreeBranch: branch }),
|
||||
).toBe("feature")
|
||||
expect(
|
||||
resolveNewSessionBranch({ worktree: "/project/feature", directory: "/project", worktreeBranch: branch }),
|
||||
).toBe("feature")
|
||||
expect(resolveNewSessionBranch({ worktree: "/missing", directory: "/project/feature", worktreeBranch: branch })).toBe(
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
test("uses location VCS state when the project inventory is stale", () => {
|
||||
|
||||
@@ -32,11 +32,11 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
|
||||
|
||||
export function resolveNewSessionBranch(input: {
|
||||
worktree: string
|
||||
local?: string
|
||||
directory: string
|
||||
worktreeBranch: (worktree: string) => string | undefined
|
||||
}) {
|
||||
if (input.worktree === "main" || input.worktree === "create") return input.local
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
|
||||
return input.worktreeBranch(directory)
|
||||
}
|
||||
|
||||
export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) {
|
||||
@@ -95,11 +95,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
local: localBranch(),
|
||||
directory: sdk().directory,
|
||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,22 +3,56 @@ import { beforeEach, describe, expect, test } from "bun:test"
|
||||
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
|
||||
|
||||
const run = () => Function(src)()
|
||||
const setSystemDark = (matches: boolean) =>
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: () => ({ matches }) as MediaQueryList,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ""
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.removeAttribute("data-color-scheme")
|
||||
document.documentElement.style.removeProperty("background-color")
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: () =>
|
||||
({
|
||||
matches: false,
|
||||
}) as MediaQueryList,
|
||||
configurable: true,
|
||||
})
|
||||
setSystemDark(false)
|
||||
})
|
||||
|
||||
describe("theme preload", () => {
|
||||
test("uses default theme and system light mode when settings are absent", () => {
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("oc-2")
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||
expect(document.documentElement.style.backgroundColor).toBe("#fafafa")
|
||||
})
|
||||
|
||||
test("restores explicit dark mode on a light system", () => {
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("dark")
|
||||
expect(document.documentElement.style.backgroundColor).toBe("#080808")
|
||||
})
|
||||
|
||||
test("restores explicit light mode on a dark system", () => {
|
||||
setSystemDark(true)
|
||||
localStorage.setItem("opencode-color-scheme", "light")
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||
expect(document.documentElement.style.backgroundColor).toBe("#fafafa")
|
||||
})
|
||||
|
||||
test("resolves persisted system mode before paint", () => {
|
||||
setSystemDark(true)
|
||||
localStorage.setItem("opencode-color-scheme", "system")
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("dark")
|
||||
expect(document.documentElement.style.backgroundColor).toBe("#080808")
|
||||
})
|
||||
|
||||
test("keeps cached css for non-default themes", () => {
|
||||
localStorage.setItem("opencode-theme-id", "nightowl")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
@@ -28,4 +62,15 @@ describe("theme preload", () => {
|
||||
expect(document.documentElement.dataset.theme).toBe("nightowl")
|
||||
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
|
||||
})
|
||||
|
||||
test("restores the cached variant for a persisted custom dark theme", () => {
|
||||
localStorage.setItem("opencode-theme-id", "nightowl")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#010203;")
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("nightowl")
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("dark")
|
||||
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#010203;")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { defineConfig } from "vite"
|
||||
import desktopPlugin from "./vite"
|
||||
import desktopPlugin from "./vite.js"
|
||||
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
|
||||
+23
-6
@@ -4,6 +4,18 @@ import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
const themeScript = readFileSync(theme, "utf8")
|
||||
const tailwind = tailwindcss()
|
||||
const tailwindGenerate = tailwind.find((plugin) => plugin.name === "@tailwindcss/vite:generate:serve")
|
||||
const tailwindHotUpdate = tailwindGenerate?.hotUpdate
|
||||
|
||||
// Tailwind 4.3.3 expects a server that Vite's bundled dev hook does not provide.
|
||||
if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
tailwindGenerate.hotUpdate = function (context) {
|
||||
if (!context.server) return
|
||||
return tailwindHotUpdate.call(this, context)
|
||||
}
|
||||
}
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -40,13 +52,18 @@ export default [
|
||||
},
|
||||
{
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||
)
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler: inlineThemePreload,
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
...tailwind,
|
||||
solidPlugin(),
|
||||
]
|
||||
|
||||
export function inlineThemePreload(html) {
|
||||
return html.replace(
|
||||
/<script id="oc-theme-preload-script" src="(?:\.\/|\/)oc-theme-preload\.js"><\/script>/,
|
||||
`<script id="oc-theme-preload-script">${themeScript}</script>`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.107",
|
||||
"effect": "4.0.0-rc.110",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -377,6 +377,15 @@ export type Endpoint5_31Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.viewed"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly idle: number }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -918,6 +927,10 @@ export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly varia
|
||||
export type Endpoint5_35Output = void
|
||||
export type SessionEnvironmentOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export type Endpoint5_36Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_36Output = void
|
||||
export type SessionViewOperation<E = never> = (input: Endpoint5_36Input) => Effect.Effect<Endpoint5_36Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
readonly create: SessionCreateOperation<E>
|
||||
@@ -963,6 +976,7 @@ export interface SessionApi<E = never> {
|
||||
readonly background: SessionBackgroundOperation<E>
|
||||
readonly message: SessionMessageOperation<E>
|
||||
readonly environment: SessionEnvironmentOperation<E>
|
||||
readonly view: SessionViewOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint6_0Input = {
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint5_36Input,
|
||||
Endpoint5_36Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -613,6 +615,11 @@ const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35I
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_36 = (raw: RawClient["server.session"]) => (input: Endpoint5_36Input) =>
|
||||
preserveEffect<Endpoint5_36Output>()(
|
||||
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint5_0(raw),
|
||||
create: Endpoint5_1(raw),
|
||||
@@ -643,6 +650,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
environment: Endpoint5_35(raw),
|
||||
view: Endpoint5_36(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
|
||||
@@ -80,6 +80,8 @@ import type {
|
||||
SessionMessageOutput,
|
||||
SessionEnvironmentInput,
|
||||
SessionEnvironmentOutput,
|
||||
SessionViewInput,
|
||||
SessionViewOutput,
|
||||
MessageListInput,
|
||||
MessageListOutput,
|
||||
ModelListInput,
|
||||
@@ -896,6 +898,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionViewOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
message: {
|
||||
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -322,6 +322,16 @@ export type SessionRenamed = {
|
||||
data: { sessionID: string; title: string }
|
||||
}
|
||||
|
||||
export type SessionViewed = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.viewed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; idle: number }
|
||||
}
|
||||
|
||||
export type SessionDeleted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1657,7 +1667,8 @@ export type SessionInfo = {
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
time: { created: number; updated: number; archived?: number }
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
@@ -1942,6 +1953,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
@@ -2027,6 +2039,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2499,7 +2512,14 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -2767,7 +2787,14 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -3035,7 +3062,14 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||
readonly outcome?: "succeeded" | "failed" | "interrupted"
|
||||
readonly time: {
|
||||
readonly created: number
|
||||
readonly updated: number
|
||||
readonly idle?: number
|
||||
readonly viewed?: number
|
||||
readonly archived?: number
|
||||
}
|
||||
readonly title?: string
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
@@ -3969,6 +4003,10 @@ export type SessionEnvironmentInput = {
|
||||
|
||||
export type SessionEnvironmentOutput = void
|
||||
|
||||
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionViewOutput = void
|
||||
|
||||
export type MessageListInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly limit?: {
|
||||
|
||||
@@ -12,8 +12,8 @@ export type EnsureTiming = {
|
||||
const timings = new WeakMap<object, EnsureTiming>()
|
||||
|
||||
export const defaultEnsureTiming: EnsureTiming = {
|
||||
pollInterval: 1_000,
|
||||
attempts: 120,
|
||||
pollInterval: 100,
|
||||
attempts: 1_200,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -844,6 +844,16 @@ export function createData(config: CreateDataInput) {
|
||||
const currentAssistant = message.activeAssistant(draft)
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Refresh only sessions this client already loaded; unloaded sessions hydrate on demand.
|
||||
if (!store.session.info[event.data.sessionID]) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.viewed":
|
||||
if (!store.session.info[event.data.sessionID]) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
void result.session.sync(event.data.sessionID)
|
||||
return
|
||||
case "session.revert.staged":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
@@ -1152,6 +1162,22 @@ export function createData(config: CreateDataInput) {
|
||||
: [],
|
||||
])
|
||||
const sessions = [info, ...children]
|
||||
// Hydrate deeper descendant levels too: the family index spans nested subagents,
|
||||
// and a grandchild's unread state must survive a client restart.
|
||||
const visited = new Set([sessionID, ...children.map((child) => child.id)])
|
||||
let frontier = children.map((child) => child.id)
|
||||
while (frontier.length > 0) {
|
||||
const levels = await Promise.all(
|
||||
frontier.map((parentID) =>
|
||||
api()
|
||||
.session.list({ parentID, order: "desc" })
|
||||
.then((response) => response.data),
|
||||
),
|
||||
)
|
||||
const next = levels.flat().filter((child) => !visited.has(child.id) && visited.add(child.id))
|
||||
sessions.push(...next)
|
||||
frontier = next.map((child) => child.id)
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
"info",
|
||||
|
||||
@@ -111,7 +111,7 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
|
||||
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
|
||||
const durable = events[1]
|
||||
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
|
||||
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
|
||||
expect(durable.created).toBe(1_717_171_717_000)
|
||||
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
|
||||
})
|
||||
|
||||
@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
|
||||
|
||||
test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const logQueries: Array<Record<string, string>> = []
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
requests.push({ method: request.method, url })
|
||||
if (url.includes("/log")) {
|
||||
logQueries.push(Object.fromEntries(request.urlParams.params))
|
||||
return Effect.succeed(
|
||||
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
const created = yield* client.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.session.switchModel({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
return { page, active, created, admitted, context, log, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
const listed = result.page.data[0]
|
||||
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
|
||||
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
|
||||
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
|
||||
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
|
||||
expect(result.active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
|
||||
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
|
||||
@@ -217,11 +224,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
expect(result.context).toEqual([])
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
|
||||
const logged = Array.from(result.log)
|
||||
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
|
||||
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||
1_717_171_717_000,
|
||||
)
|
||||
expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
|
||||
expect(logged.at(-1)).toEqual(synced)
|
||||
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
@@ -260,6 +266,8 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -523,6 +523,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
|
||||
const active = await client.session.active()
|
||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||
await client.session.view({ sessionID: "ses_test" })
|
||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.session.switchModel({
|
||||
sessionID: "ses_test",
|
||||
@@ -549,6 +550,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
@@ -561,6 +563,7 @@ test("session methods use the public HTTP contract", async () => {
|
||||
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
|
||||
["GET", "http://localhost:3000/api/session/active"],
|
||||
["POST", "http://localhost:3000/api/session"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/view"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/agent"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/model"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
|
||||
@@ -635,6 +638,8 @@ const session = {
|
||||
time: {
|
||||
created: 1_717_171_717_000,
|
||||
updated: 1_717_171_717_000,
|
||||
idle: 1_717_171_717_002,
|
||||
viewed: 1_717_171_717_001,
|
||||
},
|
||||
title: "Test",
|
||||
location: { directory: "/tmp/project" },
|
||||
|
||||
+162
-44
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
|
||||
"id": "3fb67508-0196-4bae-b2bd-c08ece7583fd",
|
||||
"prevIds": [
|
||||
"dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1350,6 +1352,36 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_idle",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_viewed",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "idle_outcome",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -1481,9 +1513,13 @@
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["active_account_id"],
|
||||
"columns": [
|
||||
"active_account_id"
|
||||
],
|
||||
"tableTo": "account",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "SET NULL",
|
||||
"nameExplicit": false,
|
||||
@@ -1492,9 +1528,13 @@
|
||||
"table": "account_state"
|
||||
},
|
||||
{
|
||||
"columns": ["aggregate_id"],
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"tableTo": "event_sequence",
|
||||
"columnsTo": ["aggregate_id"],
|
||||
"columnsTo": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1503,9 +1543,13 @@
|
||||
"table": "event"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1514,9 +1558,13 @@
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1525,9 +1573,13 @@
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1536,9 +1588,13 @@
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1547,9 +1603,13 @@
|
||||
"table": "instruction_state"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1558,9 +1618,13 @@
|
||||
"table": "session_inbox"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1569,9 +1633,13 @@
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session_v2",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1580,9 +1648,13 @@
|
||||
"table": "session_pending"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1591,9 +1663,13 @@
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1602,133 +1678,175 @@
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["email", "url"],
|
||||
"columns": [
|
||||
"email",
|
||||
"url"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "control_account_pk",
|
||||
"entityType": "pks",
|
||||
"table": "control_account"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id", "directory"],
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "project_directory_pk",
|
||||
"entityType": "pks",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id", "key"],
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_entry_pk",
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id", "directory"],
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "worktree_pk",
|
||||
"entityType": "pks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "account_state_pk",
|
||||
"table": "account_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "account_pk",
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "credential_pk",
|
||||
"table": "credential",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["aggregate_id"],
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "event_sequence_pk",
|
||||
"table": "event_sequence",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "event_pk",
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["key"],
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "kv_pk",
|
||||
"table": "kv",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "permission_pk",
|
||||
"table": "permission",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "project_pk",
|
||||
"table": "project",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["hash"],
|
||||
"columns": [
|
||||
"hash"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_blob_pk",
|
||||
"table": "instruction_blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "instruction_state_pk",
|
||||
"table": "instruction_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_inbox_pk",
|
||||
"table": "session_inbox",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pending_pk",
|
||||
"table": "session_pending",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_v2_pk",
|
||||
"table": "session_v2",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
@@ -2024,4 +2142,4 @@
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
}
|
||||
@@ -328,7 +328,6 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
body: projected.body === undefined ? undefined : { ...projected.body },
|
||||
headers: info.headers,
|
||||
},
|
||||
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
|
||||
providerOptions: projected.settings,
|
||||
},
|
||||
body: {
|
||||
|
||||
+2
@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -89,4 +90,5 @@ export const migrations = [
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
m44,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260819222447_session_viewed_state",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`idle_outcome\` text;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -209,6 +209,9 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`model\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_idle\` integer,
|
||||
\`time_viewed\` integer,
|
||||
\`idle_outcome\` text,
|
||||
\`time_compacting\` integer,
|
||||
\`time_archived\` integer,
|
||||
\`time_suspended\` integer,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -111,7 +111,7 @@ export class ToolCallError extends Schema.TaggedError<ToolCallError>()("MCP.Tool
|
||||
type ServerEntry = {
|
||||
readonly config: Mcp.ServerConfig
|
||||
status: Status
|
||||
readonly startup: Deferred.Deferred<void>
|
||||
readonly startup: Latch.Latch
|
||||
scope?: Scope.Closeable
|
||||
client?: MCPClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
@@ -535,7 +535,7 @@ export const layer = (options?: Options) =>
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
const scope = entry.scope
|
||||
@@ -562,7 +562,7 @@ export const layer = (options?: Options) =>
|
||||
const entry: ServerEntry = {
|
||||
config: serverConfig,
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
startup: Latch.makeUnsafe(),
|
||||
}
|
||||
entries.set(name, entry)
|
||||
yield* Effect.gen(function* () {
|
||||
@@ -575,7 +575,7 @@ export const layer = (options?: Options) =>
|
||||
yield* startServer(name, entry)
|
||||
}).pipe(
|
||||
// Settle startup even when registration fails or replacement is interrupted, so readers cannot hang.
|
||||
Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))),
|
||||
Effect.ensuring(entry.startup.open),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -597,7 +597,7 @@ export const layer = (options?: Options) =>
|
||||
entries.set(name, {
|
||||
config: server,
|
||||
status: { status: "pending" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
startup: Latch.makeUnsafe(),
|
||||
})
|
||||
}
|
||||
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
|
||||
@@ -607,7 +607,7 @@ export const layer = (options?: Options) =>
|
||||
for (const [name, entry] of entries) {
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
entry.startup.openUnsafe()
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
continue
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export const layer = (options?: Options) =>
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
const whenAllReady = Effect.suspend(() =>
|
||||
Effect.forEach(Array.from(entries.values()), (entry) => Deferred.await(entry.startup), {
|
||||
Effect.forEach(Array.from(entries.values()), (entry) => entry.startup.await, {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
}),
|
||||
@@ -734,7 +734,7 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
callTool: Effect.fn("MCP.callTool")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
yield* target.entry.startup.await
|
||||
if (!target.entry.client)
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
@@ -773,7 +773,7 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
yield* target.entry.startup.await
|
||||
if (!target.entry.client) return undefined
|
||||
const result = yield* target.entry.client
|
||||
.prompt({ name: input.name, args: input.args })
|
||||
@@ -827,7 +827,7 @@ export const layer = (options?: Options) =>
|
||||
}),
|
||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
yield* target.entry.startup.await
|
||||
if (!target.entry.client) return undefined
|
||||
const result = yield* target.entry.client
|
||||
.readResource({ uri: input.uri })
|
||||
|
||||
@@ -164,7 +164,6 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
body: Provider.mergeOverlay(mapping?.body, resolved.body),
|
||||
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
|
||||
@@ -70,13 +70,11 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
|
||||
input: ["text", ...(item.capabilities?.vision ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
item.loaded_instances.length === 0
|
||||
? item.max_context_length
|
||||
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length)),
|
||||
output: 0,
|
||||
}
|
||||
const context =
|
||||
item.loaded_instances.length === 0
|
||||
? item.max_context_length
|
||||
: Math.min(...item.loaded_instances.map((instance) => instance.config.context_length))
|
||||
if (context > 0) model.limit.context = context
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -96,13 +96,10 @@ export function make(origin = "http://127.0.0.1:11434", interval: Duration.Input
|
||||
input: ["text", ...(item.show.capabilities?.includes("vision") ? ["image"] : [])],
|
||||
output: ["text"],
|
||||
}
|
||||
model.limit = {
|
||||
context:
|
||||
Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
||||
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
|
||||
)[0] ?? 0,
|
||||
output: 0,
|
||||
}
|
||||
const context = Object.entries(item.show.model_info ?? {}).flatMap(([key, value]) =>
|
||||
key.endsWith(".context_length") && typeof value === "number" && value > 0 ? [value] : [],
|
||||
)[0]
|
||||
if (context !== undefined) model.limit.context = context
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,7 +55,8 @@ export function make(origin = "http://127.0.0.1:8000", interval: Duration.Input
|
||||
model.name = item.id
|
||||
// Tool calling depends on vLLM server flags and parsers that model discovery does not report.
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
model.limit = { context: item.max_model_len ?? 0, output: 0 }
|
||||
if (typeof item.max_model_len === "number" && item.max_model_len > 0)
|
||||
model.limit.context = item.max_model_len
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ export { Service, type Interface } from "./supervisor-service.js"
|
||||
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Event } from "@opencode-ai/schema/config"
|
||||
import { Cause, Deferred, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ConfigPluginSource } from "../config/plugin/source.js"
|
||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
|
||||
@@ -164,7 +164,7 @@ export const layer = Layer.effect(
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
|
||||
yield* ready.close
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
@@ -176,12 +176,12 @@ export const layer = Layer.effect(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
|
||||
if (observed === target) yield* ready.open
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
|
||||
return Service.of({ flush: ready.await })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ export interface Interface {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly variables?: SessionEnvironment.Variables
|
||||
}) => Effect.Effect<SessionEnvironment.Variables | undefined, NotFoundError>
|
||||
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -459,6 +460,17 @@ const layer = Layer.effect(
|
||||
if (input.variables !== undefined) yield* environments.set(input.sessionID, input.variables)
|
||||
return yield* environments.get(input.sessionID)
|
||||
}),
|
||||
view: Effect.fn("Session.view")(function* (input) {
|
||||
const row = yield* db
|
||||
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
|
||||
if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
|
||||
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID, idle: row.idle })
|
||||
}),
|
||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -9,17 +9,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { App } from "../app.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
@@ -70,13 +65,12 @@ export type Draft = {
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
readonly modelRequests: SessionModelRequest.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -85,6 +79,8 @@ export type AutoInput = {
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
@@ -92,8 +88,6 @@ export type ManualInput = {
|
||||
readonly started?: boolean
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
@@ -116,8 +110,19 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
|
||||
|
||||
const truncate = (value: string) =>
|
||||
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
|
||||
export const truncateToolOutput = (value: string) => {
|
||||
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
|
||||
let end = 0
|
||||
for (let count = 0; count < TOOL_OUTPUT_MAX_CHARS && end < value.length; count++) {
|
||||
const code = value.charCodeAt(end)
|
||||
end +=
|
||||
code >= 0xd800 && code <= 0xdbff && value.charCodeAt(end + 1) >= 0xdc00 && value.charCodeAt(end + 1) <= 0xdfff
|
||||
? 2
|
||||
: 1
|
||||
}
|
||||
if (end === value.length) return value
|
||||
return `${value.slice(0, end)}\n[truncated]`
|
||||
}
|
||||
|
||||
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
|
||||
content
|
||||
@@ -146,7 +151,7 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
if (part.state.status === "completed")
|
||||
return [
|
||||
`[Assistant tool call]: ${part.name}(${input})`,
|
||||
`[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
|
||||
`[Tool result]: ${truncateToolOutput(serializeToolContent(part.state.content))}`,
|
||||
]
|
||||
if (part.state.status === "error")
|
||||
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
|
||||
@@ -157,7 +162,8 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
if (message.type === "system") return `[System update]: ${message.text}`
|
||||
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
|
||||
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
|
||||
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
|
||||
if (message.type === "shell")
|
||||
return `[Shell]: ${message.command}\n${truncateToolOutput(message.output?.output ?? "")}`
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -266,65 +272,51 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref },
|
||||
LLM.request({
|
||||
model: plan.resolved.model,
|
||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
const prepared = yield* dependencies.modelRequests.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
yield* dependencies.llm
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.resolved.ref,
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
@@ -425,14 +417,13 @@ export const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, app, hooks })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
return make({ bus, llm, models, modelRequests })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
|
||||
})
|
||||
|
||||
@@ -50,9 +50,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
outcome: row.idle_outcome ?? undefined,
|
||||
time: {
|
||||
created: DateTime.makeUnsafe(row.time_created),
|
||||
updated: DateTime.makeUnsafe(row.time_updated),
|
||||
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
|
||||
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
|
||||
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
"session.created": () => Effect.void,
|
||||
"session.viewed": () => Effect.void,
|
||||
"session.usage.recorded": () => Effect.void,
|
||||
"session.agent.selected": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -61,13 +61,20 @@ interface PrepareInput {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
/** Omitted for requests that carry no tools (title, compaction). */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape the agent conversation. Requests that are not
|
||||
* part of the conversation (title, compaction) opt out: their transcripts
|
||||
* pass through unchanged.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
@@ -209,7 +216,10 @@ export const layer = Layer.effect(
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
const tools = input.scope.tools
|
||||
const tools = input.scope.tools ?? {
|
||||
definitions: [],
|
||||
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
|
||||
}
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
@@ -219,14 +229,18 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context =
|
||||
input.contextHooks === false
|
||||
? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions }
|
||||
: yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
|
||||
@@ -391,6 +391,37 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function projectIdle(
|
||||
db: DatabaseService,
|
||||
event:
|
||||
| typeof SessionEvent.Execution.Succeeded.Type
|
||||
| typeof SessionEvent.Execution.Failed.Type
|
||||
| typeof SessionEvent.Execution.Interrupted.Type,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
|
||||
const time = event.created
|
||||
const outcome =
|
||||
event.type === SessionEvent.Execution.Succeeded.type
|
||||
? "succeeded"
|
||||
: event.type === SessionEvent.Execution.Failed.type
|
||||
? "failed"
|
||||
: "interrupted"
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
|
||||
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
|
||||
idle_outcome: outcome,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
@@ -512,6 +543,20 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Viewed, (event) => {
|
||||
const idle = event.data.idle
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
// Monotone watermark: a duplicate or stale view never regresses, and a terminal event
|
||||
// committing after the viewer's observation keeps the newer idle transition unread.
|
||||
time_viewed: sql`max(${idle}, coalesce(${SessionTable.time_viewed}, ${idle}))`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
|
||||
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
|
||||
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
|
||||
@@ -580,9 +625,9 @@ const layer = Layer.effectDiscard(
|
||||
delivery: event.data.delivery,
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
|
||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
|
||||
@@ -89,7 +89,15 @@ const classifyToolExits = (
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
const reasons = cause.reasons.flatMap(
|
||||
(reason): Array<Cause.Reason<never>> => (Cause.isFailReason(reason) ? [] : [reason]),
|
||||
(reason): Array<Cause.Reason<never>> =>
|
||||
Cause.isFailReason(reason)
|
||||
? isDecline(reason.error)
|
||||
? []
|
||||
: // A typed failure here broke the ExecuteError contract (the per-fiber
|
||||
// `catchTag("Tool.Error")` consumes honest ones). Surfacing it as a defect
|
||||
// keeps it from being dropped, which would leave its call unsettled forever.
|
||||
[Cause.makeDieReason(reason.error)]
|
||||
: [reason],
|
||||
)
|
||||
return reasons.length > 0 ? [Cause.fromReasons(reasons)] : []
|
||||
})
|
||||
@@ -315,7 +323,6 @@ const layer = Layer.effect(
|
||||
const loaded = yield* context.load(selected)
|
||||
const { session, agent } = loaded
|
||||
const resolved = loaded.model
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, resolved }
|
||||
|
||||
@@ -56,6 +56,9 @@ export const SessionTable = sqliteTable(
|
||||
variant?: string
|
||||
}>(),
|
||||
...Timestamps,
|
||||
time_idle: integer(),
|
||||
time_viewed: integer(),
|
||||
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
|
||||
time_compacting: integer(),
|
||||
time_archived: integer(),
|
||||
/** The execution claim timestamp (historical column name; see SessionStore.claim). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -8,14 +8,10 @@ import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
@@ -25,15 +21,14 @@ const MAX_LENGTH = 100
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly agents: Agent.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly modelRequests: SessionModelRequest.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -81,39 +76,28 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
const prepared = yield* dependencies.modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved },
|
||||
transcript: {
|
||||
system: agent.system ? [SystemPart.make(agent.system)] : [],
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (!streamed || failed) return
|
||||
const title = chunks
|
||||
@@ -146,11 +130,10 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const agents = yield* Agent.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ bus, llm, agents, models, store, app, hooks })
|
||||
const title = make({ bus, llm, agents, models, modelRequests, store })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
@@ -165,9 +148,8 @@ export const node = makeLocationNode({
|
||||
llmClient,
|
||||
Agent.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelRequest.node,
|
||||
SessionStore.node,
|
||||
Database.node,
|
||||
App.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -117,6 +117,11 @@ const layer = Layer.effect(
|
||||
tokens_cache_write: input.data.info.tokens.cache.write,
|
||||
time_created: DateTime.toEpochMillis(input.data.info.time.created),
|
||||
time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
|
||||
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
|
||||
time_viewed: input.data.info.time.viewed
|
||||
? DateTime.toEpochMillis(input.data.info.time.viewed)
|
||||
: null,
|
||||
idle_outcome: input.data.info.outcome ?? null,
|
||||
time_archived: input.data.info.time.archived
|
||||
? DateTime.toEpochMillis(input.data.info.time.archived)
|
||||
: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Shell from "./shell.js"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Schedule, Stream } from "effect"
|
||||
import { Context, Deferred, Duration, Effect, Fiber, Latch, Layer, Schema, Schedule, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { produce } from "immer"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
@@ -286,7 +286,7 @@ const layer = () =>
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Deferred.makeUnsafe<void>()
|
||||
const outputDone = Latch.makeUnsafe()
|
||||
const pump = handle.all.pipe(
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
@@ -304,8 +304,8 @@ const layer = () =>
|
||||
stream.end(() => resolve())
|
||||
}),
|
||||
)
|
||||
yield* Deferred.succeed(outputDone, undefined)
|
||||
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
|
||||
yield* outputDone.open
|
||||
}).pipe(Effect.catch(() => outputDone.open)),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
@@ -324,7 +324,7 @@ const layer = () =>
|
||||
draft.time.completed = Date.now()
|
||||
})
|
||||
yield* beforeWait
|
||||
yield* Deferred.await(outputDone)
|
||||
yield* outputDone.await
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user