mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 18:34:42 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e77baed670 | |||
| db4da867e1 |
@@ -87,6 +87,7 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -99,6 +100,7 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -205,6 +207,7 @@
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-W2QBi7/jtbvLp3Gj+nR49+wqVgCB0qfnQ30C0PQOjO4=",
|
||||
"aarch64-linux": "sha256-R4YKo8JUvKd77QYehTNpj/65oodeLBQ8mdxLzBLrFdw=",
|
||||
"aarch64-darwin": "sha256-7v2ABLG6zsIMSzsYu06BWEqbLL4HOeKyZFBx/gx87SI=",
|
||||
"x86_64-darwin": "sha256-emKRoQILMzB5ko45dtMPuZGV2pAyszcwzKUh9ey9JZM="
|
||||
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
|
||||
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
|
||||
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
|
||||
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -87,6 +88,7 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,24 +36,6 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
|
||||
const image: Prompt = [
|
||||
{ type: "text", content: "look", start: 0, end: 4 },
|
||||
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
]
|
||||
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
|
||||
expect(entries).toHaveLength(2)
|
||||
|
||||
const untouched = removeHistoryEntry(entries, text("never sent"))
|
||||
expect(untouched).toBe(entries)
|
||||
|
||||
const withoutComments = removeHistoryEntry(entries, image)
|
||||
expect(withoutComments).toBe(entries)
|
||||
|
||||
const removed = removeHistoryEntry(entries, image, [comment("c1")])
|
||||
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
|
||||
@@ -48,18 +48,6 @@ export function prependHistoryEntry(
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
|
||||
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
|
||||
export function removeHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
) {
|
||||
const entry = { prompt, comments } satisfies PromptHistoryEntry
|
||||
const next = entries.filter((item) => !isPromptEqual(item, entry))
|
||||
return next.length === entries.length ? entries : next
|
||||
}
|
||||
|
||||
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||
return (
|
||||
commentA.path === commentB.path &&
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -14,7 +13,6 @@ import { PromptHistoryState } from "../schema"
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = typeof PromptHistoryState.Type
|
||||
@@ -34,13 +32,6 @@ function createComposerHistoryStore(
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
remove(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = removeHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,12 +56,5 @@ export function createComposerHistory() {
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.remove(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +264,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
|
||||
@@ -53,7 +53,6 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
@@ -61,9 +60,7 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
addToHistory() {},
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -536,8 +533,12 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -548,8 +549,6 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
|
||||
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ type ComposerSubmitInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
@@ -251,8 +250,6 @@ function restoreSubmission(
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
// The prompt is back in the composer; its history entry would only keep attachments referenced.
|
||||
input.removeFromHistory(value.prompt, value.mode, comments)
|
||||
restored.target.set(restored.prompt, promptLength(restored.prompt))
|
||||
restored.target.mode.set(value.mode)
|
||||
restored.target.context.replaceComments(
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
@@ -326,19 +327,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
// Calendar day in the local time zone, comparable as a number.
|
||||
function localDay(date: Date) {
|
||||
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = new Date()
|
||||
const today = localDay(now)
|
||||
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
|
||||
const todaySessions = records.filter((record) => day(record) === today)
|
||||
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
|
||||
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -8,8 +9,6 @@ export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
const RECENT_LIMIT = 5
|
||||
// luxon's diffNow().as("months") used an average month; keep the same window.
|
||||
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
@@ -30,23 +29,27 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
),
|
||||
)
|
||||
|
||||
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
|
||||
const release = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
available().map(
|
||||
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -98,8 +101,9 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export function createSessionContextFormatter(locale: string) {
|
||||
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
|
||||
const dateTime = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
return {
|
||||
number(value: number | null | undefined) {
|
||||
if (value === undefined) return "—"
|
||||
@@ -20,7 +14,7 @@ export function createSessionContextFormatter(locale: string) {
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return dateTime.format(value)
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,9 +131,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
|
||||
// like the Promise variant rather than by attempt count.
|
||||
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
|
||||
@@ -456,10 +456,6 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
|
||||
|
||||
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
|
||||
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
@@ -1660,19 +1656,19 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
@@ -1902,7 +1898,7 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ModelSettings
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
@@ -2111,13 +2107,13 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: ConfigModelSettings
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
@@ -2228,7 +2224,7 @@ export type SessionMessageAssistant = {
|
||||
snapshot?: { start?: string; end?: string; files?: Array<string> }
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
state?: SessionMessageProviderState
|
||||
providerState?: SessionMessageProviderState
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
error?: SessionStructuredError
|
||||
@@ -3180,7 +3176,7 @@ export type SessionImportInput = {
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3497,7 +3493,7 @@ export type SessionImportInput = {
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3814,7 +3810,7 @@ export type SessionImportInput = {
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly state?: { readonly [x: string]: JsonValue }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type EnsureTiming = {
|
||||
readonly pollInterval: number
|
||||
readonly attempts: number
|
||||
readonly requestTimeout: number
|
||||
readonly spawnDelay: number
|
||||
readonly maxSpawnDelay: number
|
||||
@@ -10,11 +11,9 @@ export type EnsureTiming = {
|
||||
|
||||
const timings = new WeakMap<object, EnsureTiming>()
|
||||
|
||||
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
|
||||
// time a client waits for it. Probes are sequential, so the wait between them only matters while
|
||||
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
|
||||
export const defaultEnsureTiming: EnsureTiming = {
|
||||
pollInterval: 25,
|
||||
pollInterval: 100,
|
||||
attempts: 1_200,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.rawFinish = undefined
|
||||
existing.state = undefined
|
||||
existing.providerState = undefined
|
||||
existing.time.created = event.data.started
|
||||
existing.time.streamed = undefined
|
||||
existing.time.completed = undefined
|
||||
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.state = event.data.providerState
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.cost = event.data.cost
|
||||
assistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
|
||||
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
|
||||
assistant.time.completed = event.created
|
||||
assistant.finish = event.data.finish ?? "error"
|
||||
assistant.rawFinish = event.data.rawFinish
|
||||
assistant.state = event.data.providerState
|
||||
assistant.providerState = event.data.providerState
|
||||
assistant.error = event.data.error
|
||||
assistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -15,7 +15,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -40,6 +41,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -491,8 +494,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -26,7 +32,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
imports: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,14 +41,12 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -106,7 +110,16 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
return parse(code, {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -15,12 +15,6 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("source syntax", () => {
|
||||
test("rejects TypeScript-only syntax", async () => {
|
||||
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
@@ -107,7 +101,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -115,13 +109,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 10)",
|
||||
"(line 1, col 14)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 1, col 8)",
|
||||
"(line 2, col 5)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -134,7 +128,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,7 +147,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
|
||||
import { ProviderShared } from "@opencode/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import type { ID, RuntimeInfo } from "./model.js"
|
||||
import type { ID, Info } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: RuntimeInfo
|
||||
readonly model: Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: RuntimeInfo
|
||||
readonly model: Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: RuntimeInfo, pkg: string) {
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.canonical ?? model.providerID,
|
||||
@@ -182,8 +182,8 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
@@ -302,7 +302,7 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
@@ -399,7 +399,7 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AISDK } from "./aisdk.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
|
||||
import type { RuntimeInfo } from "./model.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
@@ -118,9 +117,9 @@ export interface Resolved {
|
||||
readonly cost: Info["cost"]
|
||||
/** Catalog token limits used by Core for context management. */
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means summary compaction. */
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Provider transport policy; omitted means HTTP. */
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
@@ -150,7 +149,7 @@ export const withVariant = (
|
||||
variant
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
@@ -160,11 +159,11 @@ export const withVariant = (
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
@@ -192,7 +191,7 @@ export const fromCatalogModel = (
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
@@ -249,7 +248,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
})
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
@@ -261,7 +260,7 @@ function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value |
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
@@ -271,7 +270,7 @@ function validateProviderVariables(
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: RuntimeInfo,
|
||||
model: Info,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
@@ -281,14 +280,14 @@ function prepareProviderSettings(
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
@@ -311,14 +310,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const unsupported = (model: RuntimeInfo) =>
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
@@ -360,11 +359,7 @@ export const layer = Layer.effect(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const selectedVariant = yield* withVariant(selected, variant)
|
||||
const runtimeInfo: RuntimeInfo = {
|
||||
...selectedVariant,
|
||||
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
|
||||
}
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
@@ -387,7 +382,7 @@ export const layer = Layer.effect(
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: provider?.settings?.transport,
|
||||
transport: runtimeInfo.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
@@ -411,7 +406,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: RuntimeInfo) {
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
|
||||
@@ -36,9 +36,6 @@ export type Ref = typeof Ref.Type
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
/** Effective provider and model settings used only while constructing a runtime model. */
|
||||
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
|
||||
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export { Event } from "@opencode/schema/model"
|
||||
@@ -191,10 +188,7 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
settings: Provider.mergeOverlay(
|
||||
Provider.modelSettings(provider?.settings),
|
||||
Provider.modelSettings(model.settings),
|
||||
),
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info
|
||||
|
||||
@@ -453,7 +453,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
@@ -139,18 +140,16 @@ export const AzurePlugin = define({
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
if (resourceName)
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
resourceName,
|
||||
...(typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
@@ -168,6 +167,10 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: item.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -239,9 +242,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
function responsesWebSocketCapable(provider: Provider.Info) {
|
||||
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = provider.settings
|
||||
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
|
||||
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = Provider.mergeOverlay(provider.settings, model.settings)
|
||||
if (settings?.useDeploymentBasedUrls === true) return false
|
||||
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
|
||||
if (typeof settings?.baseURL !== "string") return true
|
||||
|
||||
@@ -254,13 +254,10 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
const account = chatgpt?.metadata?.accountID
|
||||
if (!chatgpt) return
|
||||
const account = chatgpt.metadata?.accountID
|
||||
providers.update(item.provider.id, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
...(chatgpt ? { baseURL: codexBaseURL } : {}),
|
||||
})
|
||||
if (!chatgpt) return
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
originator: "opencode",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
@@ -273,6 +270,9 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(model.providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
@@ -132,13 +131,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(
|
||||
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
|
||||
? snapshot.config
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
@@ -95,13 +95,14 @@ export const XAIPlugin = define({
|
||||
editor.method.update(device(ctx.app))
|
||||
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
if (!providers.get(providerID)) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, {
|
||||
transport: models.provider.get(providerID)?.provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { ToolEditor } from "@opencode/plugin/effect/tool"
|
||||
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
@@ -20,19 +21,25 @@ const maxDepth = 6
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.tool.input.repair",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.gen(function* () {
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
let get: ToolEditor["get"] = () => undefined
|
||||
yield* ctx.tool.transform((draft) => {
|
||||
// The draft sees later tool transforms too; reload replaces this lookup.
|
||||
get = draft.get
|
||||
})
|
||||
yield* ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
|
||||
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
|
||||
if (event.tool === "execute") return
|
||||
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
|
||||
const tool = get(event.tool)
|
||||
if (!tool) return
|
||||
const schema = definition(tool).inputSchema
|
||||
if (schema.type !== "object") return
|
||||
event.input = repair(event.input, schema, schema, 0)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
|
||||
|
||||
@@ -134,16 +134,11 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function modelSettings(settings: Settings | undefined) {
|
||||
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
base: Readonly<Record<string, unknown>> | undefined,
|
||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||
|
||||
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.tap((message) =>
|
||||
SessionProviderContext.isCheckpoint(message)
|
||||
? SessionProviderContext.validate(message.providerContext)
|
||||
|
||||
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
|
||||
if (row === undefined) return undefined
|
||||
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||
return yield* new LifecycleConflict({ id })
|
||||
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
|
||||
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const base = { id, sessionID, time: { created: message.time.created }, delivery }
|
||||
if (message.type === "user")
|
||||
return User.make({
|
||||
|
||||
@@ -222,7 +222,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.error = undefined
|
||||
draft.finish = undefined
|
||||
draft.rawFinish = undefined
|
||||
draft.state = undefined
|
||||
draft.providerState = undefined
|
||||
draft.time.created = DateTime.makeUnsafe(event.data.started)
|
||||
draft.time.streamed = undefined
|
||||
draft.time.completed = undefined
|
||||
@@ -263,7 +263,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.time.completed = created
|
||||
draft.finish = event.data.finish
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.state = castDraft(event.data.providerState)
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
projectTerminalSnapshot(draft, event)
|
||||
@@ -274,7 +274,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.time.completed = created
|
||||
draft.finish = event.data.finish ?? "error"
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.state = castDraft(event.data.providerState)
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.error = castDraft(event.data.error)
|
||||
draft.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
return Effect.gen(function* () {
|
||||
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
|
||||
decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||
const updateMessage = (message: SessionMessage.Info) => {
|
||||
const encoded = encodeMessage(message)
|
||||
const { id, type, ...data } = encoded
|
||||
|
||||
@@ -107,9 +107,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const files = new Map<RelativePath, Snapshot.ID>()
|
||||
for (const row of rows) {
|
||||
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
|
||||
if (message.type !== "assistant" || !message.snapshot?.start) continue
|
||||
for (const file of message.snapshot.files ?? [])
|
||||
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
|
||||
|
||||
@@ -26,7 +26,6 @@ const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
|
||||
@@ -39,7 +39,6 @@ type Data = {
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Tool.Info & { readonly id: string }>>
|
||||
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
|
||||
}
|
||||
|
||||
@@ -221,7 +220,6 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: () => Effect.sync(() => Array.from(state.get().tools.values())),
|
||||
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
|
||||
Effect.sync(() => {
|
||||
const data = state.get()
|
||||
|
||||
@@ -27,15 +27,14 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Provider.Settings = {}): Model.RuntimeInfo => ({
|
||||
...Model.Info.make({
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make("test-provider"), Model.ID.make("catalog-model")),
|
||||
modelID: Model.ID.make("api-model"),
|
||||
package: Provider.aisdk(packageName),
|
||||
settings,
|
||||
limit: { context: 100, output: 20 },
|
||||
}),
|
||||
settings,
|
||||
})
|
||||
})
|
||||
|
||||
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
|
||||
@@ -511,7 +511,7 @@ describe("Provider and Model", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps settings scoped while resolving request overlay merges", () =>
|
||||
it.effect("resolves provider and model overlay merges", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -533,7 +533,6 @@ describe("Provider and Model", () => {
|
||||
})
|
||||
|
||||
const model = required(yield* models.get(providerID, modelID))
|
||||
expect((yield* providers.get(providerID))?.settings).toEqual({ provider: true, shared: "provider" })
|
||||
expect(model.settings).toEqual({ provider: true, shared: "model", model: true })
|
||||
expect(model.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
|
||||
expect(model.body).toEqual({ provider: true, shared: "model", model: true })
|
||||
|
||||
@@ -37,7 +37,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("inherits the provider compaction setting with model overrides and rejects unsupported routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
const providers = yield* Provider.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
@@ -64,7 +63,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(native.settings?.compaction).toEqual({ type: "native" })
|
||||
expect(local.settings?.compaction).toEqual({ type: "summary" })
|
||||
expect(defaultModel.settings?.compaction).toBeUndefined()
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.compaction).toEqual({ type: "native" })
|
||||
yield* ModelResolver.fromCatalogModel(native)
|
||||
yield* ModelResolver.fromCatalogModel(local)
|
||||
yield* ModelResolver.fromCatalogModel(defaultModel)
|
||||
@@ -75,9 +73,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the provider websocket policy out of model settings", () =>
|
||||
it.effect("inherits the provider websocket policy with model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -86,12 +83,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
settings: { transport: "http", timeout: 100, chunkTimeout: 200, shared: "provider" },
|
||||
models: {
|
||||
inherited: {
|
||||
settings: { transport: "websocket", timeout: 1, chunkTimeout: 2, model: true },
|
||||
},
|
||||
},
|
||||
settings: { transport: "http" },
|
||||
models: { inherited: {}, override: { settings: { transport: "websocket" } } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
@@ -99,9 +92,10 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
])
|
||||
const inherited = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* models.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* models.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect((yield* providers.get(Provider.ID.make("custom")))?.settings?.transport).toBe("http")
|
||||
expect(inherited.settings).toEqual({ shared: "provider", model: true })
|
||||
expect(inherited.settings?.transport).toBe("http")
|
||||
expect(override.settings?.transport).toBe("websocket")
|
||||
expect(untouched.settings?.transport).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
@@ -112,7 +106,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
|
||||
]) {
|
||||
it.live(`configured provider transport overrides ${builtin.id} defaults`, () =>
|
||||
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
@@ -128,8 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
editor.models.update(providerID, modelID, () => {})
|
||||
})
|
||||
yield* builtin.plugin.effect(host)
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("websocket")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("websocket")
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
@@ -138,16 +131,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
providers: {
|
||||
[builtin.id]: {
|
||||
settings: { transport: "http" },
|
||||
models: { override: { modelID: builtin.model } },
|
||||
models: { override: { modelID: builtin.model, settings: { transport: "websocket" } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* providers.get(providerID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBeUndefined()
|
||||
expect((yield* models.get(providerID, modelID))?.settings?.transport).toBe("http")
|
||||
expect((yield* models.get(providerID, Model.ID.make("override")))?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export const registerToolPlugin = <R>(
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -133,7 +133,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
list: () => Effect.die("unused tool.list"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
},
|
||||
vcs: overrides.vcs ?? {
|
||||
|
||||
@@ -73,7 +73,6 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
reload: () => Effect.die("unused tool.reload"),
|
||||
list: () => Effect.die("unused tool.list"),
|
||||
hook: (name, callback) => {
|
||||
if (name === "execute.after") {
|
||||
// Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API.
|
||||
|
||||
@@ -434,7 +434,7 @@ describe("AzurePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stores the Azure Responses WebSocket preference on the provider", () =>
|
||||
it.effect("marks only Azure v1 Responses deployments as WebSocket capable", () =>
|
||||
withEnv({ AZURE_RESOURCE_NAME: undefined, AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
@@ -472,8 +472,9 @@ describe("AzurePlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
expect((yield* catalog.get(Provider.ID.azure))?.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.responses, models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const responses = required(yield* service.get(Provider.ID.azure, models.responses))
|
||||
expect(responses.settings?.transport).toBe("websocket")
|
||||
for (const modelID of [models.chat, models.preview, models.deploymentURL, models.gateway, models.nonAzure]) {
|
||||
const model = required(yield* service.get(Provider.ID.azure, modelID))
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
}
|
||||
|
||||
@@ -205,8 +205,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(model.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(model.enabled).toBe(true)
|
||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||
expect(provider.settings?.transport).toBe("websocket")
|
||||
expect(model.settings?.transport).toBeUndefined()
|
||||
expect(model.settings?.transport).toBe("websocket")
|
||||
expect(direct.headers).not.toHaveProperty("originator")
|
||||
expect(direct.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(provider.headers).not.toHaveProperty("x-codex-beta-features")
|
||||
|
||||
@@ -550,15 +550,14 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("refreshes hosted search with Console config, retains it on failure, and skips unchanged snapshots", () =>
|
||||
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { advertised: false, failing: false, requests: 0 }
|
||||
const state = { advertised: false, requests: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
state.requests++
|
||||
if (state.failing) return new Response("Unavailable", { status: 502 })
|
||||
return Response.json({
|
||||
providers: {},
|
||||
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
|
||||
@@ -608,18 +607,10 @@ describe("OpencodePlugin", () => {
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.failing = true
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
|
||||
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
|
||||
|
||||
state.failing = false
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(state.requests).toBe(5)
|
||||
expect(state.requests).toBe(4)
|
||||
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
|
||||
expect(yield* websearch.providers()).toEqual([])
|
||||
expect(yield* websearch.default()).toBeUndefined()
|
||||
|
||||
@@ -83,10 +83,8 @@ describe("XAIPlugin", () => {
|
||||
|
||||
yield* addPlugin()
|
||||
|
||||
const provider = yield* providers.get(providerID)
|
||||
const model = yield* models.get(providerID, Model.ID.make("grok-4.6"))
|
||||
expect(provider?.settings?.transport).toBe("websocket")
|
||||
expect(model?.settings?.transport).toBeUndefined()
|
||||
expect(model?.settings?.transport).toBe("websocket")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -22,19 +22,30 @@ function run(input: unknown, inputSchema: JsonSchema.JsonSchema) {
|
||||
"execute.before": event,
|
||||
"execute.after": { ...event, status: "error", error: new Tool.Error({ message: "unused" }) },
|
||||
}
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
const base = host()
|
||||
return ToolInputRepairPlugin.Plugin.effect(
|
||||
host({
|
||||
tool: {
|
||||
...base.tool,
|
||||
list: () => Effect.succeed([tool]),
|
||||
transform: (callback) =>
|
||||
Effect.sync(() => {
|
||||
const tool = {
|
||||
id: "test",
|
||||
name: "test",
|
||||
description: "Test repair",
|
||||
input: inputSchema,
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
callback({
|
||||
list: () => [tool],
|
||||
get: (id) => (id === tool.id ? tool : undefined),
|
||||
add: () => {},
|
||||
namespace: () => {},
|
||||
update: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
hook: (name, callback) => callback(events[name]).pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -42,19 +42,4 @@ describe("Provider", () => {
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits shared and loose settings without provider-only policies", () => {
|
||||
expect(
|
||||
Provider.modelSettings({
|
||||
timeout: 60_000,
|
||||
chunkTimeout: 30_000,
|
||||
transport: "websocket",
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
compaction: { type: "native" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -690,7 +690,7 @@ describe("SessionProjector", () => {
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
rawFinish: "stop_sequence",
|
||||
state: { response: "ended" },
|
||||
providerState: { response: "ended" },
|
||||
cost: Money.USD.make(1),
|
||||
tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
|
||||
snapshot: { end: "snap_ended", files: ["src/ended.ts"] },
|
||||
@@ -700,7 +700,7 @@ describe("SessionProjector", () => {
|
||||
type: "assistant",
|
||||
finish: "content-filter",
|
||||
rawFinish: "blocked",
|
||||
state: { response: "failed" },
|
||||
providerState: { response: "failed" },
|
||||
error: { type: "provider.invalid-request", message: "Failed" },
|
||||
snapshot: { end: "snap_failed", files: ["src/failed.ts"] },
|
||||
time: { completed: created },
|
||||
|
||||
@@ -4953,7 +4953,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
rawFinish: "end_turn",
|
||||
state: { responseId: "response-1", serviceTier: "priority" },
|
||||
providerState: { responseId: "response-1", serviceTier: "priority" },
|
||||
content: [Expected.text("Complete")],
|
||||
},
|
||||
])
|
||||
@@ -4984,7 +4984,7 @@ describe("SessionRunnerLLM", () => {
|
||||
type: "assistant",
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
state: {
|
||||
providerState: {
|
||||
responseId: "response-blocked",
|
||||
refusal: { category: "safety", explanation: "Prompt blocked" },
|
||||
},
|
||||
|
||||
@@ -59,7 +59,6 @@ describe("SkillInstructions", () => {
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
||||
"<available_skills>",
|
||||
" <skill>",
|
||||
" <id>effect</id>",
|
||||
|
||||
@@ -855,21 +855,6 @@ describe("Tool", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
it.effect("lists registered tools by effective name", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* transform(service, { echo: make() }, { codemode: false })
|
||||
yield* transform(service, { count: { ...constant("1"), name: "count" } }, { namespace: "acme" })
|
||||
|
||||
expect((yield* service.list()).map((tool) => [tool.id, tool.name])).toEqual([
|
||||
["echo", "echo"],
|
||||
["acme_count", "count"],
|
||||
])
|
||||
|
||||
yield* service.transform((editor) => editor.remove("echo"))
|
||||
expect((yield* service.list()).map((tool) => tool.id)).toEqual(["acme_count"])
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "string", content: "hooked", text: "hooked" },
|
||||
{ name: "empty string", content: "", text: "" },
|
||||
|
||||
@@ -91,11 +91,6 @@ for (const channel of channels) {
|
||||
"js-yaml/dist/js-yaml.min.js",
|
||||
"js-yaml/dist/js-yaml.mjs.map",
|
||||
"js-yaml/bin/js-yaml.js",
|
||||
"unrelated/dist/index.js.map",
|
||||
"unrelated/dist/index.cjs.map",
|
||||
"unrelated/dist/index.d.ts",
|
||||
"unrelated/dist/index.d.cts",
|
||||
"unrelated/dist/index.d.ts.map",
|
||||
]) {
|
||||
expect(filter(path.join(import.meta.dirname, prefix, file), statSync(import.meta.filename))).toBe(false)
|
||||
}
|
||||
@@ -117,10 +112,7 @@ for (const channel of channels) {
|
||||
"js-yaml/lib/loader.js",
|
||||
"js-yaml/dist/js-yaml.mjs",
|
||||
"debug/src/index.js",
|
||||
"unrelated/dist/index.js",
|
||||
"unrelated/dist/index.cjs",
|
||||
"unrelated/dist/data.json",
|
||||
"unrelated/src/index.ts",
|
||||
"unrelated/dist/index.js.map",
|
||||
...["@zip.js/zip.js", "electron-updater", "builder-util-runtime", "ajv", "ajv-formats", "js-yaml"].flatMap(
|
||||
(name) => [`${name}/package.json`, `${name}/LICENSE`],
|
||||
),
|
||||
|
||||
@@ -77,14 +77,11 @@ const getBase = (appId: string): Configuration => ({
|
||||
// Log export imports Zip.js as ESM. Keep index.js and lib, including its inline worker.
|
||||
"!**/node_modules/@zip.js/zip.js/dist{,/**/*}",
|
||||
"!**/node_modules/@zip.js/zip.js/{index.cjs,index.min.js,index-fflate.js,deno.json,eslint.config.mjs}",
|
||||
// Nothing executes type declarations or source maps, and every entry costs startup time: the
|
||||
// main process parses the whole asar header before it runs any JavaScript.
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}",
|
||||
"!**/node_modules/**/*.d.{ts,cts,mts}.map",
|
||||
"!**/node_modules/**/*.{js,cjs,mjs}.map",
|
||||
// These packages execute compiled JavaScript, not their sources.
|
||||
// These packages execute compiled JavaScript, not their sources or source maps.
|
||||
"!**/node_modules/{electron-updater,builder-util-runtime,lazy-val}/out/**/*.js.map",
|
||||
"!**/node_modules/ajv/lib{,/**/*}",
|
||||
"!**/node_modules/ajv-formats/src{,/**/*}",
|
||||
"!**/node_modules/{ajv,ajv-formats}/dist/**/*.js.map",
|
||||
// Keep js-yaml's CommonJS sources and dist/js-yaml.mjs ESM entry, not browser bundles or its CLI.
|
||||
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
|
||||
"!**/node_modules/js-yaml/bin{,/**/*}",
|
||||
|
||||
@@ -175,12 +175,8 @@ const summaries = Object.fromEntries(
|
||||
const phaseOrder = [
|
||||
["electron native init", "processCreated", "nodeStart"],
|
||||
["node bootstrap", "nodeStart", "nodeBootstrapped"],
|
||||
["electron js init → entry", "nodeBootstrapped", "entryStart"],
|
||||
["entry → chromium ready", "entryStart", "electronReady"],
|
||||
["ready → window shown", "electronReady", "windowVisible"],
|
||||
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
|
||||
["layers → first log line", "bundleEvaluated", "appStarting"],
|
||||
["layers → renderer process", "appStarting", "rendererProcess"],
|
||||
["electron js init + main bundle", "nodeBootstrapped", "appStarting"],
|
||||
["main layers → window created", "appStarting", "rendererProcess"],
|
||||
["renderer boot → first paint", "rendererProcess", "firstPaint"],
|
||||
["first paint → shell", "firstPaint", "shellVisible"],
|
||||
["shell → idle", "shellVisible", "rendererIdle"],
|
||||
@@ -372,9 +368,6 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
|
||||
processCreated: boot && Math.round(boot.created - spawnAt),
|
||||
nodeStart: boot && Math.round(boot.origin + boot.nodeStart - spawnAt),
|
||||
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
|
||||
entryStart: main.marks.entry && main.marks.entry - spawnAt,
|
||||
electronReady: main.marks.ready && main.marks.ready - spawnAt,
|
||||
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
|
||||
appStarting: main.appStarting && main.appStarting - spawnAt,
|
||||
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
|
||||
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
|
||||
@@ -532,8 +525,6 @@ function mainLog() {
|
||||
const dir = dirs.map((d) => join(paths.logs, d)).find((d) => existsSync(join(d, "main.log")))
|
||||
const timeline: [number, string, string][] = []
|
||||
let windowShownAt: number | undefined
|
||||
// Epoch marks the entry module recorded before any logger existed, reported with "app starting".
|
||||
const marks: Record<string, number> = {}
|
||||
for (const name of dir ? readdirSync(dir).filter((f) => f.endsWith(".log")) : []) {
|
||||
const text = readFileSync(join(dir!, name), "utf8")
|
||||
// electron-log wraps long objects onto continuation lines; read them as part of the entry.
|
||||
@@ -545,8 +536,6 @@ function mainLog() {
|
||||
// A window shown before the logger existed reports when it was shown; the line itself is later.
|
||||
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
|
||||
if (shown) windowShownAt = Number(shown)
|
||||
if (/app starting/.test(message))
|
||||
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
|
||||
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
|
||||
}
|
||||
}
|
||||
@@ -559,8 +548,7 @@ function mainLog() {
|
||||
versionDone: at(/v2 CLI command completed/),
|
||||
serviceStarting: at(/v2 CLI background service starting/),
|
||||
serviceReady: at(/background service ready/),
|
||||
windowVisible: windowShownAt ?? marks.window ?? at(/main window visible/),
|
||||
marks,
|
||||
windowVisible: windowShownAt ?? at(/main window visible/),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,15 @@ import { app } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Ipc } from "./ipc"
|
||||
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
|
||||
import { installContextMenu } from "./lifecycle/environment"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { BackgroundService } from "./service/background-service"
|
||||
import { DesktopCli } from "./service/desktop-cli"
|
||||
import { UpdaterLive } from "./updater/live"
|
||||
import { marks } from "./lifecycle/marks"
|
||||
|
||||
// Everything above has been loaded and evaluated by now; the layers start below.
|
||||
marks.bundle = Date.now()
|
||||
|
||||
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
|
||||
const lifecycle = yield* ApplicationLifecycle.Service
|
||||
const ipc = yield* Ipc.registerIpcHandlers
|
||||
if (lifecycle.restoreWindows().length) ipc.installMenu()
|
||||
yield* installContextMenu
|
||||
yield* Effect.callback<void>((resume) => {
|
||||
const quit = () => resume(Effect.void)
|
||||
app.once("will-quit", quit)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Imported first so its evaluation stamps the moment Electron handed control to this module.
|
||||
import { marks } from "./lifecycle/marks"
|
||||
import { app } from "electron"
|
||||
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
|
||||
import { createEarlyWindow } from "./windows/early"
|
||||
@@ -15,9 +13,7 @@ if (acquireApplicationLock()) {
|
||||
// Window first, then the bundle: starting the import before ready delays ready itself, because the
|
||||
// module graph evaluates on the same thread Chromium needs to finish initialising.
|
||||
void app.whenReady().then(() => {
|
||||
marks.ready = Date.now()
|
||||
createEarlyWindow()
|
||||
marks.window = Date.now()
|
||||
return import("./desktop")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,12 +65,16 @@ export const registerIpcHandlers = Effect.gen(function* () {
|
||||
if (input.type !== "keyDown" || input.key !== "Escape") return
|
||||
win.webContents.send(DragCancelEvent)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
const connect = () => {
|
||||
if (win.isDestroyed() || win.webContents.isDestroyed()) return
|
||||
const channel = new MessageChannelMain()
|
||||
handoff.bind(win.webContents, channel.port1)
|
||||
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
|
||||
})
|
||||
}
|
||||
win.webContents.on("did-finish-load", connect)
|
||||
// The first window starts loading before this layer exists; a renderer that has already finished
|
||||
// loading is waiting for its port right now.
|
||||
if (!win.webContents.isLoading() && win.webContents.getURL()) connect()
|
||||
}
|
||||
yield* Effect.sync(() => {
|
||||
app.on("browser-window-created", wire)
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import http from "node:http"
|
||||
import { getCACertificates, setDefaultCACertificates } from "node:tls"
|
||||
import { app } from "electron"
|
||||
import contextMenu from "electron-context-menu"
|
||||
import { Effect, Path } from "effect"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { getUserShell, loadShellEnv } from "../service/shell-env"
|
||||
import { registerRendererProtocol, setDockIcon } from "../windows"
|
||||
|
||||
// electron-context-menu attaches to every existing and future window, so it can load once the first
|
||||
// window is up instead of holding up startup with its dependency tree.
|
||||
export const installContextMenu = Effect.gen(function* () {
|
||||
const { default: contextMenu } = yield* Effect.promise(() => import("electron-context-menu"))
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
})
|
||||
|
||||
export const prepareApplicationEnvironment = Effect.gen(function* () {
|
||||
yield* loadSystemCertificates
|
||||
yield* loadProxyEnvironment
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
|
||||
// layer reports them with "app starting" so the startup benchmark can split the time before the
|
||||
// first log line into Electron's own initialisation, our entry, and the main bundle.
|
||||
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
|
||||
@@ -5,7 +5,6 @@ import { app, crashReporter, netLog, shell } from "electron"
|
||||
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References, Stream } from "effect"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "../constants"
|
||||
import { marks } from "../lifecycle/marks"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
@@ -35,7 +34,6 @@ const serviceLayer = Layer.effect(
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
|
||||
marks,
|
||||
})
|
||||
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
|
||||
return Service.of({
|
||||
|
||||
@@ -1,57 +1,15 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { BackgroundServiceState } from "./background-service-state"
|
||||
|
||||
test("new consumers receive the latest reconnected service", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
const replacement = { url: "http://127.0.0.1:4200", password: "second" }
|
||||
const service = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.succeed(initial),
|
||||
reconnect: Effect.succeed(replacement),
|
||||
})
|
||||
expect(yield* state.connection).toEqual(initial)
|
||||
expect(yield* state.reconnect).toEqual(replacement)
|
||||
return yield* state.connection
|
||||
}),
|
||||
),
|
||||
BackgroundServiceState.make({ initial: Effect.succeed(initial), reconnect: Effect.succeed(replacement) }),
|
||||
)
|
||||
expect(service).toEqual(replacement)
|
||||
})
|
||||
|
||||
test("the state is ready before the initial connect finishes and consumers wait for it", async () => {
|
||||
const initial = { url: "http://127.0.0.1:4100", password: "first" }
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const gate = yield* Deferred.make<void>()
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Deferred.await(gate).pipe(Effect.as(initial)),
|
||||
reconnect: Effect.succeed(initial),
|
||||
})
|
||||
const consumer = yield* Effect.forkChild(state.connection)
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(consumer.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(consumer)).toEqual(initial)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("a failed initial connect fails consumers instead of hanging them", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* BackgroundServiceState.make({
|
||||
initial: Effect.fail(new Error("service unavailable")),
|
||||
reconnect: Effect.succeed({ url: "http://127.0.0.1:4100", password: "later" }),
|
||||
})
|
||||
return yield* state.connection.pipe(Effect.exit)
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(result._tag).toBe("Failure")
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(initial)
|
||||
expect(await Effect.runPromise(service.reconnect)).toEqual(replacement)
|
||||
expect(await Effect.runPromise(service.connection)).toEqual(replacement)
|
||||
})
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
export * as BackgroundServiceState from "./background-service-state"
|
||||
|
||||
import { Deferred, Effect, Exit, Ref } from "effect"
|
||||
import { Effect, Exit, Ref } from "effect"
|
||||
import type { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
// The initial connect runs in the background: windows are created as soon as the main process
|
||||
// is up, and the renderer loads its bundle while the CLI service is being resolved. Consumers
|
||||
// await the first outcome through `connection`, so nothing observes the service before it exists.
|
||||
export const make = Effect.fn("BackgroundServiceState.make")(function* (options: {
|
||||
readonly initial: Effect.Effect<SidecarCredentials.Data, unknown>
|
||||
readonly reconnect: Effect.Effect<SidecarCredentials.Data>
|
||||
}) {
|
||||
// Every Exit is an Effect, so the latest resolution replays directly for each consumer.
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown> | undefined>(undefined)
|
||||
const first = yield* Deferred.make<void>()
|
||||
yield* options.initial.pipe(
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Ref.set(current, exit)),
|
||||
Effect.ensuring(Deferred.succeed(first, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
const current = yield* Ref.make<Exit.Exit<SidecarCredentials.Data, unknown>>(yield* options.initial.pipe(Effect.exit))
|
||||
return {
|
||||
connection: Deferred.await(first).pipe(
|
||||
Effect.flatMap(() => Ref.get(current)),
|
||||
Effect.flatMap((exit) => exit ?? Exit.die(new Error("background service connect did not resolve"))),
|
||||
Effect.orDie,
|
||||
),
|
||||
connection: Ref.get(current).pipe(Effect.flatten, Effect.orDie),
|
||||
reconnect: options.reconnect.pipe(Effect.tap((next) => Ref.set(current, Exit.succeed(next)))),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,29 +17,17 @@ describe("draft store", () => {
|
||||
expect(database.db.all(sql`SELECT key FROM document`)).toEqual([])
|
||||
})
|
||||
|
||||
test("stores blobs by content hash and collects unreferenced ones shortly after open", async () => {
|
||||
test("stores blobs by content hash and collects unreferenced ones on open", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
let clock = 0
|
||||
const first = createDraftStore(database.db, { delay: 1_000, now: () => clock })
|
||||
const first = createDraftStore(database.db, { delay: 1_000 })
|
||||
const used = first.putBlob(new Uint8Array([1, 2, 3]))
|
||||
const unused = first.putBlob(new Uint8Array([4, 5, 6]))
|
||||
expect(first.putBlob(new Uint8Array([1, 2, 3]))).toBe(used)
|
||||
first.set("doc", JSON.stringify({ parts: [{ blob: { id: used } }] }))
|
||||
first.flush()
|
||||
first.close()
|
||||
// The startup collection runs off the window's critical path and keeps the usual grace, so a
|
||||
// blob from a session that ended within the grace period is still there...
|
||||
const second = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(second.getBlob(unused)).toEqual(new Uint8Array([4, 5, 6]))
|
||||
second.close()
|
||||
// ...and gone once the grace has passed.
|
||||
clock = blobGrace + 1
|
||||
const third = createDraftStore(database.db, { delay: 1_000, now: () => clock, collectDelay: 0 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(third.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(third.getBlob(unused)).toBeNull()
|
||||
third.close()
|
||||
const second = createDraftStore(database.db, { delay: 1_000 })
|
||||
expect(second.getBlob(used)).toEqual(new Uint8Array([1, 2, 3]))
|
||||
expect(second.getBlob(unused)).toBeNull()
|
||||
})
|
||||
|
||||
test("collects a retired chunk only once it is unreferenced and past the grace period", () => {
|
||||
|
||||
@@ -27,14 +27,11 @@ const referenced = (value: unknown) => sql`
|
||||
|
||||
export function createDraftStore(
|
||||
db: Database,
|
||||
input: { delay?: number; collectDelay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
input: { delay?: number; onError?: (error: unknown) => void; now?: () => number } = {},
|
||||
) {
|
||||
const now = input.now ?? Date.now
|
||||
// Orphans left by an earlier session are collected once the window is up rather than before it:
|
||||
// the scan walks every stored document, and the usual grace keeps anything a renderer has
|
||||
// uploaded in the meantime.
|
||||
const startup = setTimeout(() => collectBlobs(db, now() - blobGrace), input.collectDelay ?? 10_000)
|
||||
startup.unref()
|
||||
// Nothing outside this process can hold a blob id at startup, so no grace applies.
|
||||
collectBlobs(db, Infinity)
|
||||
let collected = now()
|
||||
let orphans = false
|
||||
const byKey = eq(document.key, sql.placeholder("key"))
|
||||
@@ -107,10 +104,7 @@ export function createDraftStore(
|
||||
return data ? (data as Uint8Array<ArrayBuffer>) : null
|
||||
},
|
||||
flush: writer.flush,
|
||||
close() {
|
||||
clearTimeout(startup)
|
||||
writer.close()
|
||||
},
|
||||
close: writer.close,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +49,8 @@ export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
|
||||
}
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
// The renderer reports its theme background on every boot; electron-store rewrites and fsyncs the
|
||||
// settings file on each set, so only persist a change.
|
||||
if (getBackgroundColor() !== color) getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
backgroundColor = color
|
||||
getStore().set(BACKGROUND_COLOR_KEY, color)
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.setBackgroundColor(color)
|
||||
if (process.platform === "darwin") win.invalidateShadow()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app, BrowserWindow, screen } from "electron"
|
||||
import { app, BrowserWindow, screen, shell } from "electron"
|
||||
import { windowIDArgument } from "../../shared/window-bootstrap"
|
||||
import { resolveExternalURL } from "../files/external-url"
|
||||
import { WINDOW_IDS_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
|
||||
import { handleRendererProtocol, loadWindow } from "./scheme"
|
||||
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
|
||||
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
|
||||
|
||||
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
|
||||
@@ -19,9 +22,11 @@ const displays = {
|
||||
}
|
||||
|
||||
// Creates and shows the first restored window the moment Electron is ready, before the rest of the
|
||||
// main process has loaded. The frame options mirror windowAppearance(); the persisted background
|
||||
// colour stands in for the theme until the renderer applies it, so the window is on screen while the
|
||||
// bundle, the layers and the renderer boot. restoreWindows() adopts it through takeEarlyWindow().
|
||||
// main process has loaded, and starts loading the renderer into it so the renderer process boots in
|
||||
// parallel with the main bundle and its layers. The frame options mirror windowAppearance(); the
|
||||
// persisted background colour stands in for the theme until the renderer applies it. The renderer's
|
||||
// IPC connection waits for a port the IPC layer hands over once it exists. restoreWindows() adopts the
|
||||
// window through takeEarlyWindow().
|
||||
export function createEarlyWindow() {
|
||||
const ids = getStore().get(WINDOW_IDS_KEY)
|
||||
const id = Array.isArray(ids) && typeof ids[0] === "string" ? ids[0] : randomUUID()
|
||||
@@ -50,6 +55,14 @@ export function createEarlyWindow() {
|
||||
},
|
||||
})
|
||||
manageWindowState(win, file, state, displays)
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => {
|
||||
const target = resolveExternalURL(url)
|
||||
if (target) void shell.openExternal(target)
|
||||
})
|
||||
wireRendererHeaders(win)
|
||||
handleRendererProtocol(path.join(root, "../renderer"))
|
||||
loadWindow(win, "index.html")
|
||||
// Closing the only window before the rest of the app has adopted it is a quit.
|
||||
win.once("closed", () => {
|
||||
if (pending?.win !== win) return
|
||||
|
||||
@@ -119,14 +119,17 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
|
||||
},
|
||||
})
|
||||
|
||||
allowRendererPermissions(win)
|
||||
// The early window arrives with its state, security wiring and renderer load already done.
|
||||
if (!early) {
|
||||
allowRendererPermissions(win)
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
manageWindowState(win, stateFile, state, displays)
|
||||
}
|
||||
wireWindowRecovery(win, id, () => relaunchHandler())
|
||||
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
|
||||
wireRendererHeaders(win)
|
||||
if (!early) manageWindowState(win, stateFile, state, displays)
|
||||
register(win, id)
|
||||
wireFullscreen(win)
|
||||
loadWindow(win, "index.html")
|
||||
if (!early) loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
let contentReady = false
|
||||
let appliedTheme = false
|
||||
|
||||
@@ -1,78 +1,18 @@
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Effect, Path } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { scoped } from "../native/logging"
|
||||
import { DesktopPaths } from "../paths"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
import { rendererHost, rendererProtocol } from "./scheme"
|
||||
import { handleRendererProtocol, setRendererProtocolReporter } from "./scheme"
|
||||
|
||||
export { isRendererUrl, loadWindow } from "./scheme"
|
||||
|
||||
// The entry module usually installs the handler before this runs; either way, route its diagnostics
|
||||
// through the application log from here on.
|
||||
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
|
||||
const path = yield* Path.Path
|
||||
const paths = yield* DesktopPaths.resolve
|
||||
const runFork = Effect.runForkWith(yield* Effect.context<never>())
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(paths.rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
runFork(
|
||||
scoped(
|
||||
"protocol",
|
||||
Effect.logError("fetch failed", {
|
||||
url: request.url,
|
||||
file,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
setRendererProtocolReporter({
|
||||
warn: (message, data) => runFork(scoped("protocol", Effect.logWarning(message, data))),
|
||||
error: (message, data) => runFork(scoped("protocol", Effect.logError(message, data))),
|
||||
})
|
||||
handleRendererProtocol(paths.rendererRoot)
|
||||
})
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import { protocol } from "electron"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { net, protocol } from "electron"
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
|
||||
|
||||
// The renderer scheme, its file handler and the window loader, kept free of Effect and logging so
|
||||
// the entry module can register the scheme before ready and load the first window before the rest
|
||||
// of the main process exists. Diagnostics go through a reporter the logging layer installs later.
|
||||
|
||||
export const rendererProtocol = "oc"
|
||||
export const rendererHost = "renderer"
|
||||
|
||||
type Reporter = {
|
||||
warn: (message: string, data: Record<string, unknown>) => void
|
||||
error: (message: string, data: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
let report: Reporter = {
|
||||
warn: (message, data) => console.warn(message, data),
|
||||
error: (message, data) => console.error(message, data),
|
||||
}
|
||||
|
||||
export function setRendererProtocolReporter(reporter: Reporter) {
|
||||
report = reporter
|
||||
}
|
||||
|
||||
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
|
||||
// before it loads anything else.
|
||||
export function registerRendererScheme() {
|
||||
@@ -14,9 +36,61 @@ export function registerRendererScheme() {
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
stream: true,
|
||||
// Let Chromium keep V8 bytecode for the renderer bundle between launches.
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
export function handleRendererProtocol(rendererRoot: string) {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
protocol.handle(rendererProtocol, async (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
report.warn("rejected host", { url: request.url })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = path.relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
report.warn("rejected path", { url: request.url, file })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
try {
|
||||
const range = request.headers.get("range")
|
||||
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
|
||||
if (response.status >= 400) {
|
||||
report.error("fetch failed", { url: request.url, file, status: response.status, statusText: response.statusText })
|
||||
}
|
||||
return addDocumentPolicy(response, file)
|
||||
} catch (error) {
|
||||
report.error("fetch error", { url: request.url, file, error })
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
void win.loadURL(new URL(html, devUrl).toString())
|
||||
return
|
||||
}
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
|
||||
export function isRendererUrl(value?: string, html = false) {
|
||||
if (!value || !URL.canParse(value)) return false
|
||||
const url = new URL(value)
|
||||
if (html && !url.pathname.endsWith(".html")) return false
|
||||
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (!devUrl || !URL.canParse(devUrl)) return false
|
||||
return url.origin === new URL(devUrl).origin
|
||||
}
|
||||
|
||||
function addDocumentPolicy(response: Response, file: string) {
|
||||
if (!file.toLowerCase().endsWith(".html")) return response
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BrowserWindow } from "electron"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
|
||||
import { isRendererUrl } from "./protocol"
|
||||
import { isRendererUrl } from "./scheme"
|
||||
|
||||
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode/app/wsl/types"
|
||||
import { Effect, FileSystem, Path } from "effect"
|
||||
import { nativeT } from "../native/translations"
|
||||
@@ -116,14 +117,7 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
async function runInteractiveCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: RunWslOptions = {},
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
// The native addon is only needed for interactive installs; loading it here keeps it out of startup.
|
||||
const pty = await import("@lydell/node-pty")
|
||||
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
|
||||
return new Promise<WslCommandResult>((resolve, reject) => {
|
||||
const child = pty.spawn(command, args, {
|
||||
name: "xterm-color",
|
||||
|
||||
@@ -159,14 +159,8 @@ export const Operations = [
|
||||
),
|
||||
operation(
|
||||
"tabs.open",
|
||||
"Open a browser tab, show it in the Review pane, and select it. Defaults to about:blank. Website traffic uses the connected server's network; localhost reaches that server.",
|
||||
{
|
||||
url: optional(short),
|
||||
focus: optional(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Default true: open the Review pane and select the new tab so the user sees it. Pass false only when the user asked for the tab to stay in the background.",
|
||||
}),
|
||||
},
|
||||
"Open a browser tab. Defaults to about:blank and focused. Website traffic uses the connected server's network; localhost reaches that server.",
|
||||
{ url: optional(short), focus: optional(Schema.Boolean) },
|
||||
Tab,
|
||||
),
|
||||
operation(
|
||||
|
||||
@@ -54,7 +54,5 @@ export interface ToolFailures extends Record<keyof ToolHooks, unknown> {
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
/** Currently registered tools, after every transform, keyed by effective name. */
|
||||
readonly list: () => Effect.Effect<readonly (Tool.Info & { readonly id: string })[]>
|
||||
readonly hook: Hooks<ToolHooks, ToolFailures>
|
||||
}
|
||||
|
||||
@@ -462,10 +462,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
},
|
||||
tool: {
|
||||
reload: () => run(host.tool.reload()),
|
||||
list: () =>
|
||||
run(host.tool.list()).then((tools) =>
|
||||
tools.map((tool) => ({ ...tool, execute: promiseExecutor(tool.execute) })),
|
||||
),
|
||||
transform: (callback) =>
|
||||
register(
|
||||
host.tool.transform((editor) =>
|
||||
|
||||
@@ -65,7 +65,5 @@ interface ToolHooks {
|
||||
export interface ToolDomain {
|
||||
readonly transform: Transform<ToolEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
/** Currently registered tools, after every transform, keyed by effective name. */
|
||||
readonly list: () => Promise<readonly (Info & { readonly id: string })[]>
|
||||
readonly hook: Hooks<ToolHooks>
|
||||
}
|
||||
|
||||
@@ -13251,27 +13251,6 @@
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13291,7 +13270,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13314,7 +13293,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15529,7 +15508,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15635,20 +15614,6 @@
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Model.Variant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15656,7 +15621,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
|
||||
@@ -340,17 +340,6 @@ export namespace Frontend {
|
||||
export const Color = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number, Schema.Number])
|
||||
export type Color = Schema.Schema.Type<typeof Color>
|
||||
|
||||
export const CapturedImage = Schema.Struct({
|
||||
x: Schema.Number,
|
||||
y: Schema.Number,
|
||||
width: Schema.Number,
|
||||
height: Schema.Number,
|
||||
pixelWidth: Schema.Number,
|
||||
pixelHeight: Schema.Number,
|
||||
rgba: Schema.String.check(Schema.isBase64()),
|
||||
})
|
||||
export interface CapturedImage extends Schema.Schema.Type<typeof CapturedImage> {}
|
||||
|
||||
export const CapturedFrame = Schema.Struct({
|
||||
cols: Schema.Number,
|
||||
rows: Schema.Number,
|
||||
@@ -368,7 +357,6 @@ export namespace Frontend {
|
||||
),
|
||||
}),
|
||||
),
|
||||
images: Schema.optionalKey(Schema.Array(CapturedImage)),
|
||||
})
|
||||
export interface CapturedFrame extends Schema.Schema.Type<typeof CapturedFrame> {}
|
||||
|
||||
|
||||
@@ -17,14 +17,6 @@ export const Settings = Schema.StructWithRest(
|
||||
).annotate({ identifier: "Config.Provider.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export const ModelSettings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.UndefinedOr(Schema.Json))],
|
||||
).annotate({ identifier: "Config.Model.Settings" })
|
||||
export type ModelSettings = typeof ModelSettings.Type
|
||||
|
||||
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
|
||||
|
||||
export const Overlays = {
|
||||
@@ -33,12 +25,6 @@ export const Overlays = {
|
||||
body: JsonRecord.pipe(optional),
|
||||
}
|
||||
|
||||
const ModelOverlays = {
|
||||
settings: ModelSettings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: JsonRecord.pipe(optional),
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
|
||||
headers: Overlays.headers,
|
||||
body: Overlays.body,
|
||||
@@ -71,11 +57,11 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
name: Schema.String.pipe(optional),
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
...ModelOverlays,
|
||||
...Overlays,
|
||||
capabilities: Capabilities.pipe(optional),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...ModelOverlays,
|
||||
...Overlays,
|
||||
}).pipe(Schema.Array, optional),
|
||||
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
|
||||
disabled: Schema.Boolean.pipe(optional),
|
||||
|
||||
@@ -56,21 +56,6 @@ export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tok
|
||||
})
|
||||
export type MaxTokensField = typeof MaxTokensField.Type
|
||||
|
||||
export const Settings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
}),
|
||||
// Provider packages may define arbitrary model-level options beyond OpenCode's shared compaction policy.
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
).annotate({ identifier: "Model.Settings" })
|
||||
export type Settings = typeof Settings.Type
|
||||
|
||||
export const Overlays = {
|
||||
settings: Settings.pipe(optional),
|
||||
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
|
||||
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
|
||||
}
|
||||
|
||||
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
||||
export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
@@ -112,7 +97,7 @@ export const Cost = Schema.Struct({
|
||||
export interface Variant extends Schema.Schema.Type<typeof Variant> {}
|
||||
export const Variant = Schema.Struct({
|
||||
id: VariantID,
|
||||
...Overlays,
|
||||
...Provider.Overlays,
|
||||
}).annotate({ identifier: "Model.Variant" })
|
||||
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
@@ -125,7 +110,7 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Provider.Package.pipe(optional),
|
||||
...Overlays,
|
||||
...Provider.Overlays,
|
||||
capabilities: Capabilities,
|
||||
variants: Schema.Array(Variant),
|
||||
time: Schema.Struct({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionMessage from "./session-message.js"
|
||||
|
||||
import { Predicate, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { SessionProviderContext } from "./session-provider-context.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { Content } from "./tool.js"
|
||||
@@ -222,7 +222,7 @@ export const Assistant = Schema.Struct({
|
||||
}).pipe(optional),
|
||||
finish: FinishReason.pipe(optional),
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
state: ProviderState.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
error: SessionError.Error.pipe(optional),
|
||||
@@ -318,11 +318,3 @@ export type Info =
|
||||
| Compaction
|
||||
| Idle
|
||||
export type Type = Info["type"]
|
||||
|
||||
/** Reads assistant rows stored before `providerState` was renamed to `state`. */
|
||||
export function persisted(input: unknown) {
|
||||
if (!Predicate.isObject(input) || input.type !== "assistant" || "state" in input || !("providerState" in input))
|
||||
return input
|
||||
const { providerState, ...rest } = input
|
||||
return { ...rest, state: providerState }
|
||||
}
|
||||
|
||||
@@ -169,7 +169,6 @@ describe("contract hygiene", () => {
|
||||
test("reusable public identifiers are stable and unique", () => {
|
||||
const identifiers = [
|
||||
Agent.Color,
|
||||
ConfigProvider.ModelSettings,
|
||||
ConfigProvider.Settings,
|
||||
FileSystem.Submatch,
|
||||
Form.Field,
|
||||
@@ -184,7 +183,6 @@ describe("contract hygiene", () => {
|
||||
Model.Ref,
|
||||
Model.Capabilities,
|
||||
Model.Cost,
|
||||
Model.Settings,
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Worktree.Directory,
|
||||
@@ -241,12 +239,11 @@ describe("contract hygiene", () => {
|
||||
|
||||
expect(
|
||||
sources
|
||||
.filter((item) => item.file !== "provider.ts" && item.file !== "model.ts" && item.file !== "integration.ts")
|
||||
.filter((item) => item.file !== "provider.ts" && item.file !== "integration.ts")
|
||||
.map((item) => item.source)
|
||||
.join("\n"),
|
||||
).not.toContain("Schema.Any")
|
||||
expect(sources.find((item) => item.file === "provider.ts")?.source.match(/Schema\.Any/g)).toHaveLength(3)
|
||||
expect(sources.find((item) => item.file === "model.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
|
||||
expect(sources.find((item) => item.file === "integration.ts")?.source.match(/Schema\.Any/g)).toHaveLength(2)
|
||||
expect(source).not.toContain("Schema.mutable")
|
||||
})
|
||||
|
||||
@@ -75,10 +75,13 @@ describe("Model.Info", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model.Settings", () => {
|
||||
test("preserves provider-specific model options", () => {
|
||||
expect(Schema.decodeUnknownSync(Model.Settings)({ providerOption: true })).toEqual({
|
||||
providerOption: true,
|
||||
describe("Model.Capabilities", () => {
|
||||
test("decodes the optional transport setting", () => {
|
||||
const model = Model.Info.default(Provider.ID.openai, Model.ID.make("gpt-5.4-mini"))
|
||||
expect(Schema.encodeSync(Model.Info)({ ...model, settings: { transport: undefined } }).settings).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "websocket" } }).settings).toEqual({
|
||||
transport: "websocket",
|
||||
})
|
||||
expect(() => Schema.decodeUnknownSync(Model.Info)({ ...model, settings: { transport: "sse" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,23 +23,14 @@ test("assistant terminal diagnostics remain optional and round trip", () => {
|
||||
...assistant,
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
state: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
state: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
})
|
||||
const legacy = SessionMessage.persisted({
|
||||
...assistant,
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
})
|
||||
expect(decode(legacy).state).toEqual({ promptFeedback: { blockReason: "SAFETY" } })
|
||||
expect(encode(decode(legacy))).toEqual({
|
||||
...assistant,
|
||||
state: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("failed steps only override the assistant finish for content filters", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageRenderable, type CliRenderer, type Renderable } from "@opentui/core"
|
||||
import type { CliRenderer, Renderable } from "@opentui/core"
|
||||
import {
|
||||
createMockKeys,
|
||||
createMockMouse,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type MockInput,
|
||||
type MockMouse,
|
||||
} from "@opentui/core/testing"
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { SimulationProtocol } from "../protocol"
|
||||
import { SimulationRenderer } from "./renderer"
|
||||
import { SimulationSemantics } from "./semantics"
|
||||
@@ -165,55 +165,9 @@ export const capture = Effect.fn("SimulationActions.capture")(function* (harness
|
||||
width: span.width,
|
||||
})),
|
||||
})),
|
||||
images: captureImages(harness.renderer),
|
||||
} satisfies SimulationProtocol.Frontend.CapturedFrame
|
||||
})
|
||||
|
||||
function captureImages(renderer: CliRenderer): SimulationProtocol.Frontend.CapturedImage[] {
|
||||
return all(renderer.root).flatMap((renderable) => {
|
||||
if (!(renderable instanceof ImageRenderable) || !renderable.visible || renderable.isDestroyed || !renderable.image)
|
||||
return []
|
||||
const fitted = renderable.fit === "cover"
|
||||
? { width: renderable.width, height: renderable.height }
|
||||
: renderable.getFittedSize(renderable.width, renderable.height)
|
||||
if (fitted.width <= 0 || fitted.height <= 0) return []
|
||||
const x = renderable.screenX + Math.floor((renderable.width - fitted.width) / 2)
|
||||
const y = renderable.screenY + Math.floor((renderable.height - fitted.height) / 2)
|
||||
let source = renderable.image
|
||||
let extracted: typeof source | undefined
|
||||
if (renderable.fit === "cover") {
|
||||
const targetAspect = renderable.width / (renderable.height * renderable.cellAspectRatio)
|
||||
const sourceAspect = source.width / source.height
|
||||
const width = sourceAspect > targetAspect ? Math.max(1, Math.round(source.height * targetAspect)) : source.width
|
||||
const height = sourceAspect > targetAspect ? source.height : Math.max(1, Math.round(source.width / targetAspect))
|
||||
extracted = source.extract({
|
||||
left: Math.floor((source.width - width) / 2),
|
||||
top: Math.floor((source.height - height) / 2),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
source = extracted
|
||||
}
|
||||
try {
|
||||
const raw = source.raw("rgba8")
|
||||
const rgba = new Uint8Array(raw.width * raw.height * 4)
|
||||
for (let row = 0; row < raw.height; row++)
|
||||
rgba.set(raw.data.subarray(row * raw.stride, row * raw.stride + raw.width * 4), row * raw.width * 4)
|
||||
return [{
|
||||
x,
|
||||
y,
|
||||
width: fitted.width,
|
||||
height: fitted.height,
|
||||
pixelWidth: raw.width,
|
||||
pixelHeight: raw.height,
|
||||
rgba: Encoding.encodeBase64(rgba),
|
||||
}]
|
||||
} finally {
|
||||
extracted?.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const execute = Effect.fn("SimulationActions.execute")(function* (harness: Harness, action: Action) {
|
||||
switch (action.type) {
|
||||
case "ui.type":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { BoxRenderable, ImageRenderable, NativeImage, TextRenderable } from "@opentui/core"
|
||||
import { BoxRenderable, TextRenderable } from "@opentui/core"
|
||||
import { Effect } from "effect"
|
||||
import { capture, createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
|
||||
import { createHarness, execute, type Harness, matches, snapshot, state } from "../src/frontend/actions"
|
||||
import { SimulationRenderer } from "../src/frontend/renderer"
|
||||
import { SimulationSemantics } from "../src/frontend/semantics"
|
||||
|
||||
@@ -74,42 +74,6 @@ test("headless input mirrors the configured kitty keyboard protocol", async () =
|
||||
)
|
||||
})
|
||||
|
||||
test("captures image pixels and their terminal placement", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const renderer = yield* SimulationRenderer.create({ width: 20, height: 10 })
|
||||
const image = NativeImage.fromRgba(
|
||||
Uint8Array.from([255, 0, 0, 255, 0, 0, 255, 255]),
|
||||
2,
|
||||
1,
|
||||
)
|
||||
renderer.root.add(new ImageRenderable(renderer, {
|
||||
source: image,
|
||||
position: "absolute",
|
||||
left: 2,
|
||||
top: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
fit: "fill",
|
||||
}))
|
||||
|
||||
const frame = yield* capture(createHarness(renderer))
|
||||
|
||||
expect(frame.images).toEqual([{
|
||||
x: 2,
|
||||
y: 1,
|
||||
width: 2,
|
||||
height: 1,
|
||||
pixelWidth: 2,
|
||||
pixelHeight: 1,
|
||||
rgba: "/wAA/wAA//8=",
|
||||
}])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("clicks a target at relative coordinates through descendant text", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, mergeProps, on, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
|
||||
import { createEffect, mergeProps, onCleanup, onMount, Show, splitProps, type ComponentProps } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createStore } from "solid-js/store"
|
||||
@@ -236,22 +236,18 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
local.viewportRef(viewportRef)
|
||||
}
|
||||
|
||||
// The observer reports every target once when it starts observing (including a thumb mount that
|
||||
// appears later), after layout. Measuring here as well would force a synchronous layout per
|
||||
// ScrollView while the page is still rendering.
|
||||
createResizeObserver(
|
||||
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
|
||||
updateThumb,
|
||||
)
|
||||
|
||||
updateThumb()
|
||||
})
|
||||
|
||||
// Inputs that change the thumb without resizing anything: re-measure on change, but leave the
|
||||
// first measurement to the observer.
|
||||
createEffect(
|
||||
on([() => local.verticalScrollAdjustment, vertical, horizontal, thumbMount], () => updateThumb(), {
|
||||
defer: true,
|
||||
}),
|
||||
)
|
||||
createEffect(() => {
|
||||
thumbMount()
|
||||
updateThumb()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!horizontal() || !viewportRef) return
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { boundToastDescription, toastDescriptionLimit } from "./description"
|
||||
|
||||
describe("toast description bound", () => {
|
||||
test("keeps ordinary descriptions and the options object", () => {
|
||||
const options = { title: "Failed", description: "Attachment exceeds the limit" }
|
||||
expect(boundToastDescription(options)).toBe(options)
|
||||
expect(boundToastDescription({ description: undefined })).toEqual({ description: undefined })
|
||||
})
|
||||
|
||||
test("cuts a description that embeds a whole data URL", () => {
|
||||
const description = `Attachment exceeds the limit: data:text/plain;base64,${"Q".repeat(50 * 1024 * 1024)}`
|
||||
const bounded = boundToastDescription({ title: "Failed to send prompt", description })
|
||||
expect(bounded.description!.length).toBe(toastDescriptionLimit + 1)
|
||||
expect(bounded.description!.endsWith("…")).toBe(true)
|
||||
expect(bounded.description!.startsWith("Attachment exceeds the limit: data:text/plain;base64,")).toBe(true)
|
||||
expect(bounded.title).toBe("Failed to send prompt")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// A toast shows a few lines; the rest of a long description would only be retained (in the dedupe
|
||||
// key, the active-toast registry and the toaster's store) for as long as the toast lives. Server
|
||||
// errors have carried a whole data URL here.
|
||||
export const toastDescriptionLimit = 2000
|
||||
|
||||
export function boundToastDescription<T extends { description?: string }>(options: T): T {
|
||||
if (!options.description || options.description.length <= toastDescriptionLimit) return options
|
||||
return { ...options, description: `${options.description.slice(0, toastDescriptionLimit)}…` }
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Toaster, toast, type ToasterProps } from "solid-sonner"
|
||||
import { boundToastDescription } from "./description"
|
||||
import { isRTL } from "@kobalte/core/i18n"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js"
|
||||
@@ -206,7 +205,7 @@ const activeToastByKey = new Map<string, ActiveToast>()
|
||||
const activeToastById = new Map<number, ActiveToast>()
|
||||
|
||||
export function showToast(options: ToastOptions | string) {
|
||||
const opts = boundToastDescription(typeof options === "string" ? { description: options } : options)
|
||||
const opts: ToastOptions = typeof options === "string" ? { description: options } : options
|
||||
const key = JSON.stringify({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
|
||||
@@ -296,7 +296,6 @@ export default defineConfig({
|
||||
],
|
||||
components: {
|
||||
Hero: "./src/components/Hero.astro",
|
||||
Banner: "./src/components/Banner.astro",
|
||||
Head: "./src/components/Head.astro",
|
||||
Header: "./src/components/Header.astro",
|
||||
Footer: "./src/components/Footer.astro",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<a class="v2-banner" href="https://opencode.ai/v2">
|
||||
<span class="badge">New</span>
|
||||
<span class="text">OpenCode v2 is now available</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.v2-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 12px 24px;
|
||||
background: var(--color-background-interactive);
|
||||
color: var(--color-text-strong) !important;
|
||||
border-bottom: 1px solid var(--color-border-weak);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.v2-banner:hover {
|
||||
background: var(--color-background-interactive-weaker);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--color-text);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.v2-banner:hover .arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
@media (max-width: 30rem) {
|
||||
.v2-banner {
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13251,27 +13251,6 @@
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13291,7 +13270,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13314,7 +13293,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15529,7 +15508,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15635,20 +15614,6 @@
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Model.Variant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15656,7 +15621,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
|
||||
@@ -13251,27 +13251,6 @@
|
||||
"required": ["input", "output"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Config.ModelEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13291,7 +13270,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -13314,7 +13293,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Model.Settings"
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15529,7 +15508,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
@@ -15635,20 +15614,6 @@
|
||||
"required": ["id", "providerID"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Model.Settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Model.Variant": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15656,7 +15621,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Model.Settings"
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import path from "node:path"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../dist/client")
|
||||
const externalRoutes = new Set(["/docs"])
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: root, absolute: true }))
|
||||
const failures = (
|
||||
await Promise.all(
|
||||
@@ -12,7 +11,6 @@ const failures = (
|
||||
Array.from(html.matchAll(/href="([^"]+)"/g), async (match) => {
|
||||
const url = new URL(match[1], page)
|
||||
if (url.origin !== "https://opencode.local") return
|
||||
if (externalRoutes.has(url.pathname)) return
|
||||
|
||||
const targetPath = path.join(root, decodeURIComponent(url.pathname))
|
||||
const target = (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user