Compare commits

..
Author SHA1 Message Date
iamdavidhill 5272d4bfdd fix(stats): show one decimal for trillion-token usage 2026-09-28 16:01:06 +00:00
27 changed files with 61 additions and 376 deletions
-20
View File
@@ -24,10 +24,6 @@ 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 }}
@@ -657,19 +653,3 @@ 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 }}
+2 -3
View File
@@ -129,9 +129,8 @@ 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 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.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -63,7 +63,6 @@ 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>
@@ -71,7 +70,6 @@ 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>
@@ -94,7 +92,6 @@ 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
+5 -10
View File
@@ -142,37 +142,32 @@ 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,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
}),
)
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({
+1 -2
View File
@@ -39,18 +39,17 @@ 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,10 +26,8 @@ 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,6 +92,5 @@ 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.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
})
+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, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
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, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
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, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
+1 -15
View File
@@ -11,10 +11,6 @@ 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* () {
@@ -50,15 +46,6 @@ 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>()
@@ -94,8 +81,7 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+2 -6
View File
@@ -135,7 +135,6 @@ 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 +
@@ -494,8 +493,7 @@ 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
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -622,9 +620,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
// 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) {
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
+8 -13
View File
@@ -63,7 +63,6 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -102,11 +101,14 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
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 }
}
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")
if (explicit)
return {
session: input.fork
@@ -131,13 +133,6 @@ 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,28 +42,6 @@ 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"))
+9 -19
View File
@@ -1,7 +1,6 @@
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"
@@ -61,24 +60,15 @@ 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,
// 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,
}),
),
)
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,
}),
),
)
return response.text
})
+3 -3
View File
@@ -273,12 +273,12 @@ export const layer = Layer.effect(
model: model.model,
http: {
headers: {
"x-session-affinity": affinity,
"X-Session-Id": affinity,
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": affinity,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
},
+1 -56
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -11,7 +10,6 @@ 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({
@@ -91,56 +89,3 @@ 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": parentID,
"X-Session-Id": parentID,
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": parentID,
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
+1 -6
View File
@@ -4693,12 +4693,7 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* s.runPrompt("Run child request")
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]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
})
+4 -3
View File
@@ -24,6 +24,7 @@ import { findModelCatalogEntry, loadModelCatalog, type ModelCatalog } from "./mo
import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "./geo-map"
import { SectionHeading } from "./section-heading"
import { setStatsPageCacheHeaders } from "./stats-cache"
import { formatTrillionTokens } from "./token-format"
import { ComparisonCardsSection, uniqueComparisonPairs, type ComparisonModelRef } from "./compare-cards"
import {
applyThemePreference,
@@ -703,7 +704,7 @@ function usageTotal(point: UsagePoint) {
}
function formatTokens(value: number) {
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 1)}T`
if (value >= 1) return formatTrillionTokens(value)
return `${Math.round(value * 1000)}B`
}
@@ -832,7 +833,7 @@ function getProviderIconId(author: string) {
}
function formatBillions(value: number) {
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}T`
if (value >= 1000) return formatTrillionTokens(value / 1000)
return `${value}B`
}
@@ -1334,7 +1335,7 @@ function formatTrillions(value: number) {
if (value === 0) return "0"
if (value < 0.001) return `${Number((value * 1_000_000).toFixed(value >= 0.00001 ? 0 : 1))}M`
if (value < 1) return `${Number((value * 1_000).toFixed(value >= 0.01 ? 0 : 1))}B`
return `${value.toFixed(value >= 10 ? 0 : 1)}T`
return formatTrillionTokens(value)
}
function formatMarketDate(day: MarketDay | undefined, fallback: string) {
@@ -0,0 +1,9 @@
import { expect, test } from "bun:test"
import { formatTrillionTokens } from "./token-format"
test("formats trillion-token usage with one decimal, including whole totals", () => {
expect(formatTrillionTokens(1)).toBe("1.0T")
expect(formatTrillionTokens(9.15)).toBe("9.2T")
expect(formatTrillionTokens(12)).toBe("12.0T")
expect(formatTrillionTokens(21.34)).toBe("21.3T")
})
@@ -0,0 +1,3 @@
export function formatTrillionTokens(value: number) {
return `${value.toFixed(1)}T`
}
@@ -208,8 +208,6 @@ 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,7 +53,6 @@ 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"
@@ -207,7 +206,6 @@ 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()
@@ -1231,9 +1229,7 @@ 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: {
@@ -1242,7 +1238,6 @@ 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 -16
View File
@@ -1,4 +1,3 @@
import { mergeProps } from "solid-js"
import { createSimpleContext } from "./helper"
export interface Args {
@@ -7,25 +6,11 @@ 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) => {
// 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
},
})
},
init: (props: Args) => props,
})
+2 -3
View File
@@ -65,13 +65,12 @@ export function Home() {
untrack(() => composer.set(prompt))
})
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
// Wait for the model store to be ready before auto-submitting --prompt.
createEffect(() => {
const r = ref()
if (sent) return
if (!r) return
if (!local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!local.model.ready) return
if (!args.prompt) return
if (r.current.text !== args.prompt) return
sent = true
-55
View File
@@ -876,61 +876,6 @@ 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
@@ -1,100 +0,0 @@
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"])
})
+2 -4
View File
@@ -61,9 +61,7 @@ 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")
const releaseNotes = notes[1].trim()
if (!releaseNotes) throw new Error("Release review has no changelog")
await Bun.write(changelog, `${releaseNotes}\n`)
await Bun.write(changelog, `${notes[1].trim()}\n`)
const answer = prompt(`Trigger the ${version} release? [y/N]`)
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
@@ -71,7 +69,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
process.exit(0)
}
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
await $`gh workflow run publish.yml --ref v2 ${input}`
console.log(`Triggered the ${version} release`)
async function generateReview(base: string) {