mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0308ec44a | |||
| 1fd817ffcf |
@@ -5,6 +5,7 @@ import { Service } from "@opencode-ai/client/effect/service"
|
|||||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||||
import { randomBytes } from "crypto"
|
import { randomBytes } from "crypto"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import semver from "semver"
|
||||||
import { selfCommand } from "../util/process"
|
import { selfCommand } from "../util/process"
|
||||||
|
|
||||||
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
|
// The CLI's service configuration file, plus the Service.EnsureOptions binding that
|
||||||
@@ -104,10 +105,21 @@ export const options = Effect.fnUntraced(function* () {
|
|||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
version: OPENCODE_VERSION,
|
version: OPENCODE_VERSION,
|
||||||
|
canReplace: (version: string | undefined) => canReplaceVersion(version),
|
||||||
command: [...selfCommand(), "serve", "--service"],
|
command: [...selfCommand(), "serve", "--service"],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export function canReplaceVersion(serverVersion: string | undefined, clientVersion = OPENCODE_VERSION) {
|
||||||
|
if (serverVersion === undefined) return true
|
||||||
|
// Preview versions end in `<channel>-<build>[.<attempt>]`. Convert the build
|
||||||
|
// to a numeric semver identifier so next-15000 sorts after next-9999.
|
||||||
|
const server = serverVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
|
||||||
|
const client = clientVersion.replace(/-(\d+)(?=(?:\.\d+)?$)/, ".$1")
|
||||||
|
if (!semver.valid(server) || !semver.valid(client)) return true
|
||||||
|
return semver.lt(server, client)
|
||||||
|
}
|
||||||
|
|
||||||
export const read = Effect.fn("cli.service-config.read")(function* () {
|
export const read = Effect.fn("cli.service-config.read")(function* () {
|
||||||
const { fs, configFile, legacyConfigFile } = yield* paths
|
const { fs, configFile, legacyConfigFile } = yield* paths
|
||||||
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
|
if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile)
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ test("service filenames share release channels and identify preview channels", (
|
|||||||
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
|
expect(ServiceConfig.versionBelongsToChannel("1.2.3", "preview-a")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("only newer clients replace managed service versions", () => {
|
||||||
|
expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.4")).toBe(true)
|
||||||
|
expect(ServiceConfig.canReplaceVersion("1.2.4", "1.2.3")).toBe(false)
|
||||||
|
expect(ServiceConfig.canReplaceVersion("1.2.3", "1.2.3")).toBe(false)
|
||||||
|
expect(ServiceConfig.canReplaceVersion("0.0.0-next-9999", "0.0.0-next-15000")).toBe(true)
|
||||||
|
expect(ServiceConfig.canReplaceVersion("0.0.0-next-15000", "0.0.0-next-9999")).toBe(false)
|
||||||
|
expect(ServiceConfig.canReplaceVersion(undefined, "1.2.3")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("service config migrates from the hashed channel filename", async () => {
|
test("service config migrates from the hashed channel filename", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-config-migration-"))
|
||||||
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
|
const legacy = path.join(root, ServiceConfig.legacyFilename("preview-a")!)
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
|||||||
import { spawn, type ChildProcess } from "node:child_process"
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
import {
|
||||||
|
VersionMismatchError,
|
||||||
|
type DiscoverOptions,
|
||||||
|
type Endpoint,
|
||||||
|
type EnsureOptions,
|
||||||
|
type StopOptions,
|
||||||
|
} from "../service.js"
|
||||||
|
|
||||||
export * from "../service.js"
|
export * from "../service.js"
|
||||||
/** Contents of the local service registration file. */
|
/** Contents of the local service registration file. */
|
||||||
@@ -56,7 +62,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
let announced = false
|
let announced = false
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
let spawnDelay = 5_000
|
let spawnDelay = 5_000
|
||||||
let ownerHeld = false
|
|
||||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (announced) return
|
if (announced) return
|
||||||
@@ -84,27 +89,27 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
|||||||
const info = registration.info
|
const info = registration.info
|
||||||
const service = registration.service
|
const service = registration.service
|
||||||
if (service !== undefined) {
|
if (service !== undefined) {
|
||||||
ownerHeld = false
|
|
||||||
spawnDelay = 5_000
|
spawnDelay = 5_000
|
||||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||||
if (compatible && service.state === "ready") return Option.some(service)
|
if (compatible && service.state === "ready") return Option.some(service)
|
||||||
if (compatible && service.state === "failed")
|
if (compatible && service.state === "failed")
|
||||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||||
if (compatible) return Option.none<LocalService>()
|
if (compatible) return Option.none<LocalService>()
|
||||||
|
if (options.canReplace?.(service.version) === false)
|
||||||
|
return yield* Effect.fail(new VersionMismatchError(options.version, service.version))
|
||||||
yield* announce("version-mismatch", service.version)
|
yield* announce("version-mismatch", service.version)
|
||||||
yield* kill(service, options).pipe(Effect.ignore)
|
yield* kill(service, options).pipe(Effect.ignore)
|
||||||
lastSpawn = 0
|
lastSpawn = 0
|
||||||
return Option.none<LocalService>()
|
return Option.none<LocalService>()
|
||||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||||
|
|
||||||
const failure = [...contenders].map(contenderFailure).find((error): error is Error => error !== undefined)
|
|
||||||
if (failure !== undefined) return yield* Effect.fail(failure)
|
|
||||||
const finished = [...contenders].filter(contenderFinished)
|
const finished = [...contenders].filter(contenderFinished)
|
||||||
|
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||||
ownerHeld = true
|
|
||||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||||
}
|
}
|
||||||
finished.forEach((item) => contenders.delete(item))
|
finished.forEach((item) => contenders.delete(item))
|
||||||
|
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||||
yield* announce("missing")
|
yield* announce("missing")
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { spawn, type ChildProcess } from "node:child_process"
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
import {
|
||||||
|
VersionMismatchError,
|
||||||
|
type DiscoverOptions,
|
||||||
|
type Endpoint,
|
||||||
|
type Info,
|
||||||
|
type EnsureOptions,
|
||||||
|
type StopOptions,
|
||||||
|
} from "../service.js"
|
||||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||||
|
|
||||||
export * from "../service.js"
|
export * from "../service.js"
|
||||||
@@ -37,7 +44,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
let announced = false
|
let announced = false
|
||||||
let lastSpawn = 0
|
let lastSpawn = 0
|
||||||
let spawnDelay = 5_000
|
let spawnDelay = 5_000
|
||||||
let ownerHeld = false
|
|
||||||
|
|
||||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||||
if (announced) return
|
if (announced) return
|
||||||
@@ -65,27 +71,27 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
|||||||
const registration = await registered(options.file, true)
|
const registration = await registered(options.file, true)
|
||||||
|
|
||||||
if (registration.service !== undefined) {
|
if (registration.service !== undefined) {
|
||||||
ownerHeld = false
|
|
||||||
spawnDelay = 5_000
|
spawnDelay = 5_000
|
||||||
const service = registration.service
|
const service = registration.service
|
||||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||||
if (compatible && service.state === "ready") return service.endpoint
|
if (compatible && service.state === "ready") return service.endpoint
|
||||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||||
if (!compatible) {
|
if (!compatible) {
|
||||||
|
if (options.canReplace?.(service.version) === false)
|
||||||
|
throw new VersionMismatchError(options.version, service.version)
|
||||||
announce("version-mismatch", service.version)
|
announce("version-mismatch", service.version)
|
||||||
await kill(service, options).catch(() => undefined)
|
await kill(service, options).catch(() => undefined)
|
||||||
lastSpawn = 0
|
lastSpawn = 0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||||
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined)
|
|
||||||
if (failure !== undefined) throw failure
|
|
||||||
const finished = [...contenders].filter(contenderFinished)
|
const finished = [...contenders].filter(contenderFinished)
|
||||||
|
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||||
ownerHeld = true
|
|
||||||
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
spawnDelay = Math.min(spawnDelay * 2, 30_000)
|
||||||
}
|
}
|
||||||
finished.forEach((item) => contenders.delete(item))
|
finished.forEach((item) => contenders.delete(item))
|
||||||
|
if (failure !== undefined && contenders.size === 0) throw failure
|
||||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||||
announce("missing")
|
announce("missing")
|
||||||
|
|||||||
@@ -28,10 +28,24 @@ export type EnsureReason = "missing" | "version-mismatch"
|
|||||||
export type EnsureOptions = DiscoverOptions & {
|
export type EnsureOptions = DiscoverOptions & {
|
||||||
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
/** Service command and arguments. Defaults to `opencode serve --service`. */
|
||||||
readonly command?: ReadonlyArray<string>
|
readonly command?: ReadonlyArray<string>
|
||||||
|
/** Decide whether a version-mismatched service may be replaced. Defaults to true. */
|
||||||
|
readonly canReplace?: (version: string | undefined) => boolean
|
||||||
/** Called once before spawning a new service process. */
|
/** Called once before spawning a new service process. */
|
||||||
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
|
readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A healthy service exists, but the caller's replacement policy protects it. */
|
||||||
|
export class VersionMismatchError extends Error {
|
||||||
|
override readonly name = "VersionMismatchError"
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly clientVersion: string | undefined,
|
||||||
|
readonly serverVersion: string | undefined,
|
||||||
|
) {
|
||||||
|
super(`Client version ${clientVersion ?? "unknown"} cannot replace server version ${serverVersion ?? "unknown"}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Options used to stop the local OpenCode service. */
|
/** Options used to stop the local OpenCode service. */
|
||||||
export type StopOptions = {
|
export type StopOptions = {
|
||||||
/** Absolute registration file path. Defaults to the XDG state directory. */
|
/** Absolute registration file path. Defaults to the XDG state directory. */
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ if (mode === "record-start") {
|
|||||||
}
|
}
|
||||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||||
|
|
||||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated") {
|
if (
|
||||||
|
mode === "delayed" ||
|
||||||
|
mode === "delayed-failed" ||
|
||||||
|
mode === "coordinated" ||
|
||||||
|
mode === "coordinated-failed-loser"
|
||||||
|
) {
|
||||||
await appendFile(registration + ".starts", process.pid + "\n")
|
await appendFile(registration + ".starts", process.pid + "\n")
|
||||||
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
if (!owner) process.exit()
|
if (!owner) process.exit(mode === "coordinated-failed-loser" ? 1 : 0)
|
||||||
if (mode === "coordinated") {
|
if (mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||||
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
|
while ((await Bun.file(registration + ".starts").text()).trim().split("\n").length < 2) await Bun.sleep(10)
|
||||||
|
if (mode === "coordinated-failed-loser") await Bun.sleep(1_500)
|
||||||
} else await Bun.sleep(Number(delay))
|
} else await Bun.sleep(Number(delay))
|
||||||
if (mode === "delayed-failed") process.exit(1)
|
if (mode === "delayed-failed") process.exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,24 @@ test("ensures a missing service with native promises", async () => {
|
|||||||
}
|
}
|
||||||
}, 15_000)
|
}, 15_000)
|
||||||
|
|
||||||
|
test("waits for a live contender when another native contender fails", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
|
||||||
|
const endpoint = await Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
|
||||||
|
})
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
try {
|
||||||
|
expect(endpoint.url).toBe(info.url)
|
||||||
|
} finally {
|
||||||
|
process.kill(info.pid, "SIGTERM")
|
||||||
|
await waitForExit(info.pid)
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
test("reports a failed registered service", async () => {
|
test("reports a failed registered service", async () => {
|
||||||
const registration = await setup("failed-owner")
|
const registration = await setup("failed-owner")
|
||||||
|
|
||||||
@@ -52,6 +70,25 @@ test("reports a failed registered service", async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not replace a version rejected by the caller", async () => {
|
||||||
|
const registration = await setup("graceful")
|
||||||
|
const directory = await temp()
|
||||||
|
const contender = join(directory, "contender.json")
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "old",
|
||||||
|
canReplace: () => false,
|
||||||
|
command: [process.execPath, fixture, contender, "record-start"],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Client version old cannot replace server version test")
|
||||||
|
|
||||||
|
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||||
|
expect(process.kill(info.pid, 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("requests graceful stop of the exact service instance", async () => {
|
test("requests graceful stop of the exact service instance", async () => {
|
||||||
const registration = await setup("graceful")
|
const registration = await setup("graceful")
|
||||||
const info = await Bun.file(registration).json()
|
const info = await Bun.file(registration).json()
|
||||||
|
|||||||
@@ -107,6 +107,28 @@ test("does not spawn contenders while an incompatible service rejects replacemen
|
|||||||
expect(existing.exitCode).toBe(null)
|
expect(existing.exitCode).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("does not replace a version rejected by the caller", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const contender = join(directory, "contender.json")
|
||||||
|
const existing = spawn(registration, "graceful")
|
||||||
|
await waitForFile(registration)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
run(
|
||||||
|
Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "old",
|
||||||
|
canReplace: () => false,
|
||||||
|
command: [process.execPath, fixture, contender, "record-start"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Client version old cannot replace server version test")
|
||||||
|
|
||||||
|
expect(await Bun.file(contender + ".started").exists()).toBe(false)
|
||||||
|
expect(existing.exitCode).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
test("a legacy health response is still replaced", async () => {
|
test("a legacy health response is still replaced", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
@@ -141,6 +163,24 @@ test("waits for a slow winner while bounding lock probes", async () => {
|
|||||||
}
|
}
|
||||||
}, 15_000)
|
}, 15_000)
|
||||||
|
|
||||||
|
test("waits for a live contender when another contender fails", async () => {
|
||||||
|
const directory = await temp()
|
||||||
|
const registration = join(directory, "service.json")
|
||||||
|
const endpoint = await run(
|
||||||
|
Service.ensure({
|
||||||
|
file: registration,
|
||||||
|
version: "test",
|
||||||
|
command: [process.execPath, fixture, registration, "coordinated-failed-loser"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const info = await Bun.file(registration).json()
|
||||||
|
try {
|
||||||
|
expect(endpoint.url).toBe(info.url)
|
||||||
|
} finally {
|
||||||
|
process.kill(info.pid, "SIGTERM")
|
||||||
|
}
|
||||||
|
}, 15_000)
|
||||||
|
|
||||||
test("reports a contender that fails to start", async () => {
|
test("reports a contender that fails to start", async () => {
|
||||||
const directory = await temp()
|
const directory = await temp()
|
||||||
const registration = join(directory, "service.json")
|
const registration = join(directory, "service.json")
|
||||||
|
|||||||
@@ -327,7 +327,10 @@ export function Prompt(props: PromptProps) {
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||||
if (agent && !args.agent) local.agent.set(agent.id)
|
if (agent && !args.agent) local.agent.set(agent.id)
|
||||||
if (!local.model.hydrate(session.model)) return
|
if (session.model) {
|
||||||
|
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
||||||
|
local.model.variant.set(session.model.variant)
|
||||||
|
}
|
||||||
syncedSessionID = sessionID
|
syncedSessionID = sessionID
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEvent } from "./event"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { useTuiPaths } from "./runtime"
|
import { useTuiPaths } from "./runtime"
|
||||||
import { useArgs } from "./args"
|
import { useArgs } from "./args"
|
||||||
|
import { useClient } from "./client"
|
||||||
import { RGBA } from "@opentui/core"
|
import { RGBA } from "@opentui/core"
|
||||||
import { readJson, writeJsonAtomic } from "../util/persistence"
|
import { readJson, writeJsonAtomic } from "../util/persistence"
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +48,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
name: "Local",
|
name: "Local",
|
||||||
init: () => {
|
init: () => {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const client = useClient()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const { mode } = useThemes()
|
const { mode } = useThemes()
|
||||||
@@ -208,47 +210,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function select(model: ModelPreferenceModel, options?: { recent?: boolean }) {
|
|
||||||
batch(() => {
|
|
||||||
if (!isModelValid(model)) return
|
|
||||||
const a = agent.current()
|
|
||||||
if (!a) return
|
|
||||||
setModelStore("model", a.id, model)
|
|
||||||
if (!options?.recent) return
|
|
||||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
|
||||||
save()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectVariant(value: string | undefined) {
|
|
||||||
const model = currentModel()
|
|
||||||
if (!model) return
|
|
||||||
const key = modelPreferenceKey(model)
|
|
||||||
const variant = normalizeModelVariant(value)
|
|
||||||
if (modelStore.variant[key] === variant) return
|
|
||||||
setModelStore("variant", key, variant)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
function matches(model?: { providerID: string; id: string }) {
|
|
||||||
if (!modelStore.ready) return false
|
|
||||||
const current = currentModel()
|
|
||||||
if (!current) return false
|
|
||||||
if (!model) return true
|
|
||||||
return current.providerID === model.providerID && current.modelID === model.id
|
|
||||||
}
|
|
||||||
|
|
||||||
function hydrate(model?: { providerID: string; id: string; variant?: string }) {
|
|
||||||
if (!modelStore.ready) return false
|
|
||||||
if (!model) return true
|
|
||||||
if (data.location.model.list() === undefined) return false
|
|
||||||
const selected = { providerID: model.providerID, modelID: model.id }
|
|
||||||
if (!isModelValid(selected)) return false
|
|
||||||
select(selected)
|
|
||||||
selectVariant(model.variant)
|
|
||||||
return matches(model)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
current: currentModel,
|
current: currentModel,
|
||||||
get ready() {
|
get ready() {
|
||||||
@@ -260,8 +221,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
favorite() {
|
favorite() {
|
||||||
return modelStore.favorite
|
return modelStore.favorite
|
||||||
},
|
},
|
||||||
hydrate,
|
|
||||||
matches,
|
|
||||||
parsed: createMemo(() => {
|
parsed: createMemo(() => {
|
||||||
const value = currentModel()
|
const value = currentModel()
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -326,7 +285,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setModelStore("recent", recentModels(next, modelStore.recent))
|
setModelStore("recent", recentModels(next, modelStore.recent))
|
||||||
save()
|
save()
|
||||||
},
|
},
|
||||||
set: select,
|
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||||
|
batch(() => {
|
||||||
|
if (!isModelValid(model)) return
|
||||||
|
const a = agent.current()
|
||||||
|
if (!a) return
|
||||||
|
setModelStore("model", a.id, model)
|
||||||
|
if (options?.recent) {
|
||||||
|
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||||
batch(() => {
|
batch(() => {
|
||||||
if (!isModelValid(model)) return
|
if (!isModelValid(model)) return
|
||||||
@@ -363,7 +333,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
return info?.variants?.map((variant) => variant.id) ?? []
|
return info?.variants?.map((variant) => variant.id) ?? []
|
||||||
},
|
},
|
||||||
set(value: string | undefined) {
|
set(value: string | undefined) {
|
||||||
selectVariant(value)
|
const m = currentModel()
|
||||||
|
if (!m) return
|
||||||
|
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||||
|
save()
|
||||||
},
|
},
|
||||||
cycle() {
|
cycle() {
|
||||||
const variants = this.list()
|
const variants = this.list()
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export function Session() {
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = prompt()
|
const current = prompt()
|
||||||
if (sent || !current || !synced() || !local.model.ready) return
|
if (sent || !current || !synced() || !local.model.ready) return
|
||||||
if (!local.agent.current() || !local.model.matches(session()?.model)) return
|
if (!local.agent.current() || !local.model.current()) return
|
||||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||||
sent = true
|
sent = true
|
||||||
current.submit()
|
current.submit()
|
||||||
|
|||||||
@@ -226,110 +226,79 @@ test("session title generated while an untitled session is loading remains visib
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const scenario of [
|
test("session startup prompt is submitted exactly once", async () => {
|
||||||
{ name: "session startup prompt is submitted exactly once", delayed: false },
|
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||||
{ name: "session model hydration retries after the model catalog loads", delayed: true },
|
const core = await import("@opentui/core")
|
||||||
]) {
|
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||||
test(scenario.name, async () => {
|
const events = createEventStream()
|
||||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
const cwd = process.cwd()
|
||||||
const core = await import("@opentui/core")
|
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
const session = {
|
||||||
const events = createEventStream()
|
id: "dummy",
|
||||||
const cwd = process.cwd()
|
title: "Demo session",
|
||||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
projectID: "project",
|
||||||
const selected = scenario.delayed ? "selected" : "model"
|
location: { directory: cwd },
|
||||||
const session = {
|
agent: "build",
|
||||||
id: "dummy",
|
model: { providerID: "provider", id: "model" },
|
||||||
title: "Demo session",
|
cost: 0,
|
||||||
projectID: "project",
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
location: { directory: cwd },
|
time: { created: 0, updated: 0 },
|
||||||
agent: "build",
|
}
|
||||||
model: { providerID: "provider", id: selected },
|
const bodies: unknown[] = []
|
||||||
cost: 0,
|
const promptSubmitted = Promise.withResolvers<void>()
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
const calls = createFetch(async (url, request) => {
|
||||||
time: { created: 0, updated: 0 },
|
if (url.pathname === "/api/location") return json(location)
|
||||||
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||||
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||||
|
if (url.pathname === "/api/session/dummy/permission") return json({ 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/session/dummy/prompt") {
|
||||||
|
bodies.push(await request.json())
|
||||||
|
promptSubmitted.resolve()
|
||||||
|
return json({ data: {} })
|
||||||
}
|
}
|
||||||
const sessionRequested = Promise.withResolvers<void>()
|
}, events)
|
||||||
const modelRequested = Promise.withResolvers<void>()
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||||
const releaseModels = Promise.withResolvers<void>()
|
|
||||||
const promptSubmitted = Promise.withResolvers<void>()
|
|
||||||
const bodies: unknown[] = []
|
|
||||||
const selections: unknown[] = []
|
|
||||||
const calls = createFetch(async (url, request) => {
|
|
||||||
if (url.pathname === "/api/location") return json(location)
|
|
||||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
||||||
if (url.pathname === "/api/session/dummy") {
|
|
||||||
sessionRequested.resolve()
|
|
||||||
return json({ data: session })
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
|
||||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
|
||||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
||||||
if (url.pathname === "/api/agent")
|
|
||||||
return json({
|
|
||||||
location,
|
|
||||||
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/model") {
|
|
||||||
modelRequested.resolve()
|
|
||||||
if (scenario.delayed) await releaseModels.promise
|
|
||||||
return json({
|
|
||||||
location,
|
|
||||||
data: [
|
|
||||||
...(scenario.delayed ? [{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] }] : []),
|
|
||||||
{ id: selected, providerID: "provider", name: "Selected", variants: [] },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/dummy/model") {
|
|
||||||
selections.push(await request.json())
|
|
||||||
return new Response(null, { status: 204 })
|
|
||||||
}
|
|
||||||
if (url.pathname === "/api/session/dummy/prompt") {
|
|
||||||
bodies.push(await request.json())
|
|
||||||
promptSubmitted.resolve()
|
|
||||||
return json({ data: {} })
|
|
||||||
}
|
|
||||||
}, events)
|
|
||||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { run } = await import("../src/app")
|
const { run } = await import("../src/app")
|
||||||
const task = Effect.runPromise(
|
const task = Effect.runPromise(
|
||||||
run({
|
run({
|
||||||
app: { name: "test", version: "test", channel: "test" },
|
app: { name: "test", version: "test", channel: "test" },
|
||||||
server: { endpoint: { url: server.url.toString() } },
|
server: { endpoint: { url: server.url.toString() } },
|
||||||
config: { get: async () => ({}), update: async () => ({}) },
|
config: { get: async () => ({}), update: async () => ({}) },
|
||||||
packages: { resolve: async () => undefined },
|
packages: { resolve: async () => undefined },
|
||||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||||
log: () => {},
|
log: () => {},
|
||||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (scenario.delayed) {
|
await Promise.race([
|
||||||
await Promise.all([sessionRequested.promise, modelRequested.promise])
|
promptSubmitted.promise,
|
||||||
await Bun.sleep(20)
|
Bun.sleep(2000).then(() => {
|
||||||
expect(bodies).toEqual([])
|
throw new Error("startup prompt was not submitted")
|
||||||
releaseModels.resolve()
|
}),
|
||||||
}
|
])
|
||||||
await Promise.race([
|
await Bun.sleep(20)
|
||||||
promptSubmitted.promise,
|
setup.renderer.destroy()
|
||||||
Bun.sleep(2000).then(() => {
|
await task
|
||||||
throw new Error("startup prompt was not submitted")
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
await Bun.sleep(20)
|
|
||||||
setup.renderer.destroy()
|
|
||||||
await task
|
|
||||||
|
|
||||||
expect(bodies).toHaveLength(1)
|
expect(bodies).toHaveLength(1)
|
||||||
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
||||||
expect(selections).toEqual([])
|
} finally {
|
||||||
} finally {
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||||
releaseModels.resolve()
|
await server.stop()
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
mock.restore()
|
||||||
await server.stop()
|
}
|
||||||
mock.restore()
|
})
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user