Compare commits

..

9 Commits

Author SHA1 Message Date
Aiden Cline 4c949b0356 fix(core): map v1 setCacheKey to compatibility
V1 provider.options.setCacheKey was passed through as an inert provider setting, so the prompt cache key was never sent for providers that opt in (and could not be disabled for providers that default it on). Lower it to compatibility.supportsPromptCacheKey on the provider, add provider-level compatibility to the config schema, and apply it to every model of the provider with model-level compatibility taking precedence.
2026-09-28 17:58:56 -05:00
Kit Langton 7827dbe396 fix(tui): deduplicate projects in the open picker (#51924) 2026-09-28 18:13:00 -04:00
James Long 5f9ced439b fix(cli): drop misleading interruption errors after declined prompts in run (#51948) 2026-09-28 17:58:04 -04:00
Aiden Cline 8c1ce954d0 fix(tui): wait for agent and model before auto-submitting --prompt (#51938) 2026-09-28 15:41:14 -05:00
ARH 07338c5d48 feat(cli): create the session when --session names one that does not exist (#51405)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-28 15:09:03 -05:00
James Long 46e53e3f2b fix(ai): expose evaluation confidence (#51930) 2026-09-28 15:24:21 -04:00
Aiden Cline 6cf442b545 fix(core): share child session affinity headers (#51923) 2026-09-28 13:31:17 -05:00
opencode-agent[bot] f20f5b68ee chore(release): announce V2 releases in Discord (#51880)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 12:30:55 -05:00
Sebastian 96dd9f77a9 fix(core): attribute one-shot generation requests (#48358)
Co-authored-by: OpenCode Agent <opencode-agent[bot]@users.noreply.github.com>
2026-09-28 11:55:14 -05:00
33 changed files with 504 additions and 88 deletions
+20
View File
@@ -24,6 +24,10 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -653,3 +657,19 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+3 -2
View File
@@ -129,8 +129,9 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -63,6 +63,7 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -70,6 +71,7 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -92,6 +94,7 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+10 -5
View File
@@ -142,32 +142,37 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
+2 -1
View File
@@ -39,17 +39,18 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
+2
View File
@@ -26,8 +26,10 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
@@ -92,5 +92,6 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
})
@@ -1,42 +0,0 @@
# RFC: The `.exe` name of the npm-installed CLI on Unix
Status: Proposed decision record (2026-09-28)
## Problem
A Linux user can run `opencode` and see `opencode.exe` in a process list. That looks like a Windows program, even though the running file is a native Linux ELF executable. The suffix is a pathname chosen by our npm packaging, not the executable format or a compatibility layer.
This RFC records a deliberate *non-change*: why that pathname exists and the cost of changing it. It does not propose changing the binary in this PR.
## How it works today
- The published `@opencode/cli` package has a single `bin` target, `./bin/opencode.exe`, for Linux, macOS, and Windows. The same target also serves the `opencode2` command. See [`script/publish.ts`](../script/publish.ts).
- Each platform-specific package contains a native binary: `opencode` on Linux/macOS and `opencode.exe` on Windows. The generic package's [`postinstall.mjs`](../script/postinstall.mjs) selects the appropriate optional dependency and hard-links or copies its binary to that *same* `bin` target. Therefore, on Unix, a native executable ends up at a pathname ending in `.exe`.
- The direct [install script](../../../install) and release archives keep the Unix executable named `opencode`. This is specific to the generic npm package; the public command users type remains `opencode`.
The constraint is the combination of **one cross-platform npm package, one static `bin` target, and direct execution of the native binary without a launcher**. Windows needs an executable target there, while the generic package cannot select a different `bin` path per OS at install time. Choosing `opencode.exe` lets the Windows target be a real executable and lets npm expose the same command everywhere. Unix permits an ELF or Mach-O file to have that suffix, but process tools can then display it.
The package also ships a text placeholder at the target path until postinstall replaces it. A skipped postinstall is a *separate functional install problem*; removing the misleading Unix suffix alone would not resolve that failure mode.
## Other npm CLIs
These are different choices, checked against their published npm packages on 2026-09-28:
- [Pi (`@earendil-works/pi-coding-agent@0.87.1`)](https://registry.npmjs.org/%40earendil-works%2Fpi-coding-agent/0.87.1) maps `pi` to a JavaScript entrypoint, `dist/bundle/cli.js`, run with Node. It is not trying to expose one direct native binary through npm.
- [Codex (`@openai/codex@0.158.0`)](https://registry.npmjs.org/%40openai%2Fcodex/0.158.0) maps `codex` to `bin/codex.js`. That Node launcher selects `codex` on Unix or `codex.exe` on Windows from a platform package, spawns it, and handles signals and exit status. This avoids a `.exe`-named Unix process at the cost of a launcher process.
- [Claude Code (`@anthropic-ai/claude-code@2.1.283`)](https://registry.npmjs.org/%40anthropic-ai%2Fclaude-code/2.1.283) makes the same direct-execution choice as OpenCode: its npm `bin` is `bin/claude.exe` on all platforms, with postinstall replacing the placeholder with the native binary. Unix users can see `.exe` in the pathname there too.
- [esbuild (`esbuild@0.28.2`)](https://registry.npmjs.org/esbuild/0.28.2) uses an extensionless JavaScript `bin/esbuild` that locates the native binary. Its postinstall *sometimes* replaces that entrypoint with a Unix native binary for direct execution; Windows keeps the launcher. This avoids the Unix suffix and often the extra Unix process, but adds platform- and package-manager-dependent behavior and still needs a Windows launcher.
## Options
1. **Keep the direct native target (recommended for now).** No new process, signal forwarding, runtime dependency at launch, or install-path migration. Cost: Unix process lists can say `opencode.exe`, which is confusing. Explain the packaging trade-off when asked.
2. **Use a platform-aware launcher as the npm `bin` target.** It could select `opencode` on Unix and `opencode.exe` on Windows from the platform-specific package, so the child process has its native filename. Cost: an additional process or platform-specific `exec` behavior, startup work, signal and exit-code forwarding, and a larger test surface across package managers. A launcher already exists for development in [`bin/opencode.cjs`](../bin/opencode.cjs); using one in release packaging would be a deliberate change to today's direct-execution behavior, not a rename.
3. **Use an esbuild-style hybrid launcher.** Publish a suffix-free JavaScript entrypoint; replace it with the native executable on Unix where safe, but keep the launcher on Windows and as a fallback. This improves the Unix process name and often avoids a second Unix process, but complicates postinstall, upgrades, cross-platform startup, and script-disabled behavior. It still crosses the no-launcher boundary on Windows.
4. **Publish different top-level packages or mutate package-manager links per OS.** This can give Unix a suffix-free target without a launcher, but loses the simple single-package install or makes startup depend on nonportable install/link behavior. Do not assume rewriting `package.json` in postinstall will reliably relink a previously created command.
5. **Change the displayed process title.** This might cosmetically affect some monitors, but leaves the actual filename and packaging contract unchanged. It is not a fix for the underlying discrepancy.
## Proposal and decision boundary
**Proposed decision: do not add a launcher or rename the npm target just to change the process-list label.** Keep the existing packaging until the name causes enough user confusion to justify a packaging change, or a broader install/reliability redesign makes the launcher cost worthwhile. Treat a skipped-postinstall/broken-upgrade fix on its own merits rather than claiming the filename change fixes it. This RFC is a record for review, not approval or implementation of a launcher.
If we choose an alternative, prove it against the current behavior before release: native binary format and process name on Linux and macOS; `.exe` startup on Windows; npm, Bun, pnpm, and Yarn installs; blocked or skipped install scripts; upgrades; exit codes and signals; and both `opencode` and the legacy `opencode2` command. Keep direct-install and archive filenames unchanged unless there is a separate reason to alter them.
+3 -3
View File
@@ -51,7 +51,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
@@ -328,7 +328,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
@@ -368,7 +368,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
+15 -1
View File
@@ -11,6 +11,10 @@ import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { findSession } from "../../session-target"
import { errorMessage } from "../../util/error"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -46,6 +50,15 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const session = Option.getOrUndefined(input.session)
// A missing --session ID becomes the ID of the session the first prompt creates.
const sessionExists =
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
@@ -81,7 +94,8 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+7 -2
View File
@@ -135,6 +135,7 @@ export async function runNonInteractivePrompt(input: Input) {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -163,6 +164,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
if (input.compatibility !== "v1") process.exitCode = 1
}
const consume = async () => {
@@ -493,7 +495,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -620,7 +623,9 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
// A declined tool call ends its step with an interrupted-step error that is
// only a consequence of our own rejection; it was already reported above.
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
+13 -8
View File
@@ -63,6 +63,7 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -101,14 +102,11 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
if (input.session && !explicit) {
if (input.fork) throw new Error("Session not found")
return { session: undefined }
}
if (explicit)
return {
session: input.fork
@@ -133,6 +131,13 @@ async function selectSession(input: {
}
}
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
throw error
})
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
+22
View File
@@ -42,6 +42,28 @@ describe("session target resolver", () => {
})
})
test("creates a missing explicit Session with its ID", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
})
test("does not create a missing explicit Session to fork", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
const create = spyOn(client.session, "create")
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
"Session not found",
)
expect(create).not.toHaveBeenCalled()
})
test("paginates to continue the exact directory", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
@@ -2117,6 +2117,7 @@ export type ConfigEntry =
name?: string
env?: Array<string>
package?: string
compatibility?: ModelCompatibility
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
@@ -99,6 +99,13 @@ export const Plugin = define({
if (configuredDefault !== undefined) models.default.set(configuredDefault.providerID, configuredDefault.model)
for (const [item, definition] of sources.models) {
const providerID = definition.providerID
if (item.compatibility !== undefined)
for (const model of models.list(providerID)) {
if (Object.hasOwn(item.models ?? {}, model.id)) continue
models.update(providerID, model.id, (draft) => {
draft.compatibility = { ...draft.compatibility, ...item.compatibility }
})
}
for (const [id, config] of Object.entries(item.models ?? {})) {
const source = definition.models.get(id)
const inherit = source?.inherit || !models.get(providerID, id)
@@ -111,6 +118,8 @@ export const Plugin = define({
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (item.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...item.compatibility }
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
+19 -9
View File
@@ -1,6 +1,7 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -60,15 +61,24 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
+3 -3
View File
@@ -273,12 +273,12 @@ export const layer = Layer.effect(
model: model.model,
http: {
headers: {
"x-session-affinity": session.id,
"X-Session-Id": session.id,
"x-session-affinity": affinity,
"X-Session-Id": affinity,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-session": affinity,
"x-opencode-client": app.name,
},
},
+1
View File
@@ -250,6 +250,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
compatibility: options.compatibility,
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
headers: info.options && options.headers,
body: info.options && options.body,
@@ -1,17 +1,22 @@
export * as ConfigProviderOptionsV1 from "./provider-options.js"
import type { Model } from "@opencode/schema/model"
type Options = Readonly<Record<string, unknown>>
export interface ProviderResult {
readonly settings: Record<string, unknown>
readonly headers?: Record<string, string>
readonly body?: Record<string, unknown>
readonly compatibility?: Model.Compatibility
}
export function provider(options: Options): ProviderResult {
const headers = options.headers
const body = options.body
const settings = Object.fromEntries(Object.entries(options).filter(([key]) => key !== "headers" && key !== "body"))
const settings = Object.fromEntries(
Object.entries(options).filter(([key]) => key !== "headers" && key !== "body" && key !== "setCacheKey"),
)
const headerOverlay =
typeof headers === "object" && headers !== null && !Array.isArray(headers)
? Object.fromEntries(
@@ -23,6 +28,10 @@ export function provider(options: Options): ProviderResult {
settings,
headers: headerOverlay,
body: bodyOverlay,
// V1 `setCacheKey` was a provider-wide switch for sending the prompt cache key; false also disabled the
// built-in defaults for OpenAI and xAI.
compatibility:
typeof options.setCacheKey === "boolean" ? { supportsPromptCacheKey: options.setCacheKey } : undefined,
}
}
+16
View File
@@ -835,6 +835,22 @@ describe("Config", () => {
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
})
test("migrates v1 setCacheKey to provider compatibility", () => {
const migrated = ConfigMigrateV1.migrate({
provider: {
enabled: { options: { setCacheKey: true, baseURL: "https://enabled.example/v1" } },
disabled: { options: { setCacheKey: false } },
unset: { options: { baseURL: "https://unset.example/v1" } },
},
})
expect(migrated.providers?.enabled?.compatibility).toEqual({ supportsPromptCacheKey: true })
expect(migrated.providers?.enabled?.settings).toEqual({ baseURL: "https://enabled.example/v1" })
expect(migrated.providers?.disabled?.compatibility).toEqual({ supportsPromptCacheKey: false })
expect(migrated.providers?.disabled?.settings).toEqual({})
expect(migrated.providers?.unset?.compatibility).toBeUndefined()
})
for (const subtask of [true, false]) {
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
expect(
@@ -24,6 +24,19 @@ describe("ConfigProviderOptionsV1", () => {
})
})
test("lowers setCacheKey to prompt cache key compatibility", () => {
expect(ConfigProviderOptionsV1.provider({ apiKey: "secret", setCacheKey: true })).toEqual({
settings: { apiKey: "secret" },
headers: undefined,
body: undefined,
compatibility: { supportsPromptCacheKey: true },
})
expect(ConfigProviderOptionsV1.provider({ setCacheKey: false }).compatibility).toEqual({
supportsPromptCacheKey: false,
})
expect(ConfigProviderOptionsV1.provider({ apiKey: "secret" }).compatibility).toBeUndefined()
})
test("keeps model and variant options unchanged", () => {
expect(
ConfigProviderOptionsV1.model({
@@ -420,6 +420,84 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("applies provider compatibility to every model unless the model overrides it", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("custom")
yield* providers.transform((editor) => {
editor.models.update(providerID, Model.ID.make("catalog"), (model) => {
model.compatibility = { requireFinishReason: true }
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("catalog"), () => {})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
custom: {
compatibility: { supportsPromptCacheKey: true },
models: {
inherited: {},
overridden: { compatibility: { supportsPromptCacheKey: false } },
},
},
},
}),
}),
])
expect((yield* models.get(providerID, Model.ID.make("catalog")))?.compatibility).toEqual({
requireFinishReason: true,
supportsPromptCacheKey: true,
})
expect((yield* models.get(providerID, Model.ID.make("inherited")))?.compatibility).toEqual({
supportsPromptCacheKey: true,
})
expect((yield* models.get(providerID, Model.ID.make("overridden")))?.compatibility).toEqual({
supportsPromptCacheKey: false,
})
expect((yield* models.get(Provider.ID.make("other"), Model.ID.make("catalog")))?.compatibility).toBeUndefined()
}),
)
it.effect("maps migrated v1 setCacheKey onto provider models", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
yield* providers.transform((editor) => {
editor.models.update(Provider.ID.make("enabled"), Model.ID.make("catalog"), () => {})
editor.models.update(Provider.ID.make("disabled"), Model.ID.make("catalog"), (model) => {
model.compatibility = { supportsPromptCacheKey: true }
})
editor.models.update(Provider.ID.make("unset"), Model.ID.make("catalog"), () => {})
})
const result = ConfigNormalize.normalize({
provider: {
enabled: { options: { setCacheKey: true }, models: { chat: {} } },
disabled: { options: { setCacheKey: false } },
unset: { options: { baseURL: "https://unset.example/v1" } },
},
})
if (result.type !== "normalized") throw new Error("Expected normalized config")
expect(result.diagnostics).toEqual([])
yield* addPlugin([new Document({ type: "document", info: decode(result.encoded) })])
const compatibility = (providerID: string, modelID: string) =>
models
.get(Provider.ID.make(providerID), Model.ID.make(modelID))
.pipe(Effect.map((model) => model?.compatibility?.supportsPromptCacheKey))
expect(yield* compatibility("enabled", "catalog")).toBe(true)
expect(yield* compatibility("enabled", "chat")).toBe(true)
expect(yield* compatibility("disabled", "catalog")).toBe(false)
expect(yield* compatibility("unset", "catalog")).toBeUndefined()
expect((yield* providers.get(Provider.ID.make("enabled")))?.settings).not.toHaveProperty("setCacheKey")
}),
)
it.effect("generates variants after rewriting a configured model package", () =>
Effect.gen(function* () {
const models = yield* Model.Service
+56 -1
View File
@@ -1,5 +1,6 @@
import { expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -10,6 +11,7 @@ import { ID, Info, Model, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -89,3 +91,56 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)
@@ -445,12 +445,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(parentID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-parent-session-id": parentID,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": sessionID,
"x-opencode-session": parentID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
+6 -1
View File
@@ -4693,7 +4693,12 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* s.runPrompt("Run child request")
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
expect(s.requests[0]?.http?.headers).toMatchObject({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-parent-session-id": parentID,
"x-opencode-session": parentID,
})
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
})
+2
View File
@@ -87,6 +87,8 @@ 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),
/** Compatibility defaults for every model of this provider. Model-level compatibility takes precedence. */
compatibility: Compatibility.pipe(optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
}) {}
@@ -208,6 +208,8 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const projectOptions = data.project
.list()
.filter((project) => project.canonical !== "/")
// Historical project identities can share a checkout. The list is newest-active first.
.filter((project, index, projects) => projects.findIndex((item) => item.canonical === project.canonical) === index)
.map((project) => ({ directory: project.canonical, project }))
.map((item) => {
const title =
@@ -53,6 +53,7 @@ import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { useArgs } from "../../context/args"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
@@ -206,6 +207,7 @@ export function Prompt(props: PromptProps) {
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const args = useArgs()
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
@@ -1229,7 +1231,9 @@ export function Prompt(props: PromptProps) {
// a local session record synchronously, so the navigation below happens
// immediately — enter feels sent even while the create round-trip is in
// flight. Sends against the new session gate on the request.
const newSessionID = args.takeNewSessionID()
const created = data.session.create({
id: newSessionID,
location: directory ? { directory } : location,
agent: agent.id,
model: {
@@ -1238,6 +1242,7 @@ export function Prompt(props: PromptProps) {
variant,
},
})
if (newSessionID !== undefined) created.request.catch(() => args.restoreNewSessionID(newSessionID))
sessionID = created.id
session = data.session.get(created.id)
newSession = {
+16 -1
View File
@@ -1,3 +1,4 @@
import { mergeProps } from "solid-js"
import { createSimpleContext } from "./helper"
export interface Args {
@@ -6,11 +7,25 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
newSessionID?: string
fork?: boolean
auto?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
name: "Args",
init: (props: Args) => props,
init: (props: Args) => {
// The first new session created from home takes this ID; later ones mint their own.
let pending = props.newSessionID
return mergeProps(props, {
takeNewSessionID() {
const id = pending
pending = undefined
return id
},
restoreNewSessionID(id: string) {
pending ??= id
},
})
},
})
+3 -2
View File
@@ -65,12 +65,13 @@ export function Home() {
untrack(() => composer.set(prompt))
})
// Wait for the model store to be ready before auto-submitting --prompt.
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
createEffect(() => {
const r = ref()
if (sent) return
if (!r) return
if (!local.model.ready) return
if (!local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!args.prompt) return
if (r.current.text !== args.prompt) return
sent = true
+55
View File
@@ -876,6 +876,61 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("home startup prompt is submitted exactly once", async () => {
await using state = await tmpdir()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd, canonical: cwd } }
const bodies: unknown[] = []
const submitted = Promise.withResolvers<void>()
let session: unknown
await using setup = await createAppFixture({
state: state.path,
args: { prompt: "HOME_READY" },
config: { animations: false, tabs: { mode: "off" } },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const input: unknown = await request.json()
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
session = {
...input,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
bodies.push(await request.json())
submitted.resolve()
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
return undefined
},
})
await setup.ready
await Promise.race([
submitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("startup prompt was not submitted")
}),
])
await Bun.sleep(20)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "HOME_READY" })
})
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
await using state = await tmpdir()
const target = fallback ? directory : process.cwd()
+100
View File
@@ -0,0 +1,100 @@
import { expect, test } from "bun:test"
import { createAppFixture } from "./fixture/app"
import { json } from "./fixture/tui-client"
type SessionInput = { id?: string; location?: { directory: string } }
const location = {
directory: "/tmp/opencode/packages/tui",
project: { id: "project", directory: "/tmp/opencode", canonical: "/tmp/opencode" },
}
function sessionInfo(record: SessionInput) {
return {
...record,
location: record.location ?? { directory: location.directory },
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
}
async function launch(options: { failCreates?: number } = {}) {
const attempts: (string | undefined)[] = []
const created: SessionInput[] = []
const prompts: string[] = []
let failures = options.failCreates ?? 0
const setup = await createAppFixture({
args: { newSessionID: "ses_chosen" },
config: { animations: false, keybinds: { "session.new": "f6" } },
fetch: async (url, request) => {
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const record: SessionInput = await request.json()
attempts.push(record.id)
if (failures > 0) {
failures--
return json({ message: "create failed" }, { status: 500 })
}
created.push(record)
return json({ data: sessionInfo(record) })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
prompts.push(url.pathname.split("/")[3] ?? "")
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (/^\/api\/session\/[^/]+\/(agent|model)$/.test(url.pathname)) return new Response(null, { status: 204 })
if (/^\/api\/session\/[^/]+$/.test(url.pathname)) {
const record = created.find((item) => item.id === url.pathname.split("/")[3])
if (!record) return json({ message: "not found" }, { status: 404 })
return json({ data: sessionInfo(record) })
}
return undefined
},
})
return { setup, attempts, created, prompts }
}
test("the first new session uses the launch session ID and later ones mint their own", async () => {
const run = await launch()
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.prompts.length === 1)
expect(run.prompts[0]).toBe("ses_chosen")
expect(run.created.map((item) => item.id)).toEqual(["ses_chosen"])
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("again")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 2)
expect(run.created[1]?.id).toMatch(/^ses/)
expect(run.created[1]?.id).not.toBe("ses_chosen")
})
test("a failed first create keeps the launch session ID for the retry", async () => {
const run = await launch({ failCreates: 1 })
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Creating a session failed") && frame.includes("hello"))
expect(run.attempts).toEqual(["ses_chosen"])
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 1)
expect(run.attempts).toEqual(["ses_chosen", "ses_chosen"])
})
+4 -2
View File
@@ -61,7 +61,9 @@ if ((await editor.exited) !== 0) {
const document = await Bun.file(review).text()
const notes = document.match(/<!-- changelog:start -->\s*([\s\S]*?)\s*<!-- changelog:end -->/)
if (!notes) throw new Error("Release review is missing its changelog markers")
await Bun.write(changelog, `${notes[1].trim()}\n`)
const releaseNotes = notes[1].trim()
if (!releaseNotes) throw new Error("Release review has no changelog")
await Bun.write(changelog, `${releaseNotes}\n`)
const answer = prompt(`Trigger the ${version} release? [y/N]`)
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
@@ -69,7 +71,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
process.exit(0)
}
await $`gh workflow run publish.yml --ref v2 ${input}`
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
console.log(`Triggered the ${version} release`)
async function generateReview(base: string) {