Compare commits

..
14 changed files with 773 additions and 193 deletions
-37
View File
@@ -147,11 +147,6 @@
"ws": "8.21.0",
},
"devDependencies": {
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-linux-arm64": "1.2.0-beta.12",
@@ -169,11 +164,6 @@
"@types/bun": "catalog:",
"@types/semver": "catalog:",
"@typescript/native-preview": "catalog:",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"vite": "catalog:",
"vite-plugin-solid": "catalog:",
},
@@ -374,7 +364,6 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/ai": "workspace:*",
@@ -1677,8 +1666,6 @@
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="],
"@ff-labs/fff-node": ["@ff-labs/fff-node@0.10.1", "", { "dependencies": { "ffi-rs": "^1.0.0" }, "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-I2TIWHkey4wpLCncUQymBz/yWrjp/TE91yjrPvSLaPMUMRf3wXwEmelGNnZ5ynxy3cUauYg5b5hsUwhOK4skfA=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
@@ -3313,28 +3300,6 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
"@yuuang/ffi-rs-android-arm64": ["@yuuang/ffi-rs-android-arm64@1.3.2", "", { "os": "android", "cpu": "arm64" }, "sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA=="],
"@yuuang/ffi-rs-darwin-arm64": ["@yuuang/ffi-rs-darwin-arm64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kRdgPaOM6TfuC5wHUwstlatk4HNie2lwSLJWQL2LiAUIJ7+96CoiWUNVhwBcFrhdfxhnWenYS6F668CV0vit8Q=="],
"@yuuang/ffi-rs-darwin-x64": ["@yuuang/ffi-rs-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-O3AlVgre8FQcZRJe44Xs7A6iDLumoPXqbw40+eJCa2gyXaXyLPdHoWrS1W9rBCa1QZRRnG7zRulPVFw8C5uo8g=="],
"@yuuang/ffi-rs-linux-arm-gnueabihf": ["@yuuang/ffi-rs-linux-arm-gnueabihf@1.3.2", "", { "os": "linux", "cpu": "arm" }, "sha512-IXiNdTbIcTCPny5eeElijFWYeKSJjQWSjt9ZyJNdLHYiB1Np+XD6K7wNZS6EOMgMelhW1kQE62T654skGkVDIA=="],
"@yuuang/ffi-rs-linux-arm64-gnu": ["@yuuang/ffi-rs-linux-arm64-gnu@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gWFO6xufUK9lPYUqDvKa6IR243dPqdetgl9Q7HrZWaDu7wLo06QQrosw8QTzndafQnOcBKm6LoLujmGCfTgJOA=="],
"@yuuang/ffi-rs-linux-arm64-musl": ["@yuuang/ffi-rs-linux-arm64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lejvOSqypPziQH5rzfkDlJ6e92qhWbDutE9ttOO6z5I2k83zoh9iZhZWhaXSU5VqgQpcshRkrbtXb9gy1ft5dA=="],
"@yuuang/ffi-rs-linux-x64-gnu": ["@yuuang/ffi-rs-linux-x64-gnu@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-s8VCFazaJKmgY2hgMTpWk4TtBY/zy5ovbaGgwyY0FvBD0YvyhcET4IrMsDJpHhFVTPCYfKZ1dN45clD/YiFp6g=="],
"@yuuang/ffi-rs-linux-x64-musl": ["@yuuang/ffi-rs-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ahr5chfKZKWUik20bEZRug+be57LZ2yYrtolyjSRoo7A4ZniBUHBZUNWm6TD6i0CJayqyxWeVk/XiaABD8bY0w=="],
"@yuuang/ffi-rs-win32-arm64-msvc": ["@yuuang/ffi-rs-win32-arm64-msvc@1.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yhpLcj0qel5VNlpzxPZfNmi7+rEX8444QHjUP6WWLxdRfqPllROu/Cp3OpkBpw3BLdxfcDhWkjWMD5QsJN0Pvg=="],
"@yuuang/ffi-rs-win32-ia32-msvc": ["@yuuang/ffi-rs-win32-ia32-msvc@1.3.2", "", { "os": "win32", "cpu": [ "x64", "ia32", ] }, "sha512-BFVSbdtg/7mJBw5kQFOPKFiA+SF7z3240HpzHN81Umm4Bp4dWkyx0msYn8+Q7/BBJiLQ4F6bi3Nftk58YA9r9w=="],
"@yuuang/ffi-rs-win32-x64-msvc": ["@yuuang/ffi-rs-win32-x64-msvc@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZL5MJ76n2rjwGo26kCWW7wK6QT/cee00Rx8pfW79pz6vM6jqfhoE7zTnwFiw4aOQUes9+HUc5DeeJ3z+Vb9oLg=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
@@ -4119,8 +4084,6 @@
"fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="],
"ffi-rs": ["ffi-rs@1.3.2", "", { "optionalDependencies": { "@yuuang/ffi-rs-android-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-arm64": "1.3.2", "@yuuang/ffi-rs-darwin-x64": "1.3.2", "@yuuang/ffi-rs-linux-arm-gnueabihf": "1.3.2", "@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-arm64-musl": "1.3.2", "@yuuang/ffi-rs-linux-x64-gnu": "1.3.2", "@yuuang/ffi-rs-linux-x64-musl": "1.3.2", "@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2", "@yuuang/ffi-rs-win32-ia32-msvc": "1.3.2", "@yuuang/ffi-rs-win32-x64-msvc": "1.3.2" } }, "sha512-4s8dX9VbBw/jd5NOuE3EJRqXaIVdjMyiumeeDzrOhtjQRwp6Bz2za7iksWXTnvTQKV/tTdm1s1w7mObe92zPjQ=="],
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
-10
View File
@@ -56,16 +56,6 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"@ff-labs/fff-bin-darwin-arm64": "0.10.1",
"@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1",
"@ff-labs/fff-bin-linux-x64-gnu": "0.10.1",
"@ff-labs/fff-bin-win32-arm64": "0.10.1",
"@ff-labs/fff-bin-win32-x64": "0.10.1",
"@yuuang/ffi-rs-darwin-arm64": "1.3.2",
"@yuuang/ffi-rs-linux-arm64-gnu": "1.3.2",
"@yuuang/ffi-rs-linux-x64-gnu": "1.3.2",
"@yuuang/ffi-rs-win32-arm64-msvc": "1.3.2",
"@yuuang/ffi-rs-win32-x64-msvc": "1.3.2",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-glibc": "2.5.1",
-2
View File
@@ -37,8 +37,6 @@ export async function collectNodeAssets(target: NodeTarget) {
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
}),
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{ key: target.fffAsset, source: fileURLToPath(import.meta.resolve(target.fffPackage)) },
{ key: target.fffFfiAsset, source: fileURLToPath(import.meta.resolve(target.fffFfiPackage)) },
{
key: photonWasmAsset,
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
-6
View File
@@ -11,8 +11,6 @@ export function nodeTarget(platform: string, arch: string) {
const targetArch = arch as "arm64" | "x64"
const nodePtyPackage = `@lydell/node-pty-${targetPlatform}-${targetArch}`
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
return {
platform: targetPlatform,
@@ -21,10 +19,6 @@ export function nodeTarget(platform: string, arch: string) {
nodePtyEntryAsset: `${nodePtyPackage}/lib/index.js`,
parcelWatcherPackage,
parcelWatcherAsset: `${parcelWatcherPackage}/watcher.node`,
fffPackage,
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
}
}
-28
View File
@@ -30,30 +30,6 @@ function runtimeRequirePlugin(): Plugin {
}
}
function fffNodePlugin(): Plugin {
return {
name: "opencode:fff-node",
enforce: "pre",
transform(code, id) {
const normalized = id.replaceAll("\\", "/")
if (normalized.endsWith("/ffi-rs/index.js")) {
const start = code.indexOf("if (!nativeBinding) {")
if (start === -1) this.error("Failed to rewrite ffi-rs native binding loader")
return `const nativeBinding = globalThis.__OPENCODE_FFF_FFI
const loadError = undefined
${code.slice(start)}`
}
if (!normalized.endsWith("/fff-node/dist/src/binary.js")) return
const transformed = code.replace(
"export function findBinary() {",
"export function findBinary() { if (process.env.FFF_BINARY_PATH) return process.env.FFF_BINARY_PATH;",
)
if (transformed === code) this.error("Failed to rewrite FFF binary loader")
return transformed
},
}
}
const resolve = {
alias: [
{ find: /^solid-js\/store$/, replacement: "solid-js/store/dist/store.js" },
@@ -189,9 +165,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
globalThis.__OPENCODE_PHOTON_WASM_PATH = process.env.OPENCODE_PHOTON_WASM_PATH
if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
}
@@ -210,7 +183,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
plugins: [
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
solid({
solid: {
generate: "universal",
-1
View File
@@ -92,7 +92,6 @@
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
+1 -3
View File
@@ -6,9 +6,7 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
Inside Code Mode, \`tools\` contains only the tools shown below${hasMoreTools ? " or returned by `search`" : ""}; surrounding top-level agent tools are not available and must not be called from the code.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
## Search
+50 -48
View File
@@ -1,51 +1,71 @@
import {
FileFinder,
type DirItem,
type DirSearchResult,
type FileItem,
type GrepCursor,
type GrepMatch,
type GrepResult,
type InitOptions,
type MixedItem,
type MixedSearchResult,
type SearchResult,
} from "@ff-labs/fff-node"
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
export interface Init {
basePath: string
frecencyDbPath?: string
historyDbPath?: string
useUnsafeNoLock?: boolean
disableMmapCache?: boolean
disableContentIndexing?: boolean
disableWatch?: boolean
aiMode?: boolean
logFilePath?: string
logLevel?: "trace" | "debug" | "info" | "warn" | "error"
enableFsRootScanning?: boolean
enableHomeDirScanning?: boolean
}
export interface File {
relativePath: string
fileName: string
modified: number
}
export interface Directory {
relativePath: string
dirName: string
maxAccessFrecency: number
}
export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory }
export interface Search {
items: FileItem[]
scores: SearchResult["scores"]
items: File[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
}
export interface DirSearch {
items: DirItem[]
scores: DirSearchResult["scores"]
items: Directory[]
scores: Array<{ total: number }>
totalMatched: number
totalDirs: number
}
export interface MixedSearch {
items: MixedItem[]
scores: MixedSearchResult["scores"]
items: Mixed[]
scores: Array<{ total: number }>
totalMatched: number
totalFiles: number
totalDirs: number
}
export type File = FileItem
export type Directory = DirItem
export type Mixed = MixedItem
export type Cursor = GrepCursor | null
export type Hit = GrepMatch
export type Cursor = null
export interface Hit {
relativePath: string
fileName: string
lineNumber: number
byteOffset: number
lineContent: string
matchRanges: [number, number][]
contextBefore?: string[]
contextAfter?: string[]
}
export interface Grep {
items: GrepResult["items"]
items: Hit[]
totalMatched: number
totalFilesSearched: number
totalFiles: number
@@ -108,29 +128,11 @@ export interface Picker {
}
export function available() {
return FileFinder.isAvailable()
return false
}
export function create(opts: Init): Result<Picker> {
const made = FileFinder.create(opts)
if (!made.ok) return made
const pick = made.value
return {
ok: true,
value: {
destroy: () => pick.destroy(),
isScanning: () => pick.isScanning(),
waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs),
refreshGitStatus: () => pick.refreshGitStatus(),
fileSearch: (query, next) => pick.fileSearch(query, next),
glob: (pattern, next) => pick.glob(pattern, next),
directorySearch: (query, next) => pick.directorySearch(query, next),
mixedSearch: (query, next) => pick.mixedSearch(query, next),
grep: (query, next) => pick.grep(query, next),
trackQuery: (query, file) => pick.trackQuery(query, file),
getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset),
},
}
export function create(_opts: Init): Result<Picker> {
return { ok: false, error: "fff unavailable on node runtime" }
}
export * as Fff from "./fff.node"
+2 -1
View File
@@ -36,7 +36,8 @@ type CollectedFiles = {
const description = [
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n")
+4 -7
View File
@@ -67,9 +67,7 @@ describe("CodeModeInstructions.render", () => {
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
expect(instructions).not.toContain("## Search")
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
expect(instructions).toContain(
"`tools` contains only the tools shown below; surrounding top-level agent tools are not available and must not be called from the code.",
)
expect(instructions).not.toContain("surrounding top-level agent tools")
})
test("adds search guidance when the catalog exceeds the budget", () => {
@@ -78,9 +76,7 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"`tools` contains only the tools shown below or returned by `search`; surrounding top-level agent tools are not available and must not be called from the code.",
)
expect(partial).not.toContain("surrounding top-level agent tools")
expect(partial).toContain("- search(input: {")
expect(partial).toContain(" limit?: number,\n offset?: number,")
expect(partial).not.toContain("tools.orders.lookup(input:")
@@ -127,7 +123,8 @@ describe("CodeModeInstructions.update", () => {
test("renders additions, changes, and removals as a compact semantic delta", () => {
const changed = { ...echo, signature: "tools.notes.echo(input: {\n text: string,\n}): Promise<string>" }
const added = entry("notes.list", "List notes")
const text = update([echo, lookup], [changed, added])
const unchanged = Array.from({ length: 5 }, (_, index) => entry(`stable.tool${index}`, `Stable ${index}`))
const text = update([echo, lookup, ...unchanged], [changed, added, ...unchanged])
expect(text).toContain("The Code Mode tool catalog has changed.")
expect(text).toContain(`New tools are available in addition to those previously listed:\n - ${added.signature}`)
expect(text).toContain(
+2 -1
View File
@@ -19,7 +19,8 @@ test("execute describes invariant Code Mode behavior", () => {
[
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
'Call Code Mode tools through `tools` using only exact paths and signatures from the current catalog or `search`. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
].join("\n"),
+16 -4
View File
@@ -1,8 +1,9 @@
import { RGBA } from "@opentui/core"
import { useTheme } from "../context/theme"
import { Spinner } from "./spinner"
export function Reconnecting() {
const { themeV2 } = useTheme()
const { themeV2 } = useTheme().contextual("elevated")
return (
<box
@@ -12,12 +13,23 @@ export function Reconnecting() {
right={0}
bottom={0}
left={0}
backgroundColor={themeV2.background.default}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
alignItems="center"
justifyContent="center"
>
<box width={62} maxWidth="90%" flexDirection="column" alignItems="center" gap={1}>
<Spinner color={themeV2.text.subdued}>Waiting for background service...</Spinner>
<box
width={48}
maxWidth="90%"
flexDirection="column"
backgroundColor={themeV2.background.default}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<Spinner color={themeV2.text.default}>Restarting service...</Spinner>
<text fg={themeV2.text.subdued}>Your session will resume automatically.</text>
</box>
</box>
)
+64 -45
View File
@@ -49,6 +49,67 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
if (history.length > connectionHistoryLimit) history.shift()
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
function start() {
stream?.abort()
const controller = new AbortController()
@@ -56,53 +117,11 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(controller.signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
controller.signal.addEventListener("abort", cancel, { once: true })
const error = await (async () => {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (first.done)
return request.signal.reason instanceof Error
? request.signal.reason
: new Error("Event stream disconnected")
if (first.value.type !== "server.connected")
return new Error("Event stream did not start with server.connected")
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
events.emit(first.value.type, first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
while (!abort.signal.aborted && !controller.signal.aborted) {
const event = await iterator.next()
if (abort.signal.aborted || controller.signal.aborted) return undefined
if (event.done) return new Error("Event stream disconnected")
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
events.emit(event.value.type, event.value)
}
return undefined
})()
.catch((error) => error)
.finally(() => {
request.abort()
clearTimeout(timeout)
controller.signal.removeEventListener("abort", cancel)
})
const result = await connect(controller.signal, attempt)
if (abort.signal.aborted || controller.signal.aborted) return
if (connectedAt !== undefined && Date.now() - connectedAt >= 1_000) attempt = 0
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
attempt += 1
const message = errorMessage(error)
const message = errorMessage(result.error)
record("disconnected", attempt, message)
log.info("event stream disconnected", {
attempt,
@@ -0,0 +1,634 @@
/** @jsxImportSource @opentui/solid */
import { afterAll, describe, expect, test } from "bun:test"
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import type { LogLevel, LogSink } from "../../../src/context/log"
import { createApi, createFetch } from "../../fixture/tui-client"
const packageRoot = process.env.OPENCODE_TUI_ROOT
const contextModule = packageRoot
? await import(`${packageRoot}/src/context/client.tsx`)
: await import("../../../src/context/client")
const environmentModule = packageRoot
? await import(`${packageRoot}/test/fixture/tui-environment.tsx`)
: await import("../../fixture/tui-environment")
const { ClientProvider, useClient } = contextModule as typeof import("../../../src/context/client")
const { TestTuiContexts } = environmentModule as typeof import("../../fixture/tui-environment")
type Client = ReturnType<typeof useClient>
type Service = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type Observation = {
scenario: string
value: unknown
}
const observations: Observation[] = []
const connected = { id: "evt_connected", type: "server.connected", data: {} } as OpenCodeEvent
afterAll(async () => {
const output = process.env.CLIENT_BEHAVIOR_OUTPUT
if (output) await Bun.write(output, `${JSON.stringify(observations, null, 2)}\n`)
})
function observe(scenario: string, value: unknown) {
observations.push({ scenario, value })
}
function normalizeError(error: unknown) {
if (error instanceof Error) return `${error.name}:${error.message}`
return String(error)
}
function history(client: Client) {
return client.connection.internal.history().map((event) => ({
status: event.data.status,
attempt: event.data.attempt,
error: event.data.error,
}))
}
async function waitFor(check: () => boolean, timeout = 3_000) {
const started = Date.now()
while (!check()) {
if (Date.now() - started > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(5)
}
}
function event(type: "vcs" | "update" | "rename", suffix: string): OpenCodeEvent {
if (type === "vcs") {
return {
id: `evt_vcs_${suffix}`,
created: 1,
type: "vcs.branch.updated",
location: { directory: "/tmp/project" },
data: { branch: suffix },
}
}
if (type === "update") {
return {
id: `evt_update_${suffix}`,
created: 2,
type: "installation.update-available",
data: { version: suffix },
}
}
return {
id: `evt_rename_${suffix}`,
created: 3,
type: "session.renamed",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
location: { directory: "/tmp/project" },
data: { sessionID: "ses_test", title: suffix },
}
}
function createStream(options?: { first?: OpenCodeEvent; closeBeforeHandshake?: boolean }) {
const encoder = new TextEncoder()
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
const requests: Request[] = []
const aborts: string[] = []
let cancellations = 0
function response(request: Request) {
requests.push(request)
request.signal.addEventListener("abort", () => aborts.push(normalizeError(request.signal.reason)), { once: true })
let current: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
current = controller
controllers.add(controller)
if (options?.closeBeforeHandshake) {
controllers.delete(controller)
controller.close()
return
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify(options?.first ?? connected)}\n\n`))
},
cancel() {
cancellations += 1
if (current) controllers.delete(current)
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return {
response,
emit(value: OpenCodeEvent) {
const chunk = encoder.encode(`data: ${JSON.stringify(value)}\n\n`)
for (const controller of controllers) controller.enqueue(chunk)
},
raw(value: string) {
const chunk = encoder.encode(value)
for (const controller of controllers) controller.enqueue(chunk)
},
close() {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.close()
}
},
fail(message: string) {
for (const controller of [...controllers]) {
controllers.delete(controller)
controller.error(new Error(message))
}
},
snapshot() {
return {
requests: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
aborts,
cancellations,
active: controllers.size,
}
},
}
}
function apiFor(stream: ReturnType<typeof createStream>) {
return createApi(
createFetch((url, request) => {
if (url.pathname === "/api/event") return stream.response(request)
}).fetch,
)
}
async function mount(input: {
api: OpenCodeClient
service?: Service
throwOn?: OpenCodeEvent["type"]
}) {
const seen: Array<{ type: string; status: string }> = []
const typed: string[] = []
const logs: Array<{ level: LogLevel; message: string; tags: Record<string, unknown> }> = []
let initialStatus = ""
let client!: Client
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
const log: LogSink = (level, message, tags) => {
logs.push({ level, message, tags: { ...tags } })
}
const app = await testRender(() => (
<TestTuiContexts log={log}>
<ClientProvider api={input.api} service={input.service}>
<Probe
onReady={(value) => {
client = value
initialStatus = value.connection.status()
ready()
}}
onEvent={(value) => {
seen.push({ type: value.type, status: client.connection.status() })
if (value.type === input.throwOn) throw new Error(`listener failed for ${value.type}`)
}}
onBranch={(branch) => typed.push(branch)}
/>
</ClientProvider>
</TestTuiContexts>
))
await mounted
return { app, client, initialStatus, seen, typed, logs }
}
function Probe(props: {
onReady: (client: Client) => void
onEvent: (event: OpenCodeEvent) => void
onBranch: (branch: string) => void
}) {
const client = useClient()
onMount(() => {
client.event.listen(({ details }) => props.onEvent(details))
client.event.on("vcs.branch.updated", (value) => props.onBranch(value.data.branch ?? ""))
props.onReady(client)
})
return <box />
}
describe("ClientProvider connection characterization", () => {
test("records handshake ordering, event delivery, logging, and active-stream cleanup", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "main"))
stream.emit(event("rename", "renamed"))
stream.emit(event("update", "2.0.0"))
await waitFor(() => setup.seen.length === 4)
observe("healthy.connected", {
initialStatus: setup.initialStatus,
finalStatus: setup.client.connection.status(),
seen: setup.seen,
typed: setup.typed,
logs: setup.logs,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
await waitFor(() => stream.snapshot().requestAborted[0] === true)
await Bun.sleep(20)
observe("healthy.cleanup", {
history: history(setup.client),
stream: stream.snapshot(),
})
expect(setup.seen.map((item) => item.type)).toEqual([
"server.connected",
"vcs.branch.updated",
"session.renamed",
"installation.update-available",
])
expect(setup.seen.map((item) => item.status)).toEqual(["connecting", "connected", "connected", "connected"])
expect(setup.logs.filter((item) => item.message === "event")).toHaveLength(1)
})
test("records an invalid first event", async () => {
const stream = createStream({ first: event("vcs", "invalid-handshake") })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.invalid", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream did not start with server.connected")
})
test("records EOF before the handshake", async () => {
const stream = createStream({ closeBeforeHandshake: true })
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.eof", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Event stream disconnected")
})
test("records a fetch failure before the handshake", async () => {
const calls = createFetch((url) => {
if (url.pathname === "/api/event") throw new Error("network unavailable")
return undefined
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("handshake.fetch-error", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
logs: setup.logs,
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records the initial connection timeout and request cancellation", async () => {
const requests: Request[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => setup.client.connection.status() === "reconnecting", 3_000)
observe("handshake.timeout", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
requestCount: requests.length,
requestAborted: requests.map((request) => request.signal.aborted),
abortReasons: requests.map((request) => normalizeError(request.signal.reason)),
history: history(setup.client),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records static transport reconnection after a connected stream closes", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.static", {
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs.filter((item) => item.message !== "event"),
})
setup.app.renderer.destroy()
expect(setup.seen.map((item) => item.type)).toEqual(["server.connected", "server.connected"])
})
test("records immediate managed-service replacement", async () => {
const initial = createStream()
const replacement = createStream()
const replacementApi = apiFor(replacement)
const reconnectSignals: boolean[] = []
const service: Service = {
reconnect(signal) {
reconnectSignals.push(signal.aborted)
return Promise.resolve({ api: replacementApi })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(initial), service })
await waitFor(() => setup.client.connection.status() === "connected")
initial.close()
await waitFor(() => replacement.snapshot().requests === 1)
await waitFor(() => setup.client.connection.status() === "connected")
replacement.emit(event("vcs", "replacement"))
await waitFor(() => setup.typed.includes("replacement"))
observe("reconnect.managed-replacement", {
status: setup.client.connection.status(),
apiReplaced: setup.client.api === replacementApi,
reconnectSignals,
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
initial: initial.snapshot(),
replacement: replacement.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.api).toBe(replacementApi)
})
test("records managed-service resolution failure and delayed retry", async () => {
const stream = createStream()
let reconnects = 0
const service: Service = {
reconnect() {
reconnects += 1
return Promise.reject(new Error("service unavailable"))
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => stream.snapshot().requests === 2, 2_000)
await waitFor(() => setup.client.connection.status() === "connected")
observe("reconnect.managed-failure", {
reconnects,
status: setup.client.connection.status(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
resolutionLogs: setup.logs.filter((item) => item.message === "server resolution failed"),
})
setup.app.renderer.destroy()
expect(reconnects).toBe(1)
})
test("records cleanup while the initial fetch is pending", async () => {
const requests: Request[] = []
const aborts: string[] = []
const calls = createFetch((url, request) => {
if (url.pathname !== "/api/event") return
requests.push(request)
return new Promise<Response>((_, reject) => {
request.signal.addEventListener(
"abort",
() => {
aborts.push(normalizeError(request.signal.reason))
reject(request.signal.reason)
},
{ once: true },
)
})
})
const setup = await mount({ api: createApi(calls.fetch) })
await waitFor(() => requests.length === 1)
setup.app.renderer.destroy()
await waitFor(() => requests[0].signal.aborted)
await Bun.sleep(20)
observe("cleanup.pending-handshake", {
status: setup.client.connection.status(),
requestAborted: requests[0].signal.aborted,
aborts,
history: history(setup.client),
logs: setup.logs,
})
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting"])
})
test("records an event listener failure as a connection failure", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "vcs.branch.updated" })
await waitFor(() => setup.client.connection.status() === "connected")
stream.emit(event("vcs", "throws"))
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
typed: setup.typed,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("listener failed for vcs.branch.updated")
})
test("records stream reader failure after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.fail("reader exploded")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.reader-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("Transport")
})
test("records malformed SSE data after connection", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.raw("data: not-json\n\n")
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("stream.malformed-data", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(setup.client.connection.error()).toBe("MalformedResponse")
})
test("records a server.connected listener failure before connected state publication", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream), throwOn: "server.connected" })
await waitFor(() => setup.client.connection.status() === "reconnecting")
observe("listener.connected-failure", {
status: setup.client.connection.status(),
error: setup.client.connection.error(),
seen: setup.seen,
history: history(setup.client),
stream: stream.snapshot(),
})
setup.app.renderer.destroy()
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting", "connected", "disconnected"])
})
test("records cleanup during static reconnect backoff", async () => {
const stream = createStream()
const setup = await mount({ api: apiFor(stream) })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => setup.client.connection.status() === "reconnecting")
setup.app.renderer.destroy()
await Bun.sleep(1_050)
observe("cleanup.reconnect-backoff", {
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
})
expect(stream.snapshot().requests).toBe(1)
})
test("records cleanup during managed-service resolution", async () => {
const stream = createStream()
let resolutionStarted = false
let resolutionAborted = false
const service: Service = {
reconnect(signal) {
resolutionStarted = true
return new Promise((_, reject) => {
signal.addEventListener(
"abort",
() => {
resolutionAborted = true
reject(signal.reason)
},
{ once: true },
)
})
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apiFor(stream), service })
await waitFor(() => setup.client.connection.status() === "connected")
stream.close()
await waitFor(() => resolutionStarted)
setup.app.renderer.destroy()
await waitFor(() => resolutionAborted)
await Bun.sleep(20)
observe("cleanup.service-resolution", {
resolutionStarted,
resolutionAborted,
status: setup.client.connection.status(),
history: history(setup.client),
stream: stream.snapshot(),
logs: setup.logs,
})
expect(resolutionAborted).toBe(true)
})
test("records attempt reset after a stable connection", async () => {
const streams = [createStream(), createStream(), createStream()]
const apis = streams.map(apiFor)
let reconnects = 0
const service: Service = {
reconnect() {
const api = apis[Math.min(reconnects + 1, apis.length - 1)]
reconnects += 1
return Promise.resolve({ api })
},
restart: () => Promise.resolve(),
}
const setup = await mount({ api: apis[0], service })
await waitFor(() => setup.client.connection.status() === "connected")
streams[0].close()
await waitFor(() => streams[1].snapshot().requests === 1)
streams[1].close()
await waitFor(() => streams[2].snapshot().requests === 1)
await Bun.sleep(1_050)
streams[2].close()
await waitFor(() => reconnects === 3)
observe("reconnect.stable-reset", {
reconnects,
status: setup.client.connection.status(),
history: history(setup.client),
streams: streams.map((stream) => stream.snapshot()),
})
setup.app.renderer.destroy()
expect(history(setup.client).filter((item) => item.status === "disconnected").map((item) => item.attempt)).toEqual([
1, 2, 1,
])
})
})