mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-16 11:20:42 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
469ffd489b | ||
|
|
26059a1f11 |
No files matched your search
@@ -652,7 +652,6 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
|
||||
@@ -100,17 +100,9 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Git Bash and MSYS2 run as emulated x64 on Windows ARM64, so `uname -m` reports x86_64.
|
||||
# The host CPU still shows in the `uname -s` suffix and in PROCESSOR_IDENTIFIER.
|
||||
if [ "$os" = "windows" ] && [ "$arch" = "x64" ]; then
|
||||
if [[ "$raw_os" == *-ARM64 || "${PROCESSOR_IDENTIFIER:-}" == ARM* ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64|windows-arm64)
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
|
||||
@@ -417,9 +417,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
|
||||
frequency_penalty: request.generation?.frequencyPenalty,
|
||||
presence_penalty: request.generation?.presencePenalty,
|
||||
stop: request.generation?.stop,
|
||||
prompt_cache_key: ProviderShared.sendsPromptCacheKey(request)
|
||||
? (options.promptCacheKey ?? request.promptCacheKey)
|
||||
: undefined,
|
||||
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
|
||||
safe_prompt: options.safePrompt,
|
||||
document_image_limit: options.documentImageLimit,
|
||||
document_page_limit: options.documentPageLimit,
|
||||
|
||||
@@ -39,15 +39,10 @@ export const knownString = <Known extends string>() =>
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// Body-level `prompt_cache_key` is off when caching is disabled or the model
|
||||
// explicitly rejects the field. Header-based affinity is unaffected.
|
||||
export const sendsPromptCacheKey = (request: LLMRequest) =>
|
||||
request.cache !== "none" && request.model.compatibility?.supportsPromptCacheKey !== false
|
||||
|
||||
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
|
||||
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
|
||||
export const promptCacheKey = (request: LLMRequest): string | undefined => {
|
||||
if (!sendsPromptCacheKey(request) || request.promptCacheKey === undefined) return undefined
|
||||
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
|
||||
const chars = Array.from(request.promptCacheKey)
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
|
||||
|
||||
@@ -199,9 +199,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
// Accepts `prompt_cache_key` in the request body. Chat omits the key unless
|
||||
// this is true; other protocols omit it only when this is false.
|
||||
// Session-affinity headers still flow regardless.
|
||||
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
|
||||
// key unless this is set; session-affinity headers still flow regardless.
|
||||
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/http"
|
||||
import { LanguageModel, LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
@@ -188,16 +188,6 @@ describe("Mistral Chat", () => {
|
||||
)
|
||||
expect(uncached.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const unsupported = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "common-key",
|
||||
providerOptions: { promptCacheKey: "native-key" },
|
||||
}),
|
||||
)
|
||||
expect(unsupported.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const longKey = "cache-key-".repeat(10)
|
||||
const unbounded = yield* compileRequest(
|
||||
LLM.request({
|
||||
|
||||
@@ -2117,24 +2117,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key when compatibility disables it", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "request_cache",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
expect(model.compatibility?.supportsPromptCacheKey).toBeUndefined()
|
||||
expect(
|
||||
(yield* compileRequest(LLM.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body,
|
||||
).toHaveProperty("prompt_cache_key", "request_cache")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -224,28 +224,147 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
|
||||
await expect(view.input).toHaveText("")
|
||||
})
|
||||
|
||||
test("a steer shows in the timeline as the composer clears, before its selection commits", async ({ page }) => {
|
||||
test("waits for model selection before clearing the composer, then shows the steer in the frame the composer clears", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "steer")
|
||||
const text = "steer after model selection commits"
|
||||
const commit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/model`, { method: "POST" })
|
||||
const admit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/prompt`, { method: "POST" })
|
||||
|
||||
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
|
||||
hasText: "steer while the model commits",
|
||||
hasText: text,
|
||||
})
|
||||
|
||||
await view.input.fill("steer while the model commits")
|
||||
await view.input.fill(text)
|
||||
await expect(view.input).toHaveText(text)
|
||||
|
||||
const probe = await page.evaluateHandle((expected) => {
|
||||
const state = { emptyFrames: 0, emptyMutations: 0, active: true }
|
||||
|
||||
const hasPrompt = () => {
|
||||
const editor = document.querySelector('[data-component="composer-editor"]')
|
||||
const inComposer = (editor?.textContent ?? "").includes(expected)
|
||||
const rows = document.querySelectorAll('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]')
|
||||
const inTimeline = Array.from(rows).some((row) => (row.textContent ?? "").includes(expected))
|
||||
|
||||
return { inComposer, inTimeline }
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!state.active) return
|
||||
const current = hasPrompt()
|
||||
|
||||
if (!current.inComposer && !current.inTimeline) state.emptyMutations++
|
||||
})
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
const tick = () => {
|
||||
if (!state.active) return
|
||||
const current = hasPrompt()
|
||||
|
||||
if (!current.inComposer && !current.inTimeline) state.emptyFrames++
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
requestAnimationFrame(tick)
|
||||
|
||||
return state
|
||||
}, text)
|
||||
|
||||
await view.input.press("Enter")
|
||||
await commit.arrived
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(prompt).toBeVisible()
|
||||
|
||||
// A second Enter while model selection is in flight must not double-submit.
|
||||
await view.input.press("Enter")
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(prompt).toHaveCount(0)
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
commit.release()
|
||||
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["steer while the model commits"])
|
||||
await admit.arrived
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt).toHaveCount(1)
|
||||
|
||||
const counts = await probe.evaluate((state) => {
|
||||
state.active = false
|
||||
|
||||
return { emptyFrames: state.emptyFrames, emptyMutations: state.emptyMutations }
|
||||
})
|
||||
|
||||
expect(counts).toEqual({ emptyFrames: 0, emptyMutations: 0 })
|
||||
|
||||
admit.release()
|
||||
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual([text])
|
||||
expect(commit.requests).toHaveLength(1)
|
||||
await expect(prompt).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("a steer into an idle session leaves the finished turn finished until delivery", async ({ page }) => {
|
||||
test("keeps the draft when model selection fails and rolls back the optimistic row when prompt admission fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mock = createQueueMock([])
|
||||
const view = await openQueue(page, mock, "steer")
|
||||
const text = "recover this prompt on failure"
|
||||
|
||||
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
|
||||
hasText: text,
|
||||
})
|
||||
|
||||
let failModel = true
|
||||
await page.route(
|
||||
(url) => url.pathname === `/api/session/${sessionID}/model`,
|
||||
async (route) => {
|
||||
if (route.request().method() !== "POST" || !failModel) return route.fallback()
|
||||
failModel = false
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { message: "Model switch failed" } }),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await view.input.fill(text)
|
||||
await view.input.press("Enter")
|
||||
await expect(page.getByText("Failed to send prompt", { exact: true })).toBeVisible()
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(prompt).toHaveCount(0)
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
let promptAttempts = 0
|
||||
const firstAttempt = Promise.withResolvers<void>()
|
||||
const releasePromptFailure = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === `/api/session/${sessionID}/prompt`,
|
||||
async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
promptAttempts++
|
||||
firstAttempt.resolve()
|
||||
await releasePromptFailure.promise
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { message: "Prompt admission failed" } }),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
await view.input.press("Enter")
|
||||
await firstAttempt.promise
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(prompt).toBeVisible()
|
||||
|
||||
releasePromptFailure.resolve()
|
||||
await expect.poll(() => promptAttempts).toBe(2)
|
||||
await expect(prompt).toHaveCount(0)
|
||||
await expect(view.input).toHaveText(text)
|
||||
})
|
||||
|
||||
test("a steer into an idle session leaves the finished turn settled until delivery", async ({ page }) => {
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[
|
||||
@@ -550,6 +669,15 @@ test("Undo appends to an existing draft and restores inline attachments", async
|
||||
await expect(view.input).toBeFocused()
|
||||
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
|
||||
const admit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/prompt`, { method: "POST" })
|
||||
await view.input.press("Enter")
|
||||
await admit.arrived
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(page.locator('[data-slot="user-message-attachment-image"]')).toBeVisible()
|
||||
admit.release()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
await expect(page.locator('[data-slot="user-message-attachment-image"]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("Undo of a comment-only prompt keeps the draft text and restores the comment once", async ({ page }) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { SessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
import type { createComposerSubmission } from "./submission-state"
|
||||
|
||||
@@ -52,8 +51,8 @@ export type ComposerQueue = {
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
handoff: {
|
||||
set: (handoff: SessionMessageHandoff) => void
|
||||
handoff?: {
|
||||
set: (message: SessionMessageUser) => void
|
||||
clear: (messageID: string) => void
|
||||
}
|
||||
api: {
|
||||
|
||||
@@ -9,6 +9,7 @@ export function createComposerSubmission(input: {
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
const initial = input.target
|
||||
const before = input.target.current()
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
let following: Prompt | undefined
|
||||
@@ -18,6 +19,7 @@ export function createComposerSubmission(input: {
|
||||
prompt: input.prompt,
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
unchanged: () => (cleared !== undefined ? target.current() === cleared : target.current() === before),
|
||||
clear() {
|
||||
if (initial !== target) {
|
||||
initial.reset()
|
||||
@@ -25,9 +27,20 @@ export function createComposerSubmission(input: {
|
||||
// A preparing session may already have an unsent follow-up in its promoted composer.
|
||||
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
|
||||
following = clonePrompt(target.current())
|
||||
|
||||
if (!following) target.reset()
|
||||
cleared = target.current()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!following) target.reset()
|
||||
if (target.current() !== before) {
|
||||
cleared = before
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageHandoff } from "@/session/handoff"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type {
|
||||
ActiveComposerAdapter,
|
||||
ComposerControls,
|
||||
ComposerDelivery,
|
||||
ComposerSession,
|
||||
NewSessionComposerAdapter,
|
||||
} from "./adapter"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import { createMemoryComposerState, type Prompt } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
@@ -81,14 +75,12 @@ function submitInput(
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
|
||||
delivery: ComposerDelivery = "steer",
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
clientCommand,
|
||||
delivery: () => delivery,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory: (prompt) =>
|
||||
@@ -118,7 +110,7 @@ function session(input: {
|
||||
return {
|
||||
id: "session-1",
|
||||
directory: "C:/repo",
|
||||
handoff: input.handoff ?? { set() {}, clear() {} },
|
||||
handoff: input.handoff,
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
@@ -237,41 +229,154 @@ describe("Composer submission", () => {
|
||||
expect(state.context.items()).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["steer", "queue"] as const)(
|
||||
"shows a %s in the session as the composer clears, before its selection commits",
|
||||
async (delivery) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const handoffs: SessionMessageHandoff[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
test("keeps the prompt in the composer while selection commits, then clears and hands off attachments on admission", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
handoff: { set: (handoff) => handoffs.push(handoff), clear() {} },
|
||||
switchModel: async () => committed.promise,
|
||||
})
|
||||
const prompt: Prompt = [
|
||||
{ type: "text", content: "ship it", start: 0, end: 7 },
|
||||
{
|
||||
type: "image",
|
||||
id: "attachment",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
|
||||
},
|
||||
]
|
||||
|
||||
await submitInput(active(state, target), undefined, "normal", undefined, undefined, undefined, delivery).submit(
|
||||
new Event("submit"),
|
||||
)
|
||||
state.set(prompt)
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(statuses).toEqual(["running"])
|
||||
// A queued prompt shows in the queue once admitted, not in the transcript.
|
||||
expect(handoffs).toMatchObject(
|
||||
delivery === "steer"
|
||||
? [
|
||||
{
|
||||
message: { type: "user", text: "ship it" },
|
||||
selection: { agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
committed.resolve()
|
||||
},
|
||||
)
|
||||
const switching = Promise.withResolvers<void>()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const sending = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const handoffs: SessionMessageUser[] = []
|
||||
const history: string[] = []
|
||||
|
||||
const target = session({
|
||||
calls,
|
||||
statuses,
|
||||
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
|
||||
handoff: { set: (message) => handoffs.push(message), clear() {} },
|
||||
switchModel: async () => {
|
||||
calls.push("switch-model")
|
||||
switching.resolve()
|
||||
await committed.promise
|
||||
},
|
||||
prompt: async () => {
|
||||
await sending.promise
|
||||
},
|
||||
})
|
||||
|
||||
const submission = submitInput(active(state, target), undefined, "normal", () => [], history)
|
||||
const first = submission.submit(new Event("submit"))
|
||||
await switching.promise
|
||||
|
||||
// Overlapping Enter while model selection is in flight must not send a second request.
|
||||
await submission.submit(new Event("submit"))
|
||||
|
||||
expect(calls).toEqual(["switch-model"])
|
||||
expect(state.current()).toEqual(prompt)
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
expect(statuses).toEqual([])
|
||||
expect(handoffs).toEqual([])
|
||||
expect(history).toEqual([])
|
||||
|
||||
committed.resolve()
|
||||
await first
|
||||
|
||||
expect(calls).toEqual(["switch-model", "prompt"])
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(state.context.items()).toEqual([])
|
||||
expect(statuses).toEqual(["running"])
|
||||
expect(history).toEqual(["add:ship itimage"])
|
||||
expect(handoffs).toMatchObject([
|
||||
{
|
||||
type: "user",
|
||||
text: "ship it",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
|
||||
name: "image.png",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
sending.resolve()
|
||||
})
|
||||
|
||||
test("keeps the draft intact without clearing or flashing history when model selection fails", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const history: string[] = []
|
||||
const errors: unknown[] = []
|
||||
const error = new Error("model unavailable")
|
||||
|
||||
const target = session({
|
||||
calls,
|
||||
statuses,
|
||||
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
|
||||
switchModel: async () => {
|
||||
calls.push("switch-model")
|
||||
throw error
|
||||
},
|
||||
})
|
||||
|
||||
const submission = submitInput(
|
||||
active(state, target),
|
||||
{ missingSelection() {}, unqueueable() {}, failed: (_kind, cause) => errors.push(cause) },
|
||||
"normal",
|
||||
() => [],
|
||||
history,
|
||||
)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
|
||||
expect(calls).toEqual(["switch-model"])
|
||||
expect(errors).toEqual([error])
|
||||
expect(statuses).toEqual([])
|
||||
expect(history).toEqual([])
|
||||
expect(state.current()).toEqual([{ type: "text", content: "ship it", start: 0, end: 7 }])
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
expect(state.retry.current()?.id).toMatch(/^msg_/)
|
||||
})
|
||||
|
||||
test("preserves edits typed while model selection is in flight and sends the captured snapshot", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const switching = Promise.withResolvers<void>()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const sent: string[] = []
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
|
||||
switchModel: async () => {
|
||||
switching.resolve()
|
||||
await committed.promise
|
||||
},
|
||||
prompt: async (value) => {
|
||||
sent.push(value.text)
|
||||
},
|
||||
})
|
||||
|
||||
const submission = submitInput(active(state, target))
|
||||
const first = submission.submit(new Event("submit"))
|
||||
await switching.promise
|
||||
|
||||
const edited: Prompt = [{ type: "text", content: "ship it and also run tests", start: 0, end: 26 }]
|
||||
state.set(edited, 26)
|
||||
committed.resolve()
|
||||
await first
|
||||
|
||||
expect(sent).toEqual(["ship it"])
|
||||
expect(state.current()).toEqual(edited)
|
||||
})
|
||||
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
@@ -505,7 +610,7 @@ describe("Composer submission", () => {
|
||||
]
|
||||
|
||||
draft.set(prompt)
|
||||
const handedOff = Promise.withResolvers<SessionMessageHandoff>()
|
||||
const handedOff = Promise.withResolvers<SessionMessageUser>()
|
||||
const admitted = Promise.withResolvers<void>()
|
||||
const cleanup = Promise.withResolvers<void>()
|
||||
|
||||
@@ -525,7 +630,7 @@ describe("Composer submission", () => {
|
||||
await submitted
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
|
||||
expect((await handedOff.promise).message).toMatchObject({
|
||||
expect(await handedOff.promise).toMatchObject({
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { PromptComment } from "./comment-note"
|
||||
import type { PromptHistoryComment } from "./history/entry"
|
||||
import type { ImageAttachmentPart, Prompt } from "./state"
|
||||
@@ -141,41 +141,93 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
if (!started) return
|
||||
const session = started.session
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
if (value.mode === "normal" && !command) {
|
||||
// The timeline shows a steer, and any switches it makes, as the composer clears; a queued prompt shows in
|
||||
// the queue once admitted.
|
||||
if (value.delivery === "steer")
|
||||
session.handoff.set({
|
||||
message: handoffMessage(value),
|
||||
selection: {
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
},
|
||||
})
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
const finishPromptCleanup = () => {
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
input.adapter.submitted()
|
||||
// Like the TUI, sent context goes with the prompt rather than riding along on the next one.
|
||||
submission.context.forEach((item) => submission.target().context.remove(item.key))
|
||||
input.comments.clear()
|
||||
clearSubmission(input, submission)
|
||||
}
|
||||
|
||||
if (value.mode === "normal" && !command) {
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
|
||||
if (input.adapter.kind === "new-session") {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
|
||||
const sending = sendPrompt(
|
||||
session,
|
||||
value,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
() => {},
|
||||
).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
finishPromptCleanup()
|
||||
void sending.then((result) => {
|
||||
if (!result.ok)
|
||||
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
|
||||
})
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for selection to commit before clearing the composer, then clear in the same batch that admits the
|
||||
// local row so the prompt never disappears between the two surfaces.
|
||||
const admitted = Promise.withResolvers<boolean>()
|
||||
|
||||
const sending = sendPrompt(
|
||||
session,
|
||||
value,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
(images) => {
|
||||
if (images.length > 0) session.handoff?.set({ ...handoffMessage(value), files: images })
|
||||
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
finishPromptCleanup()
|
||||
admitted.resolve(true)
|
||||
},
|
||||
).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => {
|
||||
admitted.resolve(false)
|
||||
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
|
||||
if (!(await admitted.promise)) {
|
||||
const result = await sending
|
||||
|
||||
if (!result.ok) {
|
||||
if (submission.current(input.adapter.state) && submission.unchanged()) {
|
||||
submission.target().retry.set({
|
||||
id: value.id,
|
||||
agent: value.selection.agent,
|
||||
providerID: value.selection.model.providerID,
|
||||
modelID: value.selection.model.modelID,
|
||||
variant: value.selection.variant,
|
||||
})
|
||||
}
|
||||
|
||||
input.notify.failed("prompt", result.error)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void sending.then((result) => {
|
||||
if (!result.ok)
|
||||
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
|
||||
@@ -186,6 +238,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
return
|
||||
}
|
||||
|
||||
input.addToHistory(value.prompt, value.mode)
|
||||
input.resetHistory()
|
||||
await started.cleanupReady
|
||||
await started.complete?.()
|
||||
input.adapter.submitted()
|
||||
@@ -337,7 +391,10 @@ function currentSelection(input: ComposerSubmitInput): ComposerSelection | undef
|
||||
}
|
||||
|
||||
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
|
||||
const unchanged = submission.unchanged()
|
||||
submission.clear()
|
||||
|
||||
if (!unchanged) return
|
||||
submission.target().mode.set("normal")
|
||||
input.setMode("normal")
|
||||
input.closePopover()
|
||||
@@ -494,6 +551,7 @@ async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: (images: NonNullable<SessionMessageUser["files"]>) => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
|
||||
@@ -533,7 +591,25 @@ async function sendPrompt(
|
||||
},
|
||||
}
|
||||
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
const images = request.files.flatMap((file) =>
|
||||
file.uri.startsWith("data:")
|
||||
? [{ data: "", mime: file.mime, source: { type: "uri" as const, uri: file.uri }, name: file.name }]
|
||||
: [],
|
||||
)
|
||||
|
||||
const sending = batch(() => {
|
||||
const request = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit(images)
|
||||
|
||||
return request
|
||||
})
|
||||
|
||||
try {
|
||||
await sending
|
||||
} catch (error) {
|
||||
session.handoff?.clear(value.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
@@ -565,7 +641,7 @@ function failSubmission(
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
|
||||
if (messageID) session.handoff.clear(messageID)
|
||||
if (messageID) session.handoff?.clear(messageID)
|
||||
rollback?.()
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
|
||||
@@ -1,54 +1,47 @@
|
||||
import type { ModelRef, SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
|
||||
// A submitted prompt the timeline shows before the server admits it, with the agent and model it was sent with.
|
||||
export type SessionMessageHandoff = {
|
||||
message: SessionMessageUser
|
||||
selection: { agent: string; model: ModelRef }
|
||||
}
|
||||
|
||||
const MAX = 40
|
||||
|
||||
const [handoffs, setHandoffs] = createStore<Record<string, SessionMessageHandoff | undefined>>({})
|
||||
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
|
||||
|
||||
const handoffOrder = new Map<string, true>()
|
||||
const messageOrder = new Map<string, true>()
|
||||
|
||||
export const setSessionMessageHandoff = (key: string, handoff: SessionMessageHandoff) => {
|
||||
handoffOrder.delete(key)
|
||||
handoffOrder.set(key, true)
|
||||
setHandoffs(key, handoff)
|
||||
export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => {
|
||||
messageOrder.delete(key)
|
||||
messageOrder.set(key, true)
|
||||
setMessages(key, message)
|
||||
|
||||
while (handoffOrder.size > MAX) {
|
||||
const first = handoffOrder.keys().next().value
|
||||
while (messageOrder.size > MAX) {
|
||||
const first = messageOrder.keys().next().value
|
||||
|
||||
if (first === undefined) return
|
||||
handoffOrder.delete(first)
|
||||
setHandoffs(first, undefined)
|
||||
messageOrder.delete(first)
|
||||
setMessages(first, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionMessageHandoff = (key: string) => handoffs[key]
|
||||
export const getSessionMessageHandoff = (key: string) => messages[key]
|
||||
|
||||
export const clearSessionMessageHandoff = (key: string, messageID: string) => {
|
||||
if (handoffs[key]?.message.id !== messageID) return
|
||||
handoffOrder.delete(key)
|
||||
setHandoffs(key, undefined)
|
||||
if (messages[key]?.id !== messageID) return
|
||||
messageOrder.delete(key)
|
||||
setMessages(key, undefined)
|
||||
}
|
||||
|
||||
// Holds a session's handoff until the server's admission echo, which follows the echoes of the switches it projects.
|
||||
export function createSessionMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
|
||||
let unsubscribe: VoidFunction | undefined
|
||||
|
||||
return {
|
||||
set(handoff: SessionMessageHandoff) {
|
||||
set(message: SessionMessageUser) {
|
||||
unsubscribe?.()
|
||||
setSessionMessageHandoff(key, handoff)
|
||||
setSessionMessageHandoff(key, message)
|
||||
unsubscribe = event.on("session.inbox.enqueued", (item) => {
|
||||
if (item.data.sessionID !== sessionID || item.data.inboxID !== handoff.message.id) return
|
||||
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, handoff.message.id)
|
||||
clearSessionMessageHandoff(key, message.id)
|
||||
})
|
||||
},
|
||||
clear(messageID: string) {
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("visibleTimelineMessages", () => {
|
||||
})
|
||||
|
||||
describe("applyTimelineMessageHandoff", () => {
|
||||
const message = {
|
||||
const handoff = {
|
||||
id: "msg_image",
|
||||
type: "user",
|
||||
text: "",
|
||||
@@ -212,81 +212,21 @@ describe("applyTimelineMessageHandoff", () => {
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const selection = { agent: "plan", model: { id: "next", providerID: "provider", variant: "high" } }
|
||||
const handoff = { message, selection }
|
||||
const switched = { agent: "plan", model: selection.model }
|
||||
|
||||
test("shows a promoted image-only prompt before client admission", () => {
|
||||
expect(applyTimelineMessageHandoff([], handoff, switched)).toEqual([message])
|
||||
expect(applyTimelineMessageHandoff([], handoff)).toEqual([handoff])
|
||||
})
|
||||
|
||||
test("adds attachments to the client's optimistic row", () => {
|
||||
const optimistic = { id: message.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
|
||||
expect(applyTimelineMessageHandoff([optimistic], handoff, switched)).toEqual([
|
||||
{ ...optimistic, files: message.files },
|
||||
])
|
||||
const optimistic = { id: handoff.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
|
||||
expect(applyTimelineMessageHandoff([optimistic], handoff)).toEqual([{ ...optimistic, files: handoff.files }])
|
||||
})
|
||||
|
||||
test("keeps the durable attachment payload", () => {
|
||||
const durable = {
|
||||
...message,
|
||||
...handoff,
|
||||
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const source = [durable]
|
||||
|
||||
expect(applyTimelineMessageHandoff(source, handoff, switched)).toBe(source)
|
||||
})
|
||||
|
||||
const agent = {
|
||||
id: "msg_image_agent",
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const model = {
|
||||
id: "msg_image_model",
|
||||
type: "model-switched",
|
||||
model: selection.model,
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
// The session takes each switch from its echo, which also lands the durable notice.
|
||||
test.each([
|
||||
{
|
||||
name: "both switches pending",
|
||||
session: { agent: "build", model: { id: "old", providerID: "provider" } },
|
||||
notices: [agent, model],
|
||||
},
|
||||
{
|
||||
name: "only a variant change",
|
||||
session: { agent: "plan", model: { ...selection.model, variant: undefined } },
|
||||
notices: [model],
|
||||
},
|
||||
{
|
||||
name: "the agent echoed",
|
||||
session: { agent: "plan", model: { id: "old", providerID: "provider" } },
|
||||
notices: [model],
|
||||
},
|
||||
{ name: "both echoed", session: switched, notices: [] },
|
||||
])("projects the prompt's switches ahead of it: $name", (row) => {
|
||||
expect(applyTimelineMessageHandoff(messages.slice(0, 2), handoff, row.session)).toEqual([
|
||||
...messages.slice(0, 2),
|
||||
...row.notices,
|
||||
message,
|
||||
])
|
||||
})
|
||||
|
||||
test("projects pending switches ahead of the client's optimistic row", () => {
|
||||
const optimistic = { ...message, files: [] } satisfies SessionMessageInfo
|
||||
const session = { agent: "build", model: selection.model }
|
||||
|
||||
expect(applyTimelineMessageHandoff([messages[0], optimistic], handoff, session)).toEqual([
|
||||
messages[0],
|
||||
agent,
|
||||
message,
|
||||
])
|
||||
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
|
||||
})
|
||||
})
|
||||
@@ -1,57 +1,15 @@
|
||||
import type { ModelRef, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { SessionMessageHandoff } from "@/session/handoff"
|
||||
import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@opencode/client/promise"
|
||||
|
||||
// Projects a submitted prompt, and the agent and model switches it makes, until the server's echoes replace them.
|
||||
// The session's live selection takes each switch's echo, so a switch projects only while the session still differs.
|
||||
export function applyTimelineMessageHandoff(
|
||||
messages: SessionMessageInfo[],
|
||||
handoff: SessionMessageHandoff | undefined,
|
||||
session: { agent?: string; model?: ModelRef } | undefined,
|
||||
) {
|
||||
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
|
||||
if (!handoff) return messages
|
||||
const notices = handoffNotices(handoff, session)
|
||||
const index = messages.findIndex((message) => message.id === handoff.message.id)
|
||||
const index = messages.findIndex((message) => message.id === handoff.id)
|
||||
|
||||
if (index < 0) return [...messages, ...notices, handoff.message]
|
||||
if (index < 0) return [...messages, handoff]
|
||||
const message = messages[index]
|
||||
const filled = message.type === "user" && !message.files?.length
|
||||
|
||||
if (!filled && notices.length === 0) return messages
|
||||
if (message.type !== "user" || message.files?.length) return messages
|
||||
|
||||
return [
|
||||
...messages.slice(0, index),
|
||||
...notices,
|
||||
filled ? { ...message, files: handoff.message.files } : message,
|
||||
...messages.slice(index + 1),
|
||||
]
|
||||
}
|
||||
|
||||
function handoffNotices(
|
||||
handoff: SessionMessageHandoff,
|
||||
session: { agent?: string; model?: ModelRef } | undefined,
|
||||
): SessionMessageInfo[] {
|
||||
const time = { created: handoff.message.time.created }
|
||||
const selection = handoff.selection
|
||||
|
||||
return [
|
||||
...(session?.agent && session.agent !== selection.agent
|
||||
? [
|
||||
{
|
||||
id: `${handoff.message.id}_agent`,
|
||||
type: "agent-switched" as const,
|
||||
agent: selection.agent,
|
||||
previous: session.agent,
|
||||
time,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(session?.model &&
|
||||
(session.model.providerID !== selection.model.providerID ||
|
||||
session.model.id !== selection.model.id ||
|
||||
(session.model.variant ?? "default") !== (selection.model.variant ?? "default"))
|
||||
? [{ id: `${handoff.message.id}_model`, type: "model-switched" as const, model: selection.model, time }]
|
||||
: []),
|
||||
]
|
||||
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
|
||||
}
|
||||
|
||||
export function visibleTimelineMessages(
|
||||
|
||||
@@ -68,25 +68,23 @@ export function createTimelineController(input: { session: TimelineSessionSource
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
const visibleMessages = createMemo(() => {
|
||||
const handedOffMessages = createMemo(() =>
|
||||
applyTimelineMessageHandoff(
|
||||
input.session.history.messages(),
|
||||
getSessionMessageHandoff(input.session.identity.sessionKey()),
|
||||
),
|
||||
)
|
||||
|
||||
const projectedMessages = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
|
||||
return visibleTimelineMessages(
|
||||
input.session.history.messages(),
|
||||
handedOffMessages(),
|
||||
id ? data.session.pending.list(id) : [],
|
||||
input.session.data.info()?.revert?.messageID,
|
||||
)
|
||||
})
|
||||
|
||||
// A handoff projects after visibility: its prompt commits any staged revert, so the boundary does not hide it.
|
||||
const projectedMessages = createMemo(() =>
|
||||
applyTimelineMessageHandoff(
|
||||
visibleMessages(),
|
||||
getSessionMessageHandoff(input.session.identity.sessionKey()),
|
||||
input.session.data.info(),
|
||||
),
|
||||
)
|
||||
|
||||
// Inputs that wait behind the active turn. A starting steer opens the next turn instead, so the turn before it
|
||||
// stays finished, as it will once the steer is delivered.
|
||||
const pendingInputIDs = createMemo(() => {
|
||||
|
||||
@@ -15,14 +15,7 @@ if (!command) throw new Error("OpenCode package does not declare a binary")
|
||||
const sourceCommand = packageJson.opencodeSourceBinary ?? command
|
||||
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
||||
const nodeArch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||
// x64 Node runs emulated on Windows ARM64, but PROCESSOR_IDENTIFIER still names the host CPU.
|
||||
const arch =
|
||||
platform === "windows" && nodeArch === "x64" && process.env.PROCESSOR_IDENTIFIER?.startsWith("ARM")
|
||||
? "arm64"
|
||||
: nodeArch
|
||||
// Windows cannot spawn npm's .cmd shim directly, so reuse the npm CLI that runs this script.
|
||||
const npm = process.env.npm_execpath?.endsWith("npm-cli.js") ? [process.execPath, process.env.npm_execpath] : ["npm"]
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||
const sourceBinary = platform === "windows" ? `${sourceCommand}.exe` : sourceCommand
|
||||
const targetBinary = path.resolve(directory, packageJson.bin[command])
|
||||
const dependencies = packageJson.optionalDependencies ?? {}
|
||||
@@ -102,10 +95,7 @@ function packageNames() {
|
||||
? [`${base}-baseline`, base]
|
||||
: [base, `${base}-baseline`]
|
||||
: [base]
|
||||
// Windows 11 on ARM64 can still run the x64 binary under emulation when the native one fails.
|
||||
const x64 = base.replace(/-arm64$/, "-x64")
|
||||
const emulated = platform === "windows" && arch === "arm64" ? [`${x64}-baseline`, x64] : []
|
||||
return [...names, ...emulated].filter((name) => dependencies[name])
|
||||
return names.filter((name) => dependencies[name])
|
||||
}
|
||||
|
||||
function copyBinary(source) {
|
||||
@@ -137,15 +127,12 @@ function installPackage(name) {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
npm[0],
|
||||
"npm",
|
||||
[
|
||||
...npm.slice(1),
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--no-save",
|
||||
"--loglevel=error",
|
||||
// npm rejects a package built for another CPU, such as arm64 under emulated x64 Node, unless forced.
|
||||
...(name.includes(`-${platform}-${nodeArch}`) ? [] : ["--force"]),
|
||||
"--prefix",
|
||||
temp,
|
||||
`${name}@${dependencies[name]}`,
|
||||
|
||||
@@ -52,7 +52,7 @@ export const relocate = Effect.fnUntraced(function* (directory: string, destinat
|
||||
|
||||
/**
|
||||
* Whether the running binary was installed by a package manager or the curl installer, the installs
|
||||
* whose upgrades replace it in place. The updater decides the same question from the install location.
|
||||
* bun can hang on. The updater decides the same question from the package manifest it already reads.
|
||||
*/
|
||||
export function installed(home: string) {
|
||||
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
|
||||
@@ -60,7 +60,6 @@ export function installed(home: string) {
|
||||
const executable = path.resolve(process.execPath)
|
||||
return (
|
||||
executable.split(path.sep).includes("node_modules") ||
|
||||
/[\\/]winget[\\/]packages[\\/]/i.test(executable) ||
|
||||
executable === path.resolve(home, ".opencode", "bin", "opencode.exe")
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { RetainedImage } from "./retained-image"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew", "winget"] as const
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
@@ -53,20 +53,8 @@ const installNames: Record<Method, string> = {
|
||||
yarn: "Yarn",
|
||||
vp: "Vite+",
|
||||
brew: "Homebrew",
|
||||
winget: "WinGet",
|
||||
}
|
||||
|
||||
const wingetPackage = "Anomaly.OpenCode"
|
||||
const wingetArgs = [
|
||||
"--id",
|
||||
wingetPackage,
|
||||
"--exact",
|
||||
"--source",
|
||||
"winget",
|
||||
"--accept-source-agreements",
|
||||
"--disable-interactivity",
|
||||
]
|
||||
|
||||
function conciseDetail(input: string) {
|
||||
const lines = stripVTControlCharacters(input)
|
||||
.trim()
|
||||
@@ -158,8 +146,6 @@ const make = Effect.gen(function* () {
|
||||
const installedFormula = ["opencode", "opencode-beta", "opencode-v2"].find((name) =>
|
||||
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
|
||||
)
|
||||
// WinGet extracts portable packages into a directory named after the package identifier.
|
||||
const installedWinget = /[\\/]winget[\\/]packages[\\/]anomaly\.opencode_/i.test(executable)
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
@@ -214,7 +200,6 @@ const make = Effect.gen(function* () {
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
if (executable === curlBinary) return "curl"
|
||||
if (installedFormula) return "brew"
|
||||
if (installedWinget) return "winget"
|
||||
if (!installedPackage) return
|
||||
|
||||
const parts = executable.toLowerCase().split(path.sep).map((part) => part.replace(/^\./, ""))
|
||||
@@ -244,8 +229,7 @@ const make = Effect.gen(function* () {
|
||||
})?.check.method
|
||||
})
|
||||
|
||||
const removalCommand = (method: Method) => {
|
||||
if (method === "winget") return ["winget", "uninstall", ...wingetArgs]
|
||||
const removal = (method: Method) => {
|
||||
if (method === "curl" || method === "brew" || !installedPackage) return undefined
|
||||
const commands = {
|
||||
npm: ["npm", "uninstall", "--global", installedPackage],
|
||||
@@ -254,12 +238,7 @@ const make = Effect.gen(function* () {
|
||||
yarn: ["yarn", "global", "remove", installedPackage],
|
||||
vp: ["vp", "uninstall", "-g", installedPackage],
|
||||
}
|
||||
return commands[method]
|
||||
}
|
||||
|
||||
const removal = (method: Method) => {
|
||||
const command = removalCommand(method)
|
||||
if (!command) return undefined
|
||||
const command = commands[method]
|
||||
return {
|
||||
command,
|
||||
run: retaining(
|
||||
@@ -330,39 +309,10 @@ const make = Effect.gen(function* () {
|
||||
return { package: packageName, version }
|
||||
})
|
||||
|
||||
// WinGet moderates every submission, so only its own index knows which release it can install.
|
||||
const wingetVersion = Effect.fnUntraced(function* () {
|
||||
const failure = (detail: string, cause?: unknown) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not check for OpenCode updates",
|
||||
detail,
|
||||
command: ["winget", "search", ...wingetArgs].join(" "),
|
||||
retry: "Fix the issue above, then run opencode upgrade again.",
|
||||
},
|
||||
cause === undefined ? undefined : { cause },
|
||||
)
|
||||
const result = yield* exec(["winget", "search", ...wingetArgs], "1 minute").pipe(
|
||||
Effect.mapError((cause) => failure(errorDetail(cause), cause)),
|
||||
)
|
||||
// Column headers are localized, but the result row always lists the identifier followed by the version.
|
||||
const version = result.stdout
|
||||
.split(/\r?\n|\r/)
|
||||
.map((line) => line.trim().split(/\s+/))
|
||||
.flatMap((tokens) => {
|
||||
const index = tokens.findIndex((token) => token.toLowerCase() === wingetPackage.toLowerCase())
|
||||
return index === -1 ? [] : tokens.slice(index + 1, index + 2)
|
||||
})
|
||||
.find((token) => parseReleaseVersion(token))
|
||||
if (result.code !== 0 || !version) return yield* Effect.fail(failure(resultDetail(result)))
|
||||
return version
|
||||
})
|
||||
|
||||
const latest = (selected?: Method) =>
|
||||
(selected ? Effect.succeed(selected) : method()).pipe(
|
||||
Effect.flatMap((method) =>
|
||||
method === "winget" ? wingetVersion() : release(method).pipe(Effect.map((data) => data.version)),
|
||||
),
|
||||
Effect.flatMap(release),
|
||||
Effect.map((data) => data.version),
|
||||
)
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
@@ -376,12 +326,7 @@ const make = Effect.gen(function* () {
|
||||
const retaining = <A, E, R>(method: Method, effect: Effect.Effect<A, E, R>, directory = global.cache) => {
|
||||
if (process.platform !== "win32" || method === "brew") return effect
|
||||
// Only the installed binary is at stake; source checkouts run inside bun or node.
|
||||
const owned =
|
||||
method === "curl"
|
||||
? executable === curlBinary
|
||||
: method === "winget"
|
||||
? installedWinget
|
||||
: installedPackage !== undefined
|
||||
const owned = method === "curl" ? executable === curlBinary : installedPackage !== undefined
|
||||
if (!owned) return effect
|
||||
return Effect.scoped(RetainedImage.retain(directory, "upgrade").pipe(Effect.andThen(effect))).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
@@ -426,16 +371,12 @@ const make = Effect.gen(function* () {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName =
|
||||
method === "winget"
|
||||
? wingetPackage
|
||||
: method === "brew" && installedFormula === "opencode"
|
||||
? "opencode"
|
||||
: (yield* release(method)).package
|
||||
method === "brew" && installedFormula === "opencode" ? "opencode" : (yield* release(method)).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew" | "winget">, string[]> = {
|
||||
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
@@ -495,22 +436,6 @@ const make = Effect.gen(function* () {
|
||||
)
|
||||
}
|
||||
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
|
||||
if (method === "winget")
|
||||
return yield* retaining(
|
||||
method,
|
||||
runUpgrade({
|
||||
method,
|
||||
command: [
|
||||
"winget",
|
||||
"upgrade",
|
||||
...wingetArgs,
|
||||
"--version",
|
||||
version,
|
||||
"--silent",
|
||||
"--accept-package-agreements",
|
||||
],
|
||||
}),
|
||||
)
|
||||
return yield* retaining(method, runUpgrade({ method, command: commands[method] }))
|
||||
}),
|
||||
).pipe(
|
||||
|
||||
@@ -68,19 +68,6 @@ windows("installed accepts package-manager and curl binaries only", () =>
|
||||
const cases = [
|
||||
[path.join(home, ".bun", "install", "global", "node_modules", "@opencode", "cli", "bin", "opencode.exe"), true],
|
||||
[path.join(home, ".opencode", "bin", "opencode.exe"), true],
|
||||
[
|
||||
path.join(
|
||||
home,
|
||||
"AppData",
|
||||
"Local",
|
||||
"Microsoft",
|
||||
"WinGet",
|
||||
"Packages",
|
||||
"Anomaly.OpenCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
|
||||
"opencode.exe",
|
||||
),
|
||||
true,
|
||||
],
|
||||
[path.join(home, "AppData", "Local", "Programs", "OpenCode", "resources", "opencode-cli.exe"), false],
|
||||
[path.join(home, "node_modules", "bun", "bin", "bun.exe"), false],
|
||||
] as const
|
||||
|
||||
@@ -340,71 +340,6 @@ it.live("Homebrew Core installs check and upgrade the Core formula", () =>
|
||||
)
|
||||
})
|
||||
|
||||
const winget = (search: Partial<AppProcess.RunResult>) =>
|
||||
fixture(
|
||||
(command) => (command.args[0] === "search" ? search : {}),
|
||||
"@opencode/cli",
|
||||
false,
|
||||
"@opencode/cli",
|
||||
undefined,
|
||||
(root) =>
|
||||
Effect.succeed(
|
||||
path.join(
|
||||
root,
|
||||
"AppData",
|
||||
"Local",
|
||||
"Microsoft",
|
||||
"WinGet",
|
||||
"Packages",
|
||||
"Anomaly.OpenCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
|
||||
"opencode.exe",
|
||||
),
|
||||
),
|
||||
)
|
||||
const wingetArgs = [
|
||||
"--id",
|
||||
"Anomaly.OpenCode",
|
||||
"--exact",
|
||||
"--source",
|
||||
"winget",
|
||||
"--accept-source-agreements",
|
||||
"--disable-interactivity",
|
||||
]
|
||||
|
||||
it.live("WinGet installs check, upgrade, and remove the version in the WinGet index", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* winget({
|
||||
stdout: Buffer.from(
|
||||
"Name Id Version\r\n------------------------------\r\nOpenCode Anomaly.OpenCode 2.0.28\r\n",
|
||||
),
|
||||
})
|
||||
expect(yield* test.updater.method()).toBe("winget")
|
||||
expect(yield* test.updater.latest()).toBe("2.0.28")
|
||||
yield* test.updater.upgrade("winget", "v2.0.28")
|
||||
const removal = test.updater.removal("winget")
|
||||
if (!removal) return yield* Effect.die("Expected winget removal command")
|
||||
yield* removal.run
|
||||
expect(test.commands).toEqual([
|
||||
["winget", "search", ...wingetArgs],
|
||||
["winget", "upgrade", ...wingetArgs, "--version", "2.0.28", "--silent", "--accept-package-agreements"],
|
||||
["winget", "uninstall", ...wingetArgs],
|
||||
])
|
||||
// WinGet moderates submissions, so the OpenCode release service may list a version WinGet cannot install yet.
|
||||
expect(test.requests).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("WinGet update checks report a missing package instead of a version", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* winget({
|
||||
exitCode: -1978335212,
|
||||
stdout: Buffer.from("No package found matching input criteria.\r\n"),
|
||||
})
|
||||
const error = yield* test.updater.latest().pipe(Effect.flip)
|
||||
expect(error.message).toBe("No package found matching input criteria.")
|
||||
}),
|
||||
)
|
||||
|
||||
// Links are named opencode-upgrade-<pid>-<random>.exe; read them from inside the installer run.
|
||||
const links = (directory: string) =>
|
||||
existsSync(directory) ? readdirSync(directory).filter((name) => name.startsWith("opencode-")) : []
|
||||
|
||||
@@ -630,15 +630,6 @@ export type ModelCompatibility = {
|
||||
supportsEffortUpdates?: boolean
|
||||
}
|
||||
|
||||
export type ProviderCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
requireReasoning?: boolean
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
supportsPromptCacheKey?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
@@ -896,6 +887,18 @@ export type ProviderRequest = {
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: ProviderID
|
||||
canonical?: ProviderID
|
||||
integrationID?: IntegrationID
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionMessageUser = {
|
||||
@@ -1909,19 +1912,6 @@ export type CredentialKey = {
|
||||
configuration?: FormAnswer
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: ProviderID
|
||||
canonical?: ProviderID
|
||||
integrationID?: IntegrationID
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compatibility?: ProviderCompatibility
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type PtyCreated = {
|
||||
@@ -2251,7 +2241,6 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
compatibility?: ProviderCompatibility
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
|
||||
@@ -77,12 +77,6 @@ export default function Download() {
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
<button data-component="cli-row" onClick={handleCopyClick("winget install --id Anomaly.OpenCode -e")}>
|
||||
<code>
|
||||
winget install --id <strong>Anomaly.OpenCode</strong> -e
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
<button data-component="cli-row" onClick={handleCopyClick("paru -S opencode-beta")}>
|
||||
<code>
|
||||
paru -S <strong>opencode-beta</strong>
|
||||
@@ -109,15 +103,6 @@ export default function Download() {
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
<button
|
||||
data-component="cli-row"
|
||||
onClick={handleCopyClick("winget install --id Anomaly.OpenCode.Desktop -e")}
|
||||
>
|
||||
<code>
|
||||
winget install --id <strong>Anomaly.OpenCode.Desktop</strong> -e
|
||||
</code>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
<div data-component="download-row">
|
||||
<div data-component="download-info">
|
||||
<span data-slot="icon">
|
||||
|
||||
@@ -88,9 +88,6 @@ export default function Home() {
|
||||
<Tabs.Trigger value="brew" data-slot="tab">
|
||||
brew
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="winget" data-slot="tab">
|
||||
winget
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="paru" data-slot="tab">
|
||||
paru
|
||||
</Tabs.Trigger>
|
||||
@@ -138,16 +135,6 @@ export default function Home() {
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content as="pre" data-slot="panel" value="winget">
|
||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||
<span>
|
||||
<span data-slot="protocol">winget install --id </span>
|
||||
<span data-slot="highlight">Anomaly.OpenCode</span>
|
||||
<span> -e</span>
|
||||
</span>
|
||||
<CopyStatus />
|
||||
</button>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content as="pre" data-slot="panel" value="paru">
|
||||
<button data-copy data-slot="command" onClick={handleCopyClick}>
|
||||
<span>
|
||||
|
||||
@@ -68,8 +68,6 @@ export const Plugin = define({
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compatibility !== undefined)
|
||||
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
|
||||
@@ -201,10 +201,6 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
compatibility:
|
||||
provider?.compatibility === undefined
|
||||
? model.compatibility
|
||||
: { ...provider.compatibility, ...model.compatibility },
|
||||
settings: Provider.mergeOverlay(
|
||||
Provider.modelSettings(provider?.settings),
|
||||
Provider.modelSettings(model.settings),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
// Chat-completions `prompt_cache_key` body support for catalog providers
|
||||
// without a dedicated AI package. Audited per provider (docs + OpenAPI +
|
||||
@@ -24,13 +25,14 @@ const providerIDs = [
|
||||
export const PromptCacheKeyPlugin = define({
|
||||
id: "opencode.provider.prompt-cache-key",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
// Set on the provider so configured provider and model compatibility can override it.
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const id of providerIDs) {
|
||||
if (!providers.get(id)) continue
|
||||
providers.update(id, (provider) => {
|
||||
provider.compatibility = { ...provider.compatibility, supportsPromptCacheKey: true }
|
||||
})
|
||||
const providerID = Provider.ID.make(id)
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.compatibility = { ...draft.compatibility, supportsPromptCacheKey: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -398,19 +398,17 @@ export const layer = Layer.effect(
|
||||
retry: (event: Parameters<Prepared["retry"]>[0]) =>
|
||||
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
|
||||
// Permission.assert and the question tool throw declines as defects so tools cannot
|
||||
// catch them and turn a "no" into model-visible output. Recover them here as failures,
|
||||
// including declines a tool rethrew and its wrapper attached to a Tool.Error, as Promise
|
||||
// plugin tools do.
|
||||
// catch them and turn a "no" into model-visible output. Recover them here as failures.
|
||||
executeTool: (call: Parameters<Prepared["executeTool"]>[0]) =>
|
||||
tools.execute({ ...call, definitions: hooked }).pipe(
|
||||
Effect.catchCauseFilter(
|
||||
(cause) => {
|
||||
const decline = cause.reasons
|
||||
.map((r) => (Cause.isDieReason(r) ? r.defect : Cause.isFailReason(r) ? r.error.error : undefined))
|
||||
.find(
|
||||
(value): value is Permission.DeclinedError | QuestionTool.CancelledError =>
|
||||
value instanceof Permission.DeclinedError || value instanceof QuestionTool.CancelledError,
|
||||
)
|
||||
const decline = cause.reasons.flatMap((r) =>
|
||||
Cause.isDieReason(r) &&
|
||||
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
|
||||
? [r.defect]
|
||||
: [],
|
||||
)[0]
|
||||
return decline ? Result.succeed(decline) : Result.fail(cause)
|
||||
},
|
||||
(decline) => Effect.fail(decline),
|
||||
|
||||
@@ -110,14 +110,11 @@ export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
|
||||
}
|
||||
|
||||
function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
||||
// V1 `setCacheKey` toggled `prompt_cache_key` for the whole provider; it is not a provider package setting.
|
||||
const { setCacheKey, ...rest } = info.options ?? {}
|
||||
const options = ConfigProviderOptionsV1.provider(rest)
|
||||
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
|
||||
return {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
compatibility: setCacheKey === undefined ? undefined : { supportsPromptCacheKey: setCacheKey },
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
|
||||
@@ -614,23 +614,6 @@ describe("ConfigNormalize", () => {
|
||||
expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "untouched", "compatibility"])
|
||||
})
|
||||
|
||||
test("migrates the legacy setCacheKey provider option into provider compatibility", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
proxy: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "https://proxy.test/v1", setCacheKey: true } },
|
||||
openai: { options: { setCacheKey: false } },
|
||||
untouched: { options: { baseURL: "https://untouched.test/v1" } },
|
||||
},
|
||||
})
|
||||
expect(result.encoded.providers).toMatchObject({
|
||||
proxy: { compatibility: { supportsPromptCacheKey: true }, settings: { baseURL: "https://proxy.test/v1" } },
|
||||
openai: { compatibility: { supportsPromptCacheKey: false } },
|
||||
})
|
||||
expect(result.encoded.providers).not.toHaveProperty(["proxy", "settings", "setCacheKey"])
|
||||
expect(result.encoded.providers).not.toHaveProperty(["openai", "settings", "setCacheKey"])
|
||||
expect(result.encoded.providers).not.toHaveProperty(["untouched", "compatibility"])
|
||||
})
|
||||
|
||||
test("invalid legacy provider overlays skip only that provider", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
|
||||
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
|
||||
import { PromptCacheKeyPlugin } from "@opencode/core/plugin/provider/prompt-cache-key"
|
||||
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
@@ -76,143 +75,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps provider and model compatibility precedence across config documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("proxy")
|
||||
yield* providers.transform((editor) => {
|
||||
editor.update(providerID, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/openai/chat"
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("catalog"), () => {})
|
||||
})
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
proxy: {
|
||||
// Model-version features are model-only and are not accepted on the provider.
|
||||
compatibility: { requireFinishReason: false, supportsEffortUpdates: false },
|
||||
models: { claude: { compatibility: { supportsPromptCacheKey: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
proxy: {
|
||||
compatibility: { supportsPromptCacheKey: false },
|
||||
models: { alias: { modelID: "catalog" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* providers.get(providerID))?.compatibility).toEqual({
|
||||
requireFinishReason: false,
|
||||
supportsPromptCacheKey: false,
|
||||
})
|
||||
// A later provider default does not override an earlier model setting.
|
||||
expect(required(yield* models.get(providerID, Model.ID.make("claude"))).compatibility).toEqual({
|
||||
requireFinishReason: false,
|
||||
supportsPromptCacheKey: true,
|
||||
})
|
||||
// A model first defined in a later document still inherits earlier provider values.
|
||||
expect(required(yield* models.get(providerID, Model.ID.make("alias"))).compatibility).toEqual({
|
||||
requireFinishReason: false,
|
||||
supportsPromptCacheKey: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets configured provider compatibility override built-in provider defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("venice")
|
||||
yield* providers.transform((editor) => {
|
||||
editor.update(providerID, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/openai-compatible"
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("chat"), () => {})
|
||||
editor.models.update(providerID, Model.ID.make("keyed"), () => {})
|
||||
})
|
||||
yield* PromptCacheKeyPlugin.effect(yield* PluginHost.make(plugin))
|
||||
expect(required(yield* models.get(providerID, Model.ID.make("chat"))).compatibility).toEqual({
|
||||
supportsPromptCacheKey: true,
|
||||
})
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
venice: {
|
||||
compatibility: { supportsPromptCacheKey: false },
|
||||
models: { keyed: { compatibility: { supportsPromptCacheKey: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
expect(required(yield* models.get(providerID, Model.ID.make("chat"))).compatibility).toEqual({
|
||||
supportsPromptCacheKey: false,
|
||||
})
|
||||
expect(required(yield* models.get(providerID, Model.ID.make("keyed"))).compatibility).toEqual({
|
||||
supportsPromptCacheKey: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies provider compatibility to every provider model with model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("proxy")
|
||||
yield* providers.transform((editor) => {
|
||||
editor.update(providerID, (provider) => {
|
||||
provider.package = "@opencode/ai/providers/openai/chat"
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("catalog"), (model) => {
|
||||
model.compatibility = { maxTokensField: "max_tokens" }
|
||||
})
|
||||
})
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
proxy: {
|
||||
compatibility: { supportsPromptCacheKey: false, requireFinishReason: false },
|
||||
models: {
|
||||
configured: { compatibility: { supportsPromptCacheKey: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
const catalog = required(yield* models.get(providerID, Model.ID.make("catalog")))
|
||||
const configured = required(yield* models.get(providerID, Model.ID.make("configured")))
|
||||
expect(catalog.compatibility).toEqual({
|
||||
maxTokensField: "max_tokens",
|
||||
supportsPromptCacheKey: false,
|
||||
requireFinishReason: false,
|
||||
})
|
||||
expect(configured.compatibility).toEqual({ supportsPromptCacheKey: true, requireFinishReason: false })
|
||||
// The OpenAI chat package enables the key by default; provider config overrides it on the runtime model.
|
||||
expect((yield* ModelResolver.fromCatalogModel(catalog)).compatibility?.supportsPromptCacheKey).toBe(false)
|
||||
expect((yield* ModelResolver.fromCatalogModel(configured)).compatibility?.supportsPromptCacheKey).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps the provider websocket policy out of model settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { PluginPromise } from "@opencode/core/plugin/promise"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -54,85 +53,3 @@ it.live("Promise tool executors receive interruption through their AbortSignal",
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
const execute = (snapshot: Tool.Snapshot, name: string) =>
|
||||
snapshot
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_promise_tool_error"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_promise_tool_error"),
|
||||
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
|
||||
it.live("Promise tool throws become tool failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
yield* PluginPromise.fromPromise({
|
||||
id: "throwing-tools",
|
||||
async setup(context) {
|
||||
await context.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "plain",
|
||||
description: "Throw an Error",
|
||||
input: { type: "object", properties: {}, additionalProperties: false },
|
||||
options: { codemode: false },
|
||||
execute: async () => {
|
||||
throw new Error("plain failure")
|
||||
},
|
||||
})
|
||||
editor.add({
|
||||
name: "typed",
|
||||
description: "Throw a Tool.Error",
|
||||
input: { type: "object", properties: {}, additionalProperties: false },
|
||||
options: { codemode: false },
|
||||
execute: async () => {
|
||||
throw new Tool.Error({ message: "typed failure", metadata: { code: 7 } })
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
}).effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const snapshot = yield* tools.snapshot()
|
||||
const plain = yield* execute(snapshot, "plain")
|
||||
const typed = yield* execute(snapshot, "typed")
|
||||
expect(Option.getOrUndefined(Exit.findErrorOption(plain))).toMatchObject({ message: "plain failure" })
|
||||
expect(Option.getOrUndefined(Exit.findErrorOption(typed))).toMatchObject({
|
||||
message: "typed failure",
|
||||
metadata: { code: 7 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("Promise tools that rethrow a decline attach it to the tool failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const declined = new Permission.DeclinedError()
|
||||
yield* tools.transform((editor) =>
|
||||
editor.add({
|
||||
name: "guarded",
|
||||
description: "Decline every call",
|
||||
input: { type: "object", properties: {}, additionalProperties: false },
|
||||
options: { codemode: false },
|
||||
execute: () => Effect.die(declined),
|
||||
}),
|
||||
)
|
||||
yield* PluginPromise.fromPromise({
|
||||
id: "wrapping-tool",
|
||||
async setup(context) {
|
||||
await context.tool.transform((editor) =>
|
||||
editor.update("guarded", (tool) => {
|
||||
const original = tool.execute
|
||||
tool.execute = (input, context) => original(input, context)
|
||||
}),
|
||||
)
|
||||
},
|
||||
}).effect(yield* PluginHost.make(plugins))
|
||||
|
||||
const exit = yield* execute(yield* tools.snapshot(), "guarded")
|
||||
expect(Option.getOrUndefined(Exit.findErrorOption(exit))?.error).toBe(declined)
|
||||
}),
|
||||
)
|
||||
@@ -4922,14 +4922,6 @@ describe("SessionRunnerLLM", () => {
|
||||
output: Schema.Struct({}),
|
||||
execute: () => Effect.die(new Permission.DeclinedError()),
|
||||
},
|
||||
wrapped: {
|
||||
name: "wrapped",
|
||||
description: "Fail with a decline attached to a tool error",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: () =>
|
||||
Effect.fail(new Tool.Error({ message: "Wrapped decline", error: new Permission.DeclinedError() })),
|
||||
},
|
||||
},
|
||||
{ codemode: false },
|
||||
)
|
||||
@@ -4939,7 +4931,6 @@ describe("SessionRunnerLLM", () => {
|
||||
TestLLM.toolCalls(
|
||||
LLMEvent.toolCall({ id: "call-failed", name: "failed", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
|
||||
LLMEvent.toolCall({ id: "call-wrapped", name: "wrapped", input: {} }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4956,10 +4947,6 @@ describe("SessionRunnerLLM", () => {
|
||||
{ id: "call-declined" },
|
||||
{ error: { type: "aborted", message: "The user declined this tool call" } },
|
||||
),
|
||||
Expected.failedTool(
|
||||
{ id: "call-wrapped" },
|
||||
{ error: { type: "aborted", message: "The user declined this tool call" } },
|
||||
),
|
||||
]),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -622,17 +622,10 @@ function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
|
||||
type RuntimeSchema = Schema.Codec<unknown, unknown>
|
||||
|
||||
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
signal,
|
||||
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
|
||||
}),
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof Tool.Error) return Effect.fail(error)
|
||||
return Effect.fail(new Tool.Error({ message: error instanceof Error ? error.message : String(error), error }))
|
||||
Effect.promise((signal) =>
|
||||
tool.execute(input, {
|
||||
...context,
|
||||
signal,
|
||||
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
|
||||
}),
|
||||
)
|
||||
@@ -14403,9 +14403,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
@@ -17721,30 +17718,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Provider.Compatibility": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
"requireFinishReason": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.ID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17770,9 +17743,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ConfigProvider from "./provider.js"
|
||||
import { Schema } from "effect"
|
||||
import { Money } from "../money.js"
|
||||
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
|
||||
import { ProviderCompatibility } from "../model-compatibility.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { optional } from "../schema.js"
|
||||
|
||||
@@ -96,8 +95,6 @@ export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
// Defaults for every model of this provider; model compatibility overrides individual fields.
|
||||
compatibility: ProviderCompatibility.pipe(optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(optional),
|
||||
}) {}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Schema, Struct } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
// Shared by model and provider schemas; kept separate because model.ts imports provider.ts.
|
||||
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
|
||||
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
|
||||
Schema.String,
|
||||
]).annotate({ identifier: "Model.ReasoningField" })
|
||||
|
||||
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
|
||||
identifier: "Model.MaxTokensField",
|
||||
})
|
||||
export type MaxTokensField = typeof MaxTokensField.Type
|
||||
|
||||
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
|
||||
export const Compatibility = Schema.Struct({
|
||||
reasoningField: ReasoningField.pipe(optional),
|
||||
/** Require every assistant message to include its reasoning field, even when empty. */
|
||||
requireReasoning: Schema.Boolean.pipe(optional),
|
||||
maxTokensField: MaxTokensField.pipe(optional),
|
||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
||||
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
|
||||
supportsPromptCacheKey: Schema.Boolean.pipe(optional),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
supportsThinkingBlockBinding: Schema.Boolean.pipe(optional),
|
||||
/** Supports per-message effort updates. Overrides model-ID detection. */
|
||||
supportsEffortUpdates: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
// Provider defaults inherited by its models. Model-version features stay model-only.
|
||||
export interface ProviderCompatibility extends Schema.Schema.Type<typeof ProviderCompatibility> {}
|
||||
export const ProviderCompatibility = Schema.Struct(
|
||||
Struct.omit(Compatibility.fields, ["supportsThinkingBlockBinding", "supportsEffortUpdates"]),
|
||||
).annotate({ identifier: "Provider.Compatibility" })
|
||||
@@ -3,7 +3,6 @@ export * as Model from "./model.js"
|
||||
import { Schema } from "effect"
|
||||
import { optional, statics } from "./schema.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { Compatibility, MaxTokensField, ReasoningField } from "./model-compatibility.js"
|
||||
import { Money } from "./money.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
|
||||
@@ -43,11 +42,20 @@ export const Ref = Schema.Struct({
|
||||
)
|
||||
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
|
||||
|
||||
export { Compatibility, MaxTokensField, ReasoningField }
|
||||
|
||||
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
|
||||
export type Family = typeof Family.Type
|
||||
|
||||
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
|
||||
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
|
||||
Schema.String,
|
||||
]).annotate({ identifier: "Model.ReasoningField" })
|
||||
|
||||
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
|
||||
identifier: "Model.MaxTokensField",
|
||||
})
|
||||
export type MaxTokensField = typeof MaxTokensField.Type
|
||||
|
||||
export const Settings = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
@@ -63,6 +71,21 @@ export const Overlays = {
|
||||
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),
|
||||
/** Require every assistant message to include its reasoning field, even when empty. */
|
||||
requireReasoning: Schema.Boolean.pipe(optional),
|
||||
maxTokensField: MaxTokensField.pipe(optional),
|
||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
||||
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
|
||||
supportsPromptCacheKey: Schema.Boolean.pipe(optional),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
supportsThinkingBlockBinding: Schema.Boolean.pipe(optional),
|
||||
/** Supports per-message effort updates. Overrides model-ID detection. */
|
||||
supportsEffortUpdates: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as Provider from "./provider.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Integration } from "./integration.js"
|
||||
import { ProviderCompatibility } from "./model-compatibility.js"
|
||||
import { optional, statics } from "./schema.js"
|
||||
import { ephemeral, inventory } from "./event.js"
|
||||
|
||||
@@ -79,8 +78,6 @@ export const Info = Schema.Struct({
|
||||
name: Schema.String,
|
||||
activation: Activation,
|
||||
package: Package,
|
||||
/** Inherited by every model of this provider; model compatibility overrides individual fields. */
|
||||
compatibility: ProviderCompatibility.pipe(optional),
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { Script } from "@opencode/script"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
if (Script.channel !== "latest" || !/^\d+\.\d+\.\d+$/.test(Script.version)) {
|
||||
throw new Error("WinGet publishing requires a latest release version")
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL("..", import.meta.url))
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
const token = process.env.WINGET_GITHUB_TOKEN
|
||||
const cli = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(root, "packages", "cli", "dist"))
|
||||
const desktop = process.env.OPENCODE_DESKTOP_DIST
|
||||
if (!desktop) throw new Error("OPENCODE_DESKTOP_DIST is required")
|
||||
const outdir = path.join(cli, "winget")
|
||||
const manifestVersion = "1.12.0"
|
||||
|
||||
const packages = [
|
||||
{
|
||||
identifier: "Anomaly.OpenCode",
|
||||
name: "OpenCode CLI",
|
||||
moniker: "opencode",
|
||||
description: "The open source AI coding agent for the terminal.",
|
||||
installer: [
|
||||
"InstallerType: zip",
|
||||
"NestedInstallerType: portable",
|
||||
"NestedInstallerFiles:",
|
||||
"- RelativeFilePath: opencode.exe",
|
||||
" PortableCommandAlias: opencode",
|
||||
"Commands:",
|
||||
"- opencode",
|
||||
],
|
||||
files: [
|
||||
{ architecture: "x64", file: path.join(cli, "opencode-windows-x64-baseline.zip") },
|
||||
{ architecture: "arm64", file: path.join(cli, "opencode-windows-arm64.zip") },
|
||||
],
|
||||
},
|
||||
{
|
||||
identifier: "Anomaly.OpenCode.Desktop",
|
||||
// Matches the display name the installer registers with Windows, without its version suffix.
|
||||
name: "OpenCode",
|
||||
moniker: "opencode-desktop",
|
||||
description: "The open source AI coding agent as a desktop app.",
|
||||
installer: [
|
||||
"InstallerType: nullsoft",
|
||||
"Scope: user",
|
||||
"UpgradeBehavior: install",
|
||||
// electron-builder derives the NSIS uninstall key from the production app ID, ai.opencode.desktop.
|
||||
"ProductCode: d074f30d-5f88-5885-b075-be1348cc7676",
|
||||
"Protocols:",
|
||||
"- opencode",
|
||||
],
|
||||
files: [
|
||||
{ architecture: "x64", file: path.join(desktop, "opencode-desktop-win-x64.exe") },
|
||||
{ architecture: "arm64", file: path.join(desktop, "opencode-desktop-win-arm64.exe") },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await rm(outdir, { recursive: true, force: true })
|
||||
const prepared = await Promise.all(packages.map(prepare))
|
||||
prepared.forEach((item) => console.log(`Prepared ${item.identifier} ${Script.version} in ${item.directory}`))
|
||||
if (dryRun) process.exit(0)
|
||||
if (!token) {
|
||||
console.log("skipped WinGet submission: WINGET_GITHUB_TOKEN is not set")
|
||||
process.exit(0)
|
||||
}
|
||||
for (const item of prepared) await submit(item)
|
||||
|
||||
async function prepare(input: (typeof packages)[number]) {
|
||||
const directory = path.join(outdir, input.identifier)
|
||||
const header = (type: string) => [
|
||||
`# yaml-language-server: $schema=https://aka.ms/winget-manifest.${type}.${manifestVersion}.schema.json`,
|
||||
"",
|
||||
`PackageIdentifier: ${input.identifier}`,
|
||||
`PackageVersion: ${Script.version}`,
|
||||
]
|
||||
const footer = (type: string) => [`ManifestType: ${type}`, `ManifestVersion: ${manifestVersion}`, ""]
|
||||
const installers = await Promise.all(
|
||||
input.files.map(async (item) => {
|
||||
const file = Bun.file(item.file)
|
||||
if (!(await file.exists()) || !file.size) throw new Error(`Missing WinGet installer: ${item.file}`)
|
||||
const sha256 = new Bun.CryptoHasher("sha256")
|
||||
for await (const chunk of file.stream()) sha256.update(chunk)
|
||||
return [
|
||||
`- Architecture: ${item.architecture}`,
|
||||
` InstallerUrl: https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${path.basename(item.file)}`,
|
||||
` InstallerSha256: ${sha256.digest("hex").toUpperCase()}`,
|
||||
]
|
||||
}),
|
||||
)
|
||||
const manifests = {
|
||||
[`${input.identifier}.yaml`]: [...header("version"), "DefaultLocale: en-US", ...footer("version")],
|
||||
[`${input.identifier}.installer.yaml`]: [
|
||||
...header("installer"),
|
||||
...input.installer,
|
||||
`ReleaseDate: ${new Date().toISOString().slice(0, 10)}`,
|
||||
"Installers:",
|
||||
...installers.flat(),
|
||||
...footer("installer"),
|
||||
],
|
||||
[`${input.identifier}.locale.en-US.yaml`]: [
|
||||
...header("defaultLocale"),
|
||||
"PackageLocale: en-US",
|
||||
// Matches the publisher the desktop installer registers with Windows.
|
||||
"Publisher: OpenCode",
|
||||
"PublisherUrl: https://anoma.ly/",
|
||||
"PublisherSupportUrl: https://github.com/anomalyco/opencode/issues",
|
||||
`PackageName: ${input.name}`,
|
||||
"PackageUrl: https://opencode.ai/",
|
||||
"License: MIT",
|
||||
"LicenseUrl: https://github.com/anomalyco/opencode/blob/HEAD/LICENSE",
|
||||
`ShortDescription: ${input.description}`,
|
||||
`Moniker: ${input.moniker}`,
|
||||
"Tags:",
|
||||
"- ai",
|
||||
"- coding-agent",
|
||||
"- developer-tools",
|
||||
"Documentations:",
|
||||
"- DocumentLabel: Docs",
|
||||
" DocumentUrl: https://opencode.ai/docs/",
|
||||
...footer("defaultLocale"),
|
||||
],
|
||||
}
|
||||
const files = Object.fromEntries(Object.entries(manifests).map(([name, lines]) => [name, lines.join("\n")]))
|
||||
await mkdir(directory, { recursive: true })
|
||||
await Promise.all(Object.entries(files).map(([name, content]) => Bun.write(path.join(directory, name), content)))
|
||||
return { identifier: input.identifier, directory, files }
|
||||
}
|
||||
|
||||
// Opens the pull request from the token owner's fork, the only way to contribute to microsoft/winget-pkgs.
|
||||
async function submit(input: Awaited<ReturnType<typeof prepare>>) {
|
||||
const upstream = "microsoft/winget-pkgs"
|
||||
const folder = ["manifests", "a", ...input.identifier.split("."), Script.version].join("/")
|
||||
const branch = `${input.identifier}-${Script.version}`
|
||||
if ((await github(`/repos/${upstream}/contents/${folder}`)).status === 200) {
|
||||
console.log(`${input.identifier} ${Script.version} is already in ${upstream}`)
|
||||
return
|
||||
}
|
||||
const fork: { owner: { login: string }; full_name: string } = await json(
|
||||
await github(`/repos/${upstream}/forks`, { method: "POST", body: { default_branch_only: true } }),
|
||||
)
|
||||
const pulls: unknown[] = await json(
|
||||
await github(`/repos/${upstream}/pulls?state=all&head=${encodeURIComponent(`${fork.owner.login}:${branch}`)}`),
|
||||
)
|
||||
if (pulls.length) {
|
||||
console.log(`${input.identifier} ${Script.version} already has a pull request`)
|
||||
return
|
||||
}
|
||||
// Forks share objects with their upstream, so the branch can start from upstream master without syncing the fork.
|
||||
const head: { object: { sha: string } } = await json(await github(`/repos/${upstream}/git/ref/heads/master`))
|
||||
const base: { tree: { sha: string } } = await json(await github(`/repos/${upstream}/git/commits/${head.object.sha}`))
|
||||
const tree: { sha: string } = await json(
|
||||
await github(`/repos/${fork.full_name}/git/trees`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
base_tree: base.tree.sha,
|
||||
tree: Object.entries(input.files).map(([name, content]) => ({
|
||||
path: `${folder}/${name}`,
|
||||
mode: "100644",
|
||||
type: "blob",
|
||||
content,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const title = `New version: ${input.identifier} version ${Script.version}`
|
||||
const commit: { sha: string } = await json(
|
||||
await github(`/repos/${fork.full_name}/git/commits`, {
|
||||
method: "POST",
|
||||
body: { message: title, tree: tree.sha, parents: [head.object.sha] },
|
||||
}),
|
||||
)
|
||||
const created = await github(`/repos/${fork.full_name}/git/refs`, {
|
||||
method: "POST",
|
||||
body: { ref: `refs/heads/${branch}`, sha: commit.sha },
|
||||
})
|
||||
// A retried release can find the branch from an attempt that failed before opening its pull request.
|
||||
await json(
|
||||
created.status === 422
|
||||
? await github(`/repos/${fork.full_name}/git/refs/heads/${branch}`, {
|
||||
method: "PATCH",
|
||||
body: { sha: commit.sha, force: true },
|
||||
})
|
||||
: created,
|
||||
)
|
||||
const run = process.env.GITHUB_RUN_ID
|
||||
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
|
||||
: undefined
|
||||
const pull: { html_url: string } = await json(
|
||||
await github(`/repos/${upstream}/pulls`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
title,
|
||||
head: `${fork.owner.login}:${branch}`,
|
||||
base: "master",
|
||||
body: `Published by the [OpenCode](https://github.com/anomalyco/opencode) release workflow${run ? ` in ${run}` : ""}.`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
console.log(`Opened ${pull.html_url}`)
|
||||
}
|
||||
|
||||
function github(route: string, init?: { method?: string; body?: unknown }) {
|
||||
return fetch(`https://api.github.com${route}`, {
|
||||
method: init?.method ?? "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
})
|
||||
}
|
||||
|
||||
async function json(response: Response) {
|
||||
if (!response.ok) throw new Error(`GitHub ${response.url} returned ${response.status}: ${await response.text()}`)
|
||||
return response.json()
|
||||
}
|
||||
@@ -116,10 +116,3 @@ if (Script.release) {
|
||||
console.log("\n=== desktop ===\n")
|
||||
await $`bun ./packages/desktop/scripts/publish.ts`
|
||||
}
|
||||
|
||||
if (Script.release && Script.channel === "latest") {
|
||||
console.log("\n=== winget ===\n")
|
||||
// Everything else is published by now, and WinGet validation can reject a submission for reasons unrelated to the release.
|
||||
if ((await $`bun ./script/publish-winget.ts`.nothrow()).exitCode !== 0)
|
||||
console.log("::warning::WinGet submission failed; see the winget step output above")
|
||||
}
|
||||
@@ -14403,9 +14403,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
@@ -17721,30 +17718,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Provider.Compatibility": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
"requireFinishReason": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.ID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17770,9 +17743,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
|
||||
@@ -14403,9 +14403,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Config.Provider.Settings"
|
||||
},
|
||||
@@ -17721,30 +17718,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Provider.Compatibility": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reasoningField": {
|
||||
"$ref": "#/components/schemas/Model.ReasoningField"
|
||||
},
|
||||
"requireReasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxTokensField": {
|
||||
"$ref": "#/components/schemas/Model.MaxTokensField"
|
||||
},
|
||||
"requireFinishReason": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"requireAssistantAfterTool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"supportsPromptCacheKey": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Provider.ID": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17770,9 +17743,6 @@
|
||||
"package": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"$ref": "#/components/schemas/Provider.Compatibility"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/Provider.Settings"
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ OpenCode is an open source AI coding agent. It’s available as a terminal-based
|
||||
tabs={[
|
||||
{ label: "curl", code: "curl -fsSL https://opencode.ai/v2/install | bash" },
|
||||
{ label: "homebrew", code: "brew install anomalyco/tap/opencode-v2" },
|
||||
{ label: "winget", code: "winget install --id Anomaly.OpenCode -e" },
|
||||
{ label: "npm", code: "npm install -g @opencode/cli" },
|
||||
{ label: "bun", code: "bun install -g --trust @opencode/cli" },
|
||||
{ label: "pnpm", code: "pnpm add -g --allow-build=@opencode/cli @opencode/cli" },
|
||||
@@ -26,6 +25,8 @@ OpenCode is an open source AI coding agent. It’s available as a terminal-based
|
||||
The npm package uses a postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm commands
|
||||
explicitly allow the required script; Vite+ requires no additional flag.
|
||||
|
||||
Windows package managers are not supported.
|
||||
|
||||
|
||||
Download a standalone CLI binary for your platform.
|
||||
|
||||
@@ -44,12 +45,6 @@ Download the latest OpenCode Desktop build for your platform.
|
||||
- **Linux (.rpm):** [x64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-x86_64.rpm) · [ARM64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-aarch64.rpm)
|
||||
- **Linux (AppImage):** [x64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-x86_64.AppImage) · [ARM64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-arm64.AppImage)
|
||||
|
||||
On Windows, you can also install it with WinGet.
|
||||
|
||||
```powershell
|
||||
winget install --id Anomaly.OpenCode.Desktop -e
|
||||
```
|
||||
|
||||
|
||||
## Web
|
||||
|
||||
|
||||
@@ -358,8 +358,7 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
|
||||
```
|
||||
|
||||
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
|
||||
`options` are separated into `settings`, `headers`, and `body` according to their request role. `options.setCacheKey`
|
||||
becomes `compatibility.supportsPromptCacheKey` on the provider. See [Providers](/providers).
|
||||
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
|
||||
|
||||
V2 consolidated two legacy provider namespaces:
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ The `providers` object is keyed by the provider ID used in model references, suc
|
||||
| `env` | Ordered environment variable names that can provide the credential. |
|
||||
| `package` | Runtime provider package. |
|
||||
| `canonical` | Built-in provider ID whose catalog defaults this provider inherits. |
|
||||
| `compatibility` | Request and response compatibility overrides for every model of this provider. Model `compatibility` overrides individual fields. `supportsThinkingBlockBinding` and `supportsEffortUpdates` are model-only. |
|
||||
| `settings` | Typed OpenCode controls and JSON options passed to the runtime package. |
|
||||
| `headers` | String-valued HTTP headers added to requests. |
|
||||
| `body` | JSON fields merged into request bodies. |
|
||||
|
||||
Reference in new issue
Block a user