mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4d283d23d | |||
| 41b4e1e8e2 | |||
| ab77fb080a | |||
| 2a7d0729d0 | |||
| e2d9376614 | |||
| f288d7e107 | |||
| afe4c5d23a | |||
| 98ad4465f8 | |||
| 5b1e8450e7 | |||
| 99490f289b | |||
| e37c7be434 | |||
| 39f4adb4dc | |||
| 393b43a881 | |||
| 394b5ac5fd | |||
| dbc7d0ee09 | |||
| 08f26a2d2e | |||
| a51622a0e7 | |||
| 20ff543ff2 | |||
| 12d3824212 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@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: conversation-shaping plugins do not observe housekeeping requests. Title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
|
||||
+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
+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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -219,9 +219,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(logQueries[0]).toEqual({ after: "0" })
|
||||
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" }))
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLM, 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,12 +9,17 @@ 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 { SessionModelRequest } from "./model-request.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 { 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"
|
||||
|
||||
@@ -65,12 +70,13 @@ 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 modelRequests: SessionModelRequest.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -79,8 +85,6 @@ 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[]
|
||||
@@ -88,6 +92,8 @@ export type ManualInput = {
|
||||
readonly started?: boolean
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
@@ -260,51 +266,65 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
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
|
||||
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: [],
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
)
|
||||
yield* dependencies.llm
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.resolved.ref,
|
||||
}),
|
||||
),
|
||||
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,
|
||||
})
|
||||
.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
@@ -405,13 +425,14 @@ export const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
return make({ bus, llm, models, modelRequests })
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, app, hooks })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
@@ -61,19 +61,13 @@ interface PrepareInput {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
/** Omitted for housekeeping requests that carry no tools. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
readonly tools: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape agent conversations. Housekeeping callers
|
||||
* (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"
|
||||
}
|
||||
@@ -215,10 +209,7 @@ export const layer = Layer.effect(
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
const tools = input.scope.tools ?? {
|
||||
definitions: [],
|
||||
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
|
||||
}
|
||||
const tools = input.scope.tools
|
||||
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.
|
||||
@@ -228,18 +219,14 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
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,
|
||||
})
|
||||
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])),
|
||||
})
|
||||
// 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.
|
||||
|
||||
@@ -315,6 +315,7 @@ 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 }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, 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,10 +8,14 @@ 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 { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
@@ -21,14 +25,15 @@ 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 {
|
||||
@@ -76,28 +81,39 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const prepared = yield* dependencies.modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved },
|
||||
transcript: {
|
||||
system: agent.system ? [SystemPart.make(agent.system)] : [],
|
||||
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,
|
||||
messages: [Message.user(firstUser.text)],
|
||||
},
|
||||
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
|
||||
tools: [],
|
||||
}),
|
||||
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
|
||||
@@ -130,10 +146,11 @@ 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 title = make({ bus, llm, agents, models, modelRequests, store })
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ bus, llm, agents, models, store, app, hooks })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
@@ -148,8 +165,9 @@ export const node = makeLocationNode({
|
||||
llmClient,
|
||||
Agent.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelRequest.node,
|
||||
SessionStore.node,
|
||||
Database.node,
|
||||
App.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -236,7 +236,6 @@ describe("ModelResolver", () => {
|
||||
endpoint: { baseURL: "https://openai.example/v1" },
|
||||
defaults: {
|
||||
headers: { "x-test": "header" },
|
||||
limits: { context: 100, input: 80, output: 20 },
|
||||
http: { body: { custom_extension: { enabled: true } } },
|
||||
},
|
||||
})
|
||||
@@ -785,7 +784,6 @@ describe("ModelResolver", () => {
|
||||
region: "test",
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
|
||||
},
|
||||
@@ -913,7 +911,6 @@ describe("ModelResolver", () => {
|
||||
baseURL: "https://provider.example/v1",
|
||||
headers: { "x-provider": "header" },
|
||||
body: { custom: true },
|
||||
limits: { context: 100, output: 20 },
|
||||
providerOptions,
|
||||
})
|
||||
return LanguageModel.make({ id: modelID, provider: "native-provider", route: native.route })
|
||||
|
||||
@@ -71,6 +71,13 @@ describe("LMStudioPlugin", () => {
|
||||
max_context_length: 131_072,
|
||||
capabilities: { vision: false, trained_for_tool_use: false },
|
||||
},
|
||||
{
|
||||
type: "llm",
|
||||
key: "unknown-context",
|
||||
display_name: "Unknown Context",
|
||||
loaded_instances: [],
|
||||
max_context_length: 0,
|
||||
},
|
||||
{
|
||||
type: "embedding",
|
||||
key: "nomic-embed",
|
||||
@@ -104,11 +111,14 @@ describe("LMStudioPlugin", () => {
|
||||
family: "gemma4",
|
||||
name: "Gemma 4 26B A4B",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 16_384, output: 0 },
|
||||
limit: { context: 16_384, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("deepseek-r1"))).toMatchObject({
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
limit: { context: 131_072, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
}),
|
||||
|
||||
@@ -54,6 +54,7 @@ describe("OllamaPlugin", () => {
|
||||
return Response.json({
|
||||
models: [
|
||||
summary("gemma3:4b", "gemma-digest", "gemma3"),
|
||||
summary("unknown-context", "unknown-digest"),
|
||||
summary("nomic-embed", "embed-digest"),
|
||||
summary("removed-model", "removed-digest"),
|
||||
],
|
||||
@@ -68,6 +69,8 @@ describe("OllamaPlugin", () => {
|
||||
capabilities: ["completion", "tools", "vision"],
|
||||
model_info: { "gemma3.context_length": 131_072 },
|
||||
}
|
||||
: body.model === "unknown-context"
|
||||
? show({ family: "unknown", capabilities: ["completion"], context: 0 })
|
||||
: show({ family: "nomic-bert", capabilities: ["embedding"], context: 8192 }),
|
||||
)
|
||||
},
|
||||
@@ -98,7 +101,10 @@ describe("OllamaPlugin", () => {
|
||||
name: "gemma3:4b",
|
||||
family: "gemma3",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
limit: { context: 131_072, output: 0 },
|
||||
limit: { context: 131_072, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-context"))).toMatchObject({
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("nomic-embed"))).toBeUndefined()
|
||||
expect(requests).toContainEqual({ method: "GET", path: "/api/tags" })
|
||||
|
||||
@@ -63,7 +63,11 @@ describe("VLLMPlugin", () => {
|
||||
state.models++
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [remoteModel("Qwen/Qwen3-Coder", 65_536), remoteModel("foreign-model", 4096, "other")],
|
||||
data: [
|
||||
remoteModel("Qwen/Qwen3-Coder", 65_536),
|
||||
remoteModel("unknown-limit", 0),
|
||||
remoteModel("foreign-model", 4096, "other"),
|
||||
],
|
||||
})
|
||||
},
|
||||
}),
|
||||
@@ -97,7 +101,10 @@ describe("VLLMPlugin", () => {
|
||||
modelID: "Qwen/Qwen3-Coder",
|
||||
name: "Qwen/Qwen3-Coder",
|
||||
capabilities: { tools: false, input: ["text"], output: ["text"] },
|
||||
limit: { context: 65_536, output: 0 },
|
||||
limit: { context: 65_536, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("unknown-limit"))).toMatchObject({
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
expect(yield* catalog.model.get(providerID, Model.ID.make("foreign-model"))).toBeUndefined()
|
||||
}),
|
||||
|
||||
@@ -28,7 +28,7 @@ const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const model = LanguageModel.make({
|
||||
id: "summary-model",
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
route: OpenAIChat.route,
|
||||
})
|
||||
let requests: LLMRequest[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -19,7 +19,6 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -31,7 +30,7 @@ let requests: LLMRequest[] = []
|
||||
const model = LanguageModel.make({
|
||||
id: "summary-model",
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
route: OpenAIChat.route,
|
||||
})
|
||||
const cost = [
|
||||
{
|
||||
@@ -77,14 +76,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
PluginHooks.node,
|
||||
SessionCompaction.node,
|
||||
]),
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
@@ -182,35 +174,6 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
/** Seeds the global project plus one session row, returning the projected session. */
|
||||
const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
directory: "/project",
|
||||
title: id,
|
||||
version: "test",
|
||||
...overrides,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const store = yield* SessionStore.Service
|
||||
return yield* store
|
||||
.get(id)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
@@ -226,7 +189,33 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
text: "Manual compaction should include this short conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
parent_id: parentID,
|
||||
slug: "manual-compaction",
|
||||
directory: "/project",
|
||||
title: "Manual compaction",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
|
||||
),
|
||||
)
|
||||
|
||||
const delta = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
@@ -276,66 +265,3 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const sessionID = Session.ID.make("ses_fork_compaction")
|
||||
const rootID = Session.ID.make("ses_fork_compaction_root")
|
||||
const session = yield* insertSession(sessionID, {
|
||||
fork_session_id: rootID,
|
||||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.promptCacheKey).toBe(rootID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
// Conversation-shaping hooks must not observe housekeeping requests: compaction
|
||||
// opts out of context hooks, so the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(SystemPart.make("Injected conversation context"))
|
||||
}),
|
||||
)
|
||||
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_hook_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2857,11 +2857,17 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
yield* TestLLM.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.textStart({ id: "commentary", providerMetadata: { openai: { phase: "commentary" } } }),
|
||||
LLMEvent.textStart({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
}),
|
||||
LLMEvent.textDelta({ id: "commentary", text: "Checking." }),
|
||||
LLMEvent.textEnd({
|
||||
id: "commentary",
|
||||
providerMetadata: { openai: { phase: "commentary" }, anthropic: { ignored: true } },
|
||||
providerMetadata: {
|
||||
openai: { itemId: "msg_commentary", phase: "commentary" },
|
||||
anthropic: { ignored: true },
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -2872,7 +2878,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "user", text: "Check first" },
|
||||
{
|
||||
type: "assistant",
|
||||
content: [{ type: "text", text: "Checking.", state: { phase: "commentary" } }],
|
||||
content: [{ type: "text", text: "Checking.", state: { itemId: "msg_commentary", phase: "commentary" } }],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -2884,7 +2890,7 @@ describe("SessionRunnerLLM", () => {
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -14,7 +14,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -28,7 +27,7 @@ let requests: LLMRequest[] = []
|
||||
const model = LanguageModel.make({
|
||||
id: "title-model",
|
||||
provider: "test",
|
||||
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
|
||||
route: OpenAIChat.route,
|
||||
})
|
||||
const cost = [
|
||||
{
|
||||
@@ -79,15 +78,7 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Agent.node,
|
||||
PluginHooks.node,
|
||||
SessionTitle.node,
|
||||
]),
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
@@ -164,9 +155,6 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
expect(requests[0]?.tools).toEqual([])
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
|
||||
const renamed = yield* store.get(sessionID)
|
||||
expect(renamed?.title).toBe("Generated Title")
|
||||
@@ -335,38 +323,6 @@ it.effect("retries after a failed title request", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from title requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
// Conversation-shaping hooks must not observe housekeeping requests: title
|
||||
// generation opts out of context hooks, so the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(SystemPart.make("Keep titles in sentence case."))
|
||||
}),
|
||||
)
|
||||
const sessionID = Session.ID.make("ses_title_context_hook")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Hook this title request")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a manual rename completed while generation is in flight", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { sentryVitePlugin } from "@sentry/vite-plugin"
|
||||
import { defineConfig } from "electron-vite"
|
||||
import appPlugin from "@opencode-ai/app/vite"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -11,9 +9,10 @@ const channel = (() => {
|
||||
|
||||
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
|
||||
const appPlugin = (await import("@opencode-ai/app/vite")).default
|
||||
const sentry =
|
||||
process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && process.env.SENTRY_PROJECT
|
||||
? sentryVitePlugin({
|
||||
? (await import("@sentry/vite-plugin")).sentryVitePlugin({
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
@@ -34,11 +33,12 @@ export default defineConfig({
|
||||
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: { index: "src/main/index.ts" },
|
||||
// Keep this identical to electron-vite's Node 20.11+ shim. Its regex insertion can
|
||||
// corrupt bundled TypeScript, while a Rollup banner places the shim safely.
|
||||
// corrupt bundled TypeScript, while an output banner places the shim safely.
|
||||
output: {
|
||||
format: "es",
|
||||
banner: `
|
||||
// -- CommonJS Shims --
|
||||
import __cjs_mod__ from 'node:module';
|
||||
@@ -56,13 +56,14 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
enforce: "pre",
|
||||
resolveId(s) {
|
||||
if (s === "@lydell/node-pty") return nodePtyPkg
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: { index: "src/preload/index.ts" },
|
||||
output: {
|
||||
format: "cjs",
|
||||
@@ -72,6 +73,9 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
experimental: {
|
||||
bundledDev: true,
|
||||
},
|
||||
define: {
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
@@ -81,7 +85,7 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
root: "src/renderer",
|
||||
build: {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
input: {
|
||||
main: "src/renderer/index.html",
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -40,7 +41,6 @@
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@sentry/solid": "catalog:",
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
@@ -50,11 +50,11 @@
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"electron": "42.3.3",
|
||||
"electron-builder": "26.15.2",
|
||||
"electron-vite": "^5",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"solid-js": "catalog:",
|
||||
"sury": "11.0.0-alpha.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "catalog:",
|
||||
"vite": "8.2.1",
|
||||
"zod-openapi": "5.4.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -11,14 +11,17 @@ async function main() {
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB = "0"
|
||||
const options = selectOptions()
|
||||
if (options.server.type === "build") process.env.OPENCODE_DESKTOP_SERVER_CHANNEL = "local"
|
||||
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
|
||||
await prepareDesktop()
|
||||
await prepareServer(options.server)
|
||||
await startDesktop(options.electron)
|
||||
}
|
||||
|
||||
async function prepareDesktop() {
|
||||
await $`bun run install-electron`
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
await Promise.all([
|
||||
$`bun run install-electron`,
|
||||
$`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`,
|
||||
])
|
||||
}
|
||||
|
||||
function selectOptions(): DevOptions {
|
||||
@@ -46,7 +49,6 @@ async function prepareServer(source: ServerSource) {
|
||||
}
|
||||
|
||||
async function startDesktop(args: string[]) {
|
||||
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
|
||||
await $`electron-vite dev ${args}`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,103 +1,100 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { access, readFile, readdir } from "node:fs/promises"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import util from "node:util"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
|
||||
const execFilePromise = util.promisify(execFile)
|
||||
|
||||
const exists = (path: string) =>
|
||||
access(path)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
export function checkAppExists(appName: string) {
|
||||
export const checkAppExists = Effect.fn("DesktopFiles.checkAppExists")(function* (appName: string) {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
return yield* checkMacosApp(appName)
|
||||
})
|
||||
|
||||
export function resolveAppPath(appName: string) {
|
||||
export const resolveAppPath = Effect.fn("DesktopFiles.resolveAppPath")(function* (appName: string) {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
return yield* resolveWindowsAppPath(appName)
|
||||
})
|
||||
|
||||
async function checkMacosApp(appName: string) {
|
||||
const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
const home = process.env.HOME
|
||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||
|
||||
for (const location of locations) {
|
||||
if (await exists(location)) return true
|
||||
if (yield* exists(fs, location)) return true
|
||||
}
|
||||
|
||||
return execFilePromise("which", [appName])
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
|
||||
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
let output: string
|
||||
try {
|
||||
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(function* (appName: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!result) return null
|
||||
|
||||
const paths = output
|
||||
const paths = result.stdout
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
|
||||
const hasExt = (value: string, ext: string) => path.extname(value).toLowerCase() === `.${ext}`
|
||||
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = async (path: string) => {
|
||||
const content = await readFile(path, "utf8")
|
||||
const resolveCmd = Effect.fnUntraced(function* (file: string) {
|
||||
const content = yield* fs.readFileString(file)
|
||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||
const lower = token.toLowerCase()
|
||||
if (!lower.includes(".exe")) continue
|
||||
|
||||
const index = lower.indexOf("%~dp0")
|
||||
if (index >= 0) {
|
||||
const base = dirname(path)
|
||||
const base = path.dirname(file)
|
||||
const suffix = token.slice(index + 5)
|
||||
const resolved = suffix
|
||||
.replace(/\//g, "\\")
|
||||
.split("\\")
|
||||
.filter((part: string) => part && part !== ".")
|
||||
.reduce((current: string, part: string) => {
|
||||
if (part === "..") return dirname(current)
|
||||
return join(current, part)
|
||||
if (part === "..") return path.dirname(current)
|
||||
return path.join(current, part)
|
||||
}, base)
|
||||
|
||||
if (await exists(resolved)) return resolved
|
||||
if (yield* exists(fs, resolved)) return resolved
|
||||
}
|
||||
|
||||
if (await exists(token)) return token
|
||||
if (yield* exists(fs, token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = await resolveCmd(path)
|
||||
for (const file of paths) {
|
||||
if (hasExt(file, "cmd") || hasExt(file, "bat")) {
|
||||
const resolved = yield* resolveCmd(file)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (await exists(cmd)) {
|
||||
const resolved = await resolveCmd(cmd)
|
||||
if (!path.extname(file)) {
|
||||
const cmd = `${file}.cmd`
|
||||
if (yield* exists(fs, cmd)) {
|
||||
const resolved = yield* resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${path}.bat`
|
||||
if (await exists(bat)) {
|
||||
const resolved = await resolveCmd(bat)
|
||||
const bat = `${file}.bat`
|
||||
if (yield* exists(fs, bat)) {
|
||||
const resolved = yield* resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
@@ -110,27 +107,31 @@ async function resolveWindowsAppPath(appName: string): Promise<string | null> {
|
||||
.join("")
|
||||
|
||||
if (key) {
|
||||
for (const path of paths) {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const file of paths) {
|
||||
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const entry of await readdir(dir)) {
|
||||
const candidate = join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
const name = stem
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
if (name.includes(key) || key.includes(name)) return candidate
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([])))
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
const name = stem
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
if (name.includes(key) || key.includes(name)) return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths[0] ?? null
|
||||
})
|
||||
|
||||
function exists(fs: FileSystem.FileSystem, path: string) {
|
||||
return fs.access(path).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import {
|
||||
assertAttachmentBudget,
|
||||
createPickedFileAuthorizations,
|
||||
@@ -9,6 +11,9 @@ import {
|
||||
readAttachment,
|
||||
} from "./attachment-picker"
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
|
||||
describe("assertAttachmentBudget", () => {
|
||||
test("accepts selections within the media ingest limit", () => {
|
||||
expect(() =>
|
||||
@@ -25,7 +30,7 @@ describe("assertAttachmentBudget", () => {
|
||||
const file = join(directory, "example.txt")
|
||||
try {
|
||||
await writeFile(file, "lorem ipsum")
|
||||
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
|
||||
expect(new TextDecoder().decode(await run(readAttachment(file)))).toBe("lorem ipsum")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -37,7 +42,7 @@ describe("assertAttachmentBudget", () => {
|
||||
try {
|
||||
await writeFile(file, "")
|
||||
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
|
||||
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
|
||||
await expect(run(readAttachment(file))).rejects.toThrow("20 MB limit")
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
@@ -45,16 +50,16 @@ describe("assertAttachmentBudget", () => {
|
||||
})
|
||||
|
||||
describe("picked file authorizations", () => {
|
||||
const read = async (path: string) => new TextEncoder().encode(path).buffer
|
||||
const read = (path: string) => Effect.sync(() => new TextEncoder().encode(path).buffer)
|
||||
|
||||
test("keeps concurrent picker selections isolated", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const first = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
const second = authorizations.add(1, ["c.txt"])
|
||||
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
|
||||
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "a.txt")))).toBe("a.txt")
|
||||
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "c.txt")))).toBe("c.txt")
|
||||
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "b.txt")))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("releases unread files for one picker without affecting another", async () => {
|
||||
@@ -63,25 +68,29 @@ describe("picked file authorizations", () => {
|
||||
const second = authorizations.add(1, ["b.txt"])
|
||||
authorizations.release(1, first)
|
||||
|
||||
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
|
||||
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
|
||||
await expect(run(authorizations.read(1, first, "a.txt"))).rejects.toThrow("not selected")
|
||||
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "b.txt")))).toBe("b.txt")
|
||||
})
|
||||
|
||||
test("keeps picker tokens scoped to their renderer", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(read)
|
||||
const token = authorizations.add(1, ["a.txt"])
|
||||
|
||||
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
|
||||
await expect(run(authorizations.read(2, token, "a.txt"))).rejects.toThrow("not selected")
|
||||
})
|
||||
|
||||
test("charges actual reads against the selection budget", async () => {
|
||||
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}, 10)
|
||||
const authorizations = createPickedFileAuthorizations(
|
||||
(_path, maxBytes) =>
|
||||
Effect.sync(() => {
|
||||
if (6 > maxBytes) throw new Error("budget exceeded")
|
||||
return new ArrayBuffer(6)
|
||||
}),
|
||||
10,
|
||||
)
|
||||
const token = authorizations.add(1, ["a.txt", "b.txt"])
|
||||
|
||||
await authorizations.read(1, token, "a.txt")
|
||||
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
|
||||
await run(authorizations.read(1, token, "a.txt"))
|
||||
await expect(run(authorizations.read(1, token, "b.txt"))).rejects.toThrow("budget exceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { open } from "node:fs/promises"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
|
||||
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
export function createPickedFileAuthorizations(
|
||||
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
|
||||
read: (path: string, maxBytes: number) => Effect.Effect<ArrayBuffer, unknown>,
|
||||
budget = MAX_ATTACHMENT_BYTES,
|
||||
) {
|
||||
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
|
||||
@@ -16,15 +16,15 @@ export function createPickedFileAuthorizations(
|
||||
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
|
||||
return token
|
||||
},
|
||||
async read(sender: number, token: string, path: string) {
|
||||
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
|
||||
const selection = selections.get(token)
|
||||
if (selection?.sender !== sender || !selection.paths.delete(path))
|
||||
throw new Error(nativeT("desktop.picker.error.notSelected"))
|
||||
const bytes = await read(path, selection.remaining)
|
||||
const bytes = yield* read(path, selection.remaining)
|
||||
selection.remaining -= bytes.byteLength
|
||||
if (selection.paths.size === 0) selections.delete(token)
|
||||
return bytes
|
||||
},
|
||||
}),
|
||||
release(sender: number, token: string) {
|
||||
if (selections.get(token)?.sender === sender) selections.delete(token)
|
||||
},
|
||||
@@ -37,21 +37,23 @@ export function assertAttachmentBudget(files: { size: number }[]) {
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
}
|
||||
|
||||
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
|
||||
const file = await open(filePath, "r")
|
||||
try {
|
||||
const info = await file.stat()
|
||||
if (info.size > maxBytes)
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
const bytes = Buffer.allocUnsafe(info.size)
|
||||
let offset = 0
|
||||
while (offset < info.size) {
|
||||
const result = await file.read(bytes, offset, info.size - offset, offset)
|
||||
if (result.bytesRead === 0) break
|
||||
offset += result.bytesRead
|
||||
}
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const file = yield* fs.open(filePath, { flag: "r" })
|
||||
const info = yield* file.stat
|
||||
if (info.size > FileSystem.Size(maxBytes))
|
||||
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
|
||||
|
||||
const bytes = new Uint8Array(Number(info.size))
|
||||
let offset = 0
|
||||
while (offset < bytes.byteLength) {
|
||||
const read = Number(yield* file.read(bytes.subarray(offset)))
|
||||
if (read === 0) break
|
||||
offset += read
|
||||
}
|
||||
return bytes.buffer.slice(0, offset)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,98 @@
|
||||
export * as DesktopFiles from "./index"
|
||||
|
||||
import { execFile } from "node:child_process"
|
||||
import { stat } from "node:fs/promises"
|
||||
import { basename } from "node:path"
|
||||
import { clipboard, dialog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
|
||||
import { writeLog } from "../native/logging"
|
||||
import { scoped } from "../native/logging"
|
||||
import { nativeT } from "../native/translations"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||
import { assertAttachmentBudget, createPickedFileAuthorizations, readAttachment } from "./attachment-picker"
|
||||
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
|
||||
|
||||
export function createFileCapabilities() {
|
||||
const pickedFiles = createPickedFileAuthorizations()
|
||||
export type Interface = ReturnType<typeof make>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopFiles") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
return Service.of(make(fs, path))
|
||||
}),
|
||||
)
|
||||
|
||||
function make(fs: FileSystem.FileSystem, path: Path.Path) {
|
||||
const pickedFiles = createPickedFileAuthorizations((file, maxBytes) =>
|
||||
readAttachment(file, maxBytes).pipe(Effect.provideService(FileSystem.FileSystem, fs)),
|
||||
)
|
||||
|
||||
return {
|
||||
async openDirectoryPicker(options?: DirectoryPickerOptions) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: options?.defaultPath,
|
||||
})
|
||||
openDirectoryPicker: Effect.fn("DesktopFiles.openDirectoryPicker")(function* (options?: DirectoryPickerOptions) {
|
||||
const result = yield* Effect.promise(() =>
|
||||
dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
|
||||
defaultPath: options?.defaultPath,
|
||||
}),
|
||||
)
|
||||
if (result.canceled) return null
|
||||
return options?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
async openFilePicker(sender: number, options?: FilePickerOptions) {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
filters: pickerFilters(options?.extensions),
|
||||
})
|
||||
}),
|
||||
openFilePicker: Effect.fn("DesktopFiles.openFilePicker")(function* (sender: number, options?: FilePickerOptions) {
|
||||
const result = yield* Effect.promise(() =>
|
||||
dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
filters: pickerFilters(options?.extensions),
|
||||
}),
|
||||
)
|
||||
if (result.canceled) return null
|
||||
const files = await Promise.all(
|
||||
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
|
||||
const files = yield* Effect.forEach(
|
||||
result.filePaths,
|
||||
Effect.fnUntraced(function* (file) {
|
||||
const info = yield* fs.stat(file)
|
||||
return { path: file, name: path.basename(file), size: Number(info.size) }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
assertAttachmentBudget(files)
|
||||
return { token: pickedFiles.add(sender, result.filePaths), files }
|
||||
},
|
||||
readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path),
|
||||
releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token),
|
||||
async saveFilePicker(options?: SaveFilePickerOptions) {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
})
|
||||
}),
|
||||
readPickedFile: pickedFiles.read,
|
||||
releasePickedFiles: pickedFiles.release,
|
||||
saveFilePicker: Effect.fn("DesktopFiles.saveFilePicker")(function* (options?: SaveFilePickerOptions) {
|
||||
const result = yield* Effect.promise(() =>
|
||||
dialog.showSaveDialog({
|
||||
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
|
||||
defaultPath: options?.defaultPath,
|
||||
}),
|
||||
)
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
},
|
||||
async openPath(path: string, application?: string) {
|
||||
if (!application) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? { file: "open", arguments: ["-a", application, path] }
|
||||
: { file: application, arguments: [path] }
|
||||
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
|
||||
})
|
||||
},
|
||||
async revealPath(path: string) {
|
||||
const exists = await stat(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
}),
|
||||
openPath: Effect.fn("DesktopFiles.openPath")(function* (target: string, application?: string) {
|
||||
if (!application) return yield* Effect.promise(() => shell.openPath(target))
|
||||
yield* Effect.tryPromise(() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? { file: "open", arguments: ["-a", application, target] }
|
||||
: { file: application, arguments: [target] }
|
||||
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) {
|
||||
const exists = yield* fs.stat(target).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
if (!exists) return false
|
||||
shell.showItemInFolder(path)
|
||||
shell.showItemInFolder(target)
|
||||
return true
|
||||
},
|
||||
}),
|
||||
readClipboardImage() {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
@@ -73,25 +102,24 @@ export function createFileCapabilities() {
|
||||
}
|
||||
}
|
||||
|
||||
export function openExternalURL(value: string) {
|
||||
export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) {
|
||||
const url = resolveExternalURL(value)
|
||||
if (!url) {
|
||||
writeLog("window", "blocked external target", { url: value }, "warn")
|
||||
yield* scoped("window", Effect.logWarning("blocked external target", { url: value }))
|
||||
return
|
||||
}
|
||||
void shell.openExternal(url)
|
||||
}
|
||||
yield* Effect.promise(() => shell.openExternal(url))
|
||||
})
|
||||
|
||||
export function openLocalFileURL(value: string) {
|
||||
export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) {
|
||||
const path = resolveLocalFilePath(value)
|
||||
if (!path) {
|
||||
writeLog("window", "blocked local file target", { url: value }, "warn")
|
||||
yield* scoped("window", Effect.logWarning("blocked local file target", { url: value }))
|
||||
return
|
||||
}
|
||||
void shell.openPath(path).then((error) => {
|
||||
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
|
||||
})
|
||||
}
|
||||
const error = yield* Effect.promise(() => shell.openPath(path))
|
||||
if (error) yield* scoped("window", Effect.logError("failed to open local file", { path, error }))
|
||||
})
|
||||
|
||||
function pickerFilters(extensions?: string[]) {
|
||||
if (!extensions?.length) return undefined
|
||||
|
||||
@@ -1,94 +1,75 @@
|
||||
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
|
||||
import { app } from "electron"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import type { ServerReadyData } from "../shared/ipc-contract"
|
||||
import { checkAppExists, resolveAppPath } from "./files/apps"
|
||||
import { registerIpcHandlers, registerUpdaterIpcHandlers, registerWslIpcHandlers } from "./ipc"
|
||||
import { Ipc } from "./ipc"
|
||||
import {
|
||||
acquireApplicationLock,
|
||||
configureApplication,
|
||||
loadProxyEnvironment,
|
||||
preferApplicationEnvironment,
|
||||
prepareApplicationEnvironment,
|
||||
prepareDesktop,
|
||||
} from "./lifecycle/environment"
|
||||
import { createApplicationLifecycle } from "./lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding"
|
||||
import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging"
|
||||
import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
import { setNativeTranslations } from "./native/translations"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { initializeFirstLaunchOnboarding } from "./lifecycle/onboarding"
|
||||
import { Shutdown } from "./lifecycle/shutdown"
|
||||
import { DesktopLogging } from "./native/logging"
|
||||
import { startBackgroundCli } from "./service/background-service"
|
||||
import { forwardInitializationFailure } from "./service/initialization"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings"
|
||||
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
|
||||
import { startWsl } from "./wsl/start"
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
const logger = configureApplication()
|
||||
if (!acquireApplicationLock()) return
|
||||
preferApplicationEnvironment(logger)
|
||||
const lifecycle = createApplicationLifecycle(logger)
|
||||
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
|
||||
import { Updater } from "./updater"
|
||||
|
||||
const runApplication = Effect.gen(function* () {
|
||||
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
|
||||
yield* prepareApplicationEnvironment
|
||||
yield* preferApplicationEnvironment
|
||||
yield* Effect.promise(() => app.whenReady())
|
||||
yield* prepareDesktop(logger)
|
||||
|
||||
const updater = setupAutoUpdater(lifecycle.prepareToRestart)
|
||||
const menu = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => void showUpdaterDialog(updater),
|
||||
relaunch: lifecycle.relaunch,
|
||||
}
|
||||
registerIpcHandlers({
|
||||
relaunch: lifecycle.relaunch,
|
||||
awaitInitialization: Effect.fnUntraced(
|
||||
function* () {
|
||||
logger.log("awaiting server ready")
|
||||
const result = yield* Deferred.await(serverReady)
|
||||
logger.log("server ready", { url: result.url })
|
||||
return result
|
||||
},
|
||||
(effect) => Effect.runPromise(effect),
|
||||
),
|
||||
consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks,
|
||||
getDefaultServerUrl,
|
||||
setDefaultServerUrl,
|
||||
isFirstLaunchOnboardingPending,
|
||||
finishFirstLaunchOnboarding,
|
||||
checkAppExists,
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
showUpdater: () => showUpdaterDialog(updater),
|
||||
setBackgroundColor,
|
||||
exportDebugLogs,
|
||||
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
|
||||
setNativeTranslations: (bundle) => {
|
||||
if (setNativeTranslations(bundle)) createMenu(menu)
|
||||
},
|
||||
})
|
||||
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
|
||||
startAutoUpdater(updater)
|
||||
yield* Effect.promise(() => startNetworkLogging())
|
||||
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
loadProxyEnvironment(logger)
|
||||
logger.log("starting v2 background service")
|
||||
const background = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
const wsl = yield* Effect.promise(() => startWsl(background, logger))
|
||||
registerWslIpcHandlers(wsl.ipc)
|
||||
wsl.start()
|
||||
lifecycle.setWslShutdown(wsl.stop)
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: background.url,
|
||||
username: background.username,
|
||||
password: background.password,
|
||||
})
|
||||
logger.log("loading task finished")
|
||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||
|
||||
yield* Fiber.await(loadingTask)
|
||||
if (lifecycle.restoreWindows().length) createMenu(menu)
|
||||
yield* prepareDesktop
|
||||
yield* runDesktop.pipe(Effect.provide(Updater.layer))
|
||||
})
|
||||
|
||||
Effect.runFork(main)
|
||||
const runDesktop = Effect.gen(function* () {
|
||||
const logging = yield* DesktopLogging.Service
|
||||
yield* logging.startNetwork
|
||||
yield* loadProxyEnvironment
|
||||
yield* Effect.logInfo("starting v2 background service")
|
||||
const loading = yield* startBackgroundCli().pipe(Effect.exit)
|
||||
const initialization = Exit.isSuccess(loading)
|
||||
? Effect.succeed({
|
||||
url: loading.value.url,
|
||||
username: loading.value.username,
|
||||
password: loading.value.password,
|
||||
} satisfies ServerReadyData)
|
||||
: Effect.failCause(loading.cause).pipe(Effect.orDie)
|
||||
|
||||
yield* runIpc(Exit.isSuccess(loading)).pipe(
|
||||
Effect.provide(Ipc.layer(initialization, Exit.isSuccess(loading) ? loading.value : undefined)),
|
||||
)
|
||||
})
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* (loaded: boolean) {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (loaded) yield* Effect.logInfo("loading task finished")
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
app.once("will-quit", quit)
|
||||
return Effect.sync(() => app.off("will-quit", quit))
|
||||
})
|
||||
})
|
||||
|
||||
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer).pipe(
|
||||
Layer.provideMerge(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
|
||||
const main = Effect.gen(function* () {
|
||||
if (!acquireApplicationLock()) return
|
||||
yield* configureApplication()
|
||||
yield* runApplication
|
||||
})
|
||||
|
||||
main.pipe(
|
||||
Effect.provide(ApplicationLifecycle.layer.pipe(Layer.provideMerge(platform))),
|
||||
Effect.scoped,
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { WebContents } from "electron"
|
||||
import { Effect, Queue, Stream } from "effect"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
|
||||
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
|
||||
|
||||
export const bindIpcEvents = Effect.fn("IpcEvents.bind")(function* (senderId: number) {
|
||||
const queue = yield* Queue.unbounded<DesktopEvent>()
|
||||
const previous = queues.get(senderId)
|
||||
queues.set(senderId, queue)
|
||||
if (previous) yield* Queue.shutdown(previous)
|
||||
return Effect.fnUntraced(function* () {
|
||||
if (queues.get(senderId) === queue) queues.delete(senderId)
|
||||
yield* Queue.shutdown(queue)
|
||||
})()
|
||||
})
|
||||
|
||||
export function ipcEventStream(senderId: number) {
|
||||
const queue = queues.get(senderId)
|
||||
return queue ? Stream.fromQueue(queue) : Stream.empty
|
||||
}
|
||||
|
||||
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
|
||||
const queue = queues.get(sender.id)
|
||||
if (queue) Queue.offerUnsafe(queue, event)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { parseDesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
|
||||
import { Effect } from "effect"
|
||||
import { AppRpcs } from "../../shared/ipc-rpc"
|
||||
import { openExternalURL } from "../files"
|
||||
import { checkAppExists, resolveAppPath } from "../files/apps"
|
||||
import { setForceFocus } from "../native/debug"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { createMenu, sendMenuCommand } from "../native/menu"
|
||||
import { setNativeTranslations } from "../native/translations"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
|
||||
import { Initialization } from "../service/initialization"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
|
||||
import { Updater } from "../updater"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const appHandlers = AppRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const initialization = yield* Initialization.Service
|
||||
const updater = yield* Updater.Service
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => initialization.await,
|
||||
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
|
||||
AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending,
|
||||
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
|
||||
finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie),
|
||||
AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie),
|
||||
AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie),
|
||||
AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)),
|
||||
AppExportDebugLogs: () => logging.exportDebug,
|
||||
AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)),
|
||||
AppRecordFatalRendererError: ({ error }) =>
|
||||
scoped("renderer", Effect.logError("fatal renderer error", { ...error })),
|
||||
AppSetNativeTranslations: ({ value }, context) =>
|
||||
Effect.sync(() => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents) {
|
||||
throw new Error("Invalid native translation sender")
|
||||
}
|
||||
const bundle = parseDesktopNativeBundle(value)
|
||||
if (!bundle) throw new Error("Invalid native translation bundle")
|
||||
if (!setNativeTranslations(bundle)) return
|
||||
createMenu({
|
||||
trigger: (id) => {
|
||||
const win = getLastFocusedWindow()
|
||||
if (win) sendMenuCommand(win, id)
|
||||
},
|
||||
checkForUpdates: () => runFork(updater.show),
|
||||
createWindow: lifecycle.createWindow,
|
||||
openExternal: (url) => runFork(openExternalURL(url)),
|
||||
relaunch: lifecycle.relaunch,
|
||||
})
|
||||
}),
|
||||
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
function promise<A>(evaluate: () => A | Promise<A>) {
|
||||
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { IpcPortHandoff } from "../ipc-transport"
|
||||
|
||||
export type RpcContext = { readonly client: { readonly id: number } }
|
||||
|
||||
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
|
||||
const contents = handoff.sender(context.client.id)
|
||||
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
|
||||
return contents
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import { EventRpcs } from "../../shared/ipc-rpc"
|
||||
import { ipcEventStream } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Effect } from "effect"
|
||||
import { FileRpcs } from "../../shared/ipc-rpc"
|
||||
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const fileHandlers = FileRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const files = yield* DesktopFiles.Service
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return FileRpcs.of({
|
||||
FilesOpenDirectoryPicker: ({ options }) => files.openDirectoryPicker(options),
|
||||
FilesOpenFilePicker: ({ options }, context) =>
|
||||
files
|
||||
.openFilePicker(
|
||||
sender(handoff, context).id,
|
||||
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
|
||||
)
|
||||
.pipe(Effect.orDie),
|
||||
FilesReadPickedFile: ({ token, path }, context) =>
|
||||
files
|
||||
.readPickedFile(sender(handoff, context).id, token, path)
|
||||
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
|
||||
FilesReleasePickedFiles: ({ token }, context) =>
|
||||
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
|
||||
FilesSaveFilePicker: ({ options }) => files.saveFilePicker(options),
|
||||
FilesOpenExternal: ({ url }) => openExternalURL(url),
|
||||
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
|
||||
FilesOpenPath: ({ path, application }) =>
|
||||
files.openPath(path, application).pipe(
|
||||
Effect.map((result) => result ?? null),
|
||||
Effect.orDie,
|
||||
),
|
||||
FilesRevealPath: ({ path }) => files.revealPath(path),
|
||||
FilesReadClipboardImage: () =>
|
||||
Effect.sync(() => {
|
||||
const image = files.readClipboardImage()
|
||||
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { MenuRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { runDesktopMenuAction } from "../native/menu-actions"
|
||||
import { Updater } from "../updater"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const menuHandlers = MenuRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const updater = yield* Updater.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
return MenuRpcs.of({
|
||||
MenuRunAction: ({ action }, context) =>
|
||||
Effect.sync(() =>
|
||||
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
|
||||
checkForUpdates: () => runFork(updater.show),
|
||||
createWindow: lifecycle.createWindow,
|
||||
relaunch: lifecycle.relaunch,
|
||||
}),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Effect } from "effect"
|
||||
import { StorageRpcs } from "../../shared/ipc-rpc"
|
||||
import { DesktopStorage } from "../storage"
|
||||
|
||||
export const storageHandlers = StorageRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* DesktopStorage.Service
|
||||
return StorageRpcs.of({
|
||||
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
|
||||
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
|
||||
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
|
||||
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
|
||||
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
|
||||
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
|
||||
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
|
||||
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
|
||||
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
|
||||
DraftsPutBlob: ({ data }) =>
|
||||
Effect.sync(() =>
|
||||
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
|
||||
),
|
||||
DraftsGetBlob: ({ id }) =>
|
||||
Effect.sync(() => {
|
||||
const data = storage.drafts.getBlob(id)
|
||||
return data ? new Uint8Array(data) : null
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { UpdaterRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Updater } from "../updater"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const updaterHandlers = UpdaterRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
const updater = yield* Updater.Service
|
||||
return UpdaterRpcs.of({
|
||||
UpdaterSubscribe: (_args, context) => updater.subscribe(sender(handoff, context)),
|
||||
UpdaterUnsubscribe: (_args, context) => updater.unsubscribe(sender(handoff, context).id),
|
||||
UpdaterCheck: () => updater.check,
|
||||
UpdaterInstall: () => updater.install,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { WindowRpcs } from "../../shared/ipc-rpc"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import {
|
||||
getPinchZoomEnabled,
|
||||
getWindowID,
|
||||
setPinchZoomEnabled,
|
||||
setTitlebar,
|
||||
setWindowThemeReady,
|
||||
updateTitlebar,
|
||||
} from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const windowHandlers = WindowRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return WindowRpcs.of({
|
||||
WindowGetId: (_args, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (!win) throw new Error("Window not found")
|
||||
const id = getWindowID(win)
|
||||
if (!id) throw new Error("Window ID not found")
|
||||
return id
|
||||
}),
|
||||
WindowThemeReady: (_args, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (!win) throw new Error("Window not found")
|
||||
setWindowThemeReady(win)
|
||||
}),
|
||||
WindowGetFocused: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
|
||||
WindowGetFullscreen: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
|
||||
WindowSetFocus: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
|
||||
WindowShow: (_args, context) =>
|
||||
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
|
||||
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
|
||||
WindowSetZoomFactor: ({ factor }, context) =>
|
||||
Effect.sync(() => {
|
||||
const contents = sender(handoff, context)
|
||||
contents.setZoomFactor(factor)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (win) updateTitlebar(win)
|
||||
}),
|
||||
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
|
||||
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
|
||||
WindowSetTitlebar: ({ theme }, context) =>
|
||||
Effect.sync(() => {
|
||||
const win = BrowserWindow.fromWebContents(sender(handoff, context))
|
||||
if (win) setTitlebar(win, theme)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user