mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-26 18:34:42 +08:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c949b0356 | |||
| 7827dbe396 | |||
| 5f9ced439b | |||
| 8c1ce954d0 | |||
| 07338c5d48 | |||
| 46e53e3f2b | |||
| 6cf442b545 | |||
| f20f5b68ee | |||
| 96dd9f77a9 |
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user