mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 07:11:25 +03:00
2848 lines
112 KiB
TypeScript
2848 lines
112 KiB
TypeScript
import { expect, spyOn, test } from "bun:test"
|
|
import { EmbeddedTerminalRenderable, type Renderable } from "@opentui/core"
|
|
import { createTestRenderer } from "@opentui/core/testing"
|
|
import { Effect, FileSystem } from "effect"
|
|
import { Global } from "@opencode/util/global"
|
|
import * as Open from "@opencode/util/open"
|
|
import { waitForGoUpsellConnect } from "../src/component/dialog-go-upsell"
|
|
import { GoUpsellArtRenderable } from "../src/component/go-upsell-art"
|
|
import { SimulationRenderer } from "@opencode/simulation/frontend/renderer"
|
|
import path from "node:path"
|
|
import { Writable } from "node:stream"
|
|
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
|
import { tmpdir } from "./fixture/fixture"
|
|
import { createAppFixture } from "./fixture/app"
|
|
import { waitForHighlightedFrame } from "./fixture/frame"
|
|
import type { PluginInfo } from "@opencode/client"
|
|
|
|
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
|
await using state = await tmpdir()
|
|
const requested = Promise.withResolvers<void>()
|
|
const response = Promise.withResolvers<Response>()
|
|
const projects = Promise.withResolvers<Response>()
|
|
const refresh = Promise.withResolvers<Response>()
|
|
const cachedSession = {
|
|
id: "ses_cached",
|
|
title: "Cached session",
|
|
projectID: "proj_fixture",
|
|
location: { directory: "/fixture" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 2 },
|
|
}
|
|
let requests = 0
|
|
await using setup = await createAppFixture({
|
|
width,
|
|
state: state.path,
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") {
|
|
requests++
|
|
requested.resolve()
|
|
if (requests === 1) return response.promise
|
|
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
|
|
if (requests > 4) return new Response("Unavailable", { status: 503 })
|
|
return json({ data: [cachedSession], cursor: {} })
|
|
}
|
|
if (url.pathname === "/api/project") return projects.promise
|
|
return undefined
|
|
},
|
|
})
|
|
try {
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await requested.promise
|
|
await setup.renderOnce()
|
|
expect(setup.captureCharFrame()).toContain("Search sessions")
|
|
expect(setup.captureCharFrame()).toContain("Refreshing")
|
|
projects.resolve(
|
|
json([
|
|
{
|
|
id: "proj_fixture",
|
|
canonical: "/fixture",
|
|
name: "Fixture project",
|
|
time: { created: 1, updated: 2 },
|
|
sandboxes: [],
|
|
},
|
|
]),
|
|
)
|
|
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
expect(requests).toBe(1)
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
|
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
|
|
await setup.renderOnce()
|
|
expect(setup.captureCharFrame()).not.toContain("Fixture project")
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
|
|
expect(setup.captureCharFrame()).not.toContain("Disposed")
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes("Cached"))
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
|
|
setup.events.emit({
|
|
id: "evt_deleted",
|
|
created: 1,
|
|
type: "session.deleted",
|
|
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
|
|
data: { sessionID: "ses_cached" },
|
|
})
|
|
await setup.waitForFrame((frame) => !frame.includes("Cached"))
|
|
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
|
|
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
|
|
expect(setup.captureCharFrame()).not.toContain("Cached")
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
|
expect(setup.captureCharFrame()).not.toContain("Cached")
|
|
} finally {
|
|
response.resolve(json({ data: [], cursor: {} }))
|
|
projects.resolve(json([]))
|
|
refresh.resolve(json({ data: [], cursor: {} }))
|
|
}
|
|
})
|
|
|
|
test.each(["dismissed", "refreshing"])(
|
|
"Ctrl-O retains committed movement of a cached-only session while %s",
|
|
async (phase) => {
|
|
await using state = await tmpdir()
|
|
const refresh = Promise.withResolvers<Response>()
|
|
const metadata = Promise.withResolvers<Response>()
|
|
const destinationRequested = Promise.withResolvers<void>()
|
|
const cached = {
|
|
id: "ses_cached_move",
|
|
title: "Cached movement",
|
|
projectID: "proj_old",
|
|
location: { directory: "/fixture/old" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 2 },
|
|
}
|
|
let requests = 0
|
|
const locations: string[] = []
|
|
await using setup = await createAppFixture({
|
|
state: state.path,
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") {
|
|
if (url.searchParams.has("parentID")) {
|
|
const parent = url.searchParams.get("parentID")
|
|
if (parent && parent !== "null") return json({ data: [], cursor: {} })
|
|
}
|
|
return requests++ === 0
|
|
? json({ data: [cached], cursor: {} })
|
|
: refresh.promise.then((response) => response.clone())
|
|
}
|
|
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
|
|
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
|
|
if (
|
|
url.pathname === `/api/session/${cached.id}/inbox` ||
|
|
url.pathname === `/api/session/${cached.id}/permission`
|
|
)
|
|
return json({ data: [] })
|
|
if (url.pathname === "/api/project")
|
|
return json(
|
|
["old", "new"].map((name) => ({
|
|
id: `proj_${name}`,
|
|
canonical: `/fixture/${name}`,
|
|
name: name === "old" ? "Old" : "New",
|
|
time: { created: 1, updated: 2 },
|
|
sandboxes: [],
|
|
})),
|
|
)
|
|
if (url.pathname === "/api/location") {
|
|
const query = url.searchParams.get("location[directory]") ?? ""
|
|
locations.push(query)
|
|
if (query.includes("/fixture/new")) {
|
|
destinationRequested.resolve()
|
|
return json({
|
|
directory: "/fixture/new",
|
|
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
|
|
})
|
|
}
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
try {
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
|
|
if (phase === "refreshing") {
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
|
|
}
|
|
setup.events.emit({
|
|
id: "evt_cached_moved",
|
|
created: 3,
|
|
type: "session.moved",
|
|
durable: { aggregateID: cached.id, seq: 1, version: 1 },
|
|
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
|
|
})
|
|
if (phase === "dismissed") {
|
|
setup.mockInput.pressKey("o", { ctrl: true })
|
|
refresh.resolve(new Response("Unavailable", { status: 503 }))
|
|
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
|
}
|
|
if (phase === "refreshing") {
|
|
await setup.waitForFrame((frame) =>
|
|
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
|
)
|
|
refresh.resolve(json({ data: [cached], cursor: {} }))
|
|
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
|
}
|
|
await setup.waitForFrame((frame) =>
|
|
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
|
)
|
|
expect(
|
|
setup
|
|
.captureCharFrame()
|
|
.split("\n")
|
|
.find((line) => line.includes(cached.title)),
|
|
).toContain("New")
|
|
locations.length = 0
|
|
setup.mockInput.pressEnter()
|
|
await destinationRequested.promise
|
|
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
|
|
} finally {
|
|
refresh.resolve(json({ data: [], cursor: {} }))
|
|
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
|
|
}
|
|
},
|
|
)
|
|
|
|
test("boots on the simulation renderer with the terminal title enabled", async () => {
|
|
await Effect.runPromise(
|
|
Effect.scoped(
|
|
Effect.gen(function* () {
|
|
const setup = SimulationRenderer.setupFor(yield* SimulationRenderer.create({}))
|
|
yield* Effect.promise(async () => {
|
|
await using app = await createAppFixture({ setup })
|
|
await app.ready
|
|
await app.waitForFrame((frame) => frame.includes("commands"))
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
|
await using state = await tmpdir()
|
|
const output: string[] = []
|
|
const setup = await createTestRenderer({
|
|
width: 80,
|
|
height: 24,
|
|
bufferedOutput: "stdout",
|
|
stdout: new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
output.push(chunk.toString())
|
|
callback()
|
|
},
|
|
}) as NodeJS.WriteStream,
|
|
})
|
|
await setup.renderer.setupTerminal()
|
|
let started!: () => void
|
|
const ready = new Promise<void>((resolve) => {
|
|
started = resolve
|
|
})
|
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
|
setup.renderer.setTerminalTitle = (title) => {
|
|
if (title === "OpenCode") started()
|
|
setTitle(title)
|
|
}
|
|
const listeners = new Set(process.listeners("SIGHUP"))
|
|
const events = createEventStream()
|
|
const calls = createFetch(undefined, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { prepare: async () => ({ directory: "" }) },
|
|
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
|
args: {},
|
|
log: () => {},
|
|
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
await ready
|
|
process.emit("SIGHUP")
|
|
await task
|
|
|
|
expect(setup.renderer.isDestroyed).toBe(true)
|
|
expect(output.join("")).toContain("\x1b]0;\x07")
|
|
expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await setup.renderer.closed
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
|
await using state = await tmpdir()
|
|
const setup = await createTestRenderer({ width: 80, height: 24 })
|
|
await setup.renderer.setupTerminal()
|
|
let initialTitle!: () => void
|
|
const initialTitleSet = new Promise<void>((resolve) => {
|
|
initialTitle = resolve
|
|
})
|
|
let renamedTitle!: () => void
|
|
const renamedTitleSet = new Promise<void>((resolve) => {
|
|
renamedTitle = resolve
|
|
})
|
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
|
setup.renderer.setTerminalTitle = (title) => {
|
|
if (title === "OC | Demo session") initialTitle()
|
|
if (title === "OC | Renamed session") renamedTitle()
|
|
setTitle(title)
|
|
}
|
|
const events = createEventStream()
|
|
let promptRequests = 0
|
|
const calls = createFetch((url) => {
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Demo session",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
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/inbox") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/prompt") {
|
|
promptRequests++
|
|
return json({ data: {} })
|
|
}
|
|
}, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
const originalWrite = process.stdout.write.bind(process.stdout)
|
|
let stdout = ""
|
|
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
stdout += String(chunk)
|
|
return true
|
|
}) as typeof process.stdout.write
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { prepare: async () => ({ directory: "" }) },
|
|
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
|
args: { sessionID: "dummy" },
|
|
log: () => {},
|
|
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await initialTitleSet
|
|
events.emit({
|
|
id: "evt_renamed",
|
|
created: 1,
|
|
type: "session.renamed",
|
|
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
|
data: { sessionID: "dummy", title: "Renamed session" },
|
|
})
|
|
await renamedTitleSet
|
|
setup.renderer.destroy()
|
|
await task
|
|
|
|
expect(stdout).toContain("Renamed session")
|
|
expect(stdout).toContain("opencode -s dummy")
|
|
expect(promptRequests).toBe(0)
|
|
} finally {
|
|
process.stdout.write = originalWrite
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("session title generated while an untitled session is loading remains visible", async () => {
|
|
await using state = await tmpdir()
|
|
const setup = await createTestRenderer({ width: 80, height: 24 })
|
|
await setup.renderer.setupTerminal()
|
|
const titles: string[] = []
|
|
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
|
const generatedTitle = Promise.withResolvers<void>()
|
|
setup.renderer.setTerminalTitle = (title) => {
|
|
titles.push(title)
|
|
if (title === "OC | Generated title") generatedTitle.resolve()
|
|
setTitle(title)
|
|
}
|
|
const sessionRequested = Promise.withResolvers<void>()
|
|
const renameSyncRequested = Promise.withResolvers<void>()
|
|
const releaseSession = Promise.withResolvers<void>()
|
|
let sessionRequests = 0
|
|
const session = {
|
|
id: "dummy",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const events = createEventStream()
|
|
const calls = createFetch(async (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy") {
|
|
sessionRequests++
|
|
sessionRequested.resolve()
|
|
if (sessionRequests === 2) renameSyncRequested.resolve()
|
|
await releaseSession.promise
|
|
return json({ data: session })
|
|
}
|
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
}, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { prepare: async () => ({ directory: "" }) },
|
|
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
|
args: { sessionID: "dummy" },
|
|
log: () => {},
|
|
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await sessionRequested.promise
|
|
events.emit({
|
|
id: "evt_renamed",
|
|
created: 1,
|
|
type: "session.renamed",
|
|
durable: { aggregateID: "dummy", seq: 1, version: 1 },
|
|
data: { sessionID: "dummy", title: "Generated title" },
|
|
})
|
|
await Promise.race([
|
|
renameSyncRequested.promise,
|
|
Bun.sleep(2_000).then(() => {
|
|
throw new Error("rename sync did not start")
|
|
}),
|
|
])
|
|
releaseSession.resolve()
|
|
await Promise.race([
|
|
generatedTitle.promise,
|
|
Bun.sleep(2_000).then(() => {
|
|
throw new Error("generated title was not shown")
|
|
}),
|
|
])
|
|
await Bun.sleep(20)
|
|
|
|
const generated = titles.lastIndexOf("OC | Generated title")
|
|
expect(generated).toBeGreaterThan(-1)
|
|
expect(titles.slice(generated + 1)).not.toContain("OpenCode")
|
|
setup.renderer.destroy()
|
|
await task
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
test("vertical session tabs switch to horizontal below readable content width", async () => {
|
|
await using state = await tmpdir()
|
|
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 42 }))
|
|
const session = {
|
|
id: "ses_resize",
|
|
title: "Resize fixture",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 2 },
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width: 120,
|
|
state: state.path,
|
|
config: {
|
|
animations: false,
|
|
tabs: { mode: "on", layout: "vertical", indicators: "status" },
|
|
session: { sidebar: "hide" },
|
|
},
|
|
args: { sessionID: session.id },
|
|
fetch: (url) => {
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (/^\/api\/session\/ses_resize\/(message|inbox|permission)$/.test(url.pathname))
|
|
return json({ data: [], cursor: {} })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
|
|
|
|
setup.resize(100, 30)
|
|
await setup.waitForFrame((frame) => frame.split("\n")[0].includes(session.title))
|
|
expect(setup.captureCharFrame()).not.toContain("⌕")
|
|
setup.resize(120, 30)
|
|
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 42).includes(session.title))
|
|
})
|
|
|
|
test("narrow vertical session tabs collapse to a compact rail with the terminal", async () => {
|
|
await using state = await tmpdir()
|
|
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 5 }))
|
|
const session = {
|
|
id: "ses_resize",
|
|
title: "Resize fixture",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 2 },
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width: 80,
|
|
state: state.path,
|
|
config: {
|
|
animations: false,
|
|
tabs: { mode: "on", layout: "vertical", indicators: "status" },
|
|
session: { sidebar: "hide" },
|
|
},
|
|
args: { sessionID: session.id },
|
|
fetch: (url) => {
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (/^\/api\/session\/ses_resize\/(message|inbox|permission)$/.test(url.pathname))
|
|
return json({ data: [], cursor: {} })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 10).trim() === "⌕")
|
|
|
|
setup.resize(68, 30)
|
|
await setup.waitForFrame((frame) => frame.split("\n")[0].includes(session.title))
|
|
expect(setup.captureCharFrame()).not.toContain("⌕")
|
|
setup.resize(80, 30)
|
|
await setup.waitForFrame((frame) => frame.split("\n")[1].slice(0, 10).trim() === "⌕")
|
|
})
|
|
|
|
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
|
|
await using state = await tmpdir()
|
|
const response = Promise.withResolvers<Response>()
|
|
const bodies: unknown[] = []
|
|
const location = { directory, project: { id: "project", directory, canonical: directory } }
|
|
const session = {
|
|
id: "ses_rename",
|
|
title: "Compiler cleanup",
|
|
projectID: "project",
|
|
location: { directory },
|
|
agent: "build",
|
|
model: { providerID: "provider", id: "model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width: 110,
|
|
height: 20,
|
|
state: state.path,
|
|
config: { tabs: { mode: "on", layout: "vertical" }, session: { sidebar: "hide" } },
|
|
args: { sessionID: session.id },
|
|
fetch: async (url, request) => {
|
|
if (url.pathname === "/api/location") return json(location)
|
|
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") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/ses_rename" && request.method === "PATCH") {
|
|
bodies.push(await request.json())
|
|
return response.promise
|
|
}
|
|
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
|
|
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
|
|
return json({ data: [], cursor: {} })
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
try {
|
|
await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build · Model Provider"))
|
|
await setup.mockInput.typeText("/rename")
|
|
setup.mockInput.pressEscape()
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitFor(() => bodies.length === 1)
|
|
await setup.renderOnce()
|
|
expect(bodies[0]).toEqual({ title: "" })
|
|
expect(setup.captureCharFrame()).toContain("Compiler cleanup")
|
|
|
|
session.title = "Simplify compiler parsing"
|
|
response.resolve(new Response(null, { status: 204 }))
|
|
await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 })
|
|
expect(setup.captureCharFrame()).not.toContain("Compiler cleanup")
|
|
} finally {
|
|
response.resolve(new Response(null, { status: 204 }))
|
|
}
|
|
})
|
|
|
|
test.each([80, 120])("completes custom Markdown and ordinary fences in a session at width %s", async (width) => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_markdown",
|
|
title: "Markdown fixture",
|
|
projectID: "project",
|
|
location: { directory },
|
|
agent: "build",
|
|
model: { providerID: "fixture", id: "model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 2 },
|
|
}
|
|
const initial =
|
|
"```mermaid\ngraph LR\n A[DiagramStart] --> B[DiagramEnd]\n```\n\n```latex\nx^2\n```\n\n```text\ninitial"
|
|
await using setup = await createAppFixture({
|
|
width,
|
|
height: 55,
|
|
state: state.path,
|
|
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
|
|
args: { sessionID: session.id },
|
|
fetch: (url) => {
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_markdown",
|
|
type: "assistant",
|
|
agent: session.agent,
|
|
model: session.model,
|
|
time: { created: 2 },
|
|
content: [{ type: "text", text: initial }],
|
|
},
|
|
{
|
|
id: "msg_compaction",
|
|
type: "compaction",
|
|
time: { created: 1 },
|
|
status: "completed",
|
|
reason: "manual",
|
|
summary: "```latex\ny^2\n```",
|
|
recent: "msg_markdown",
|
|
},
|
|
],
|
|
cursor: {},
|
|
})
|
|
if (
|
|
url.pathname === `/api/session/${session.id}/inbox` ||
|
|
url.pathname === `/api/session/${session.id}/permission`
|
|
)
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
const streaming = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) =>
|
|
frame.includes("initial") &&
|
|
frame.includes("DiagramStart") &&
|
|
frame.includes("DiagramEnd") &&
|
|
frame.includes("x\u00b2") &&
|
|
frame.includes("y\u00b2"),
|
|
)
|
|
expect(streaming).toContain("Compaction")
|
|
expect(streaming).not.toContain("initial final")
|
|
expect(streaming).not.toContain("MARKDOWN_END")
|
|
|
|
// Queue final text and completion together to exercise TextPart's reactive property order.
|
|
setup.events.emit({
|
|
id: "evt_markdown_text_ended",
|
|
created: 3,
|
|
type: "session.text.ended",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: {
|
|
sessionID: session.id,
|
|
assistantMessageID: "msg_markdown",
|
|
ordinal: 0,
|
|
text: `${initial} final\n\`\`\`\n\nMARKDOWN_END`,
|
|
},
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_markdown_step_ended",
|
|
created: 4,
|
|
type: "session.step.ended",
|
|
durable: { aggregateID: session.id, seq: 2, version: 1 },
|
|
data: {
|
|
sessionID: session.id,
|
|
assistantMessageID: "msg_markdown",
|
|
finish: "stop",
|
|
cost: 0,
|
|
tokens: session.tokens,
|
|
},
|
|
})
|
|
const frame = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) => frame.includes("MARKDOWN_END") && frame.includes("initial final") && frame.includes("2ms"),
|
|
)
|
|
expect(frame).toContain("DiagramStart")
|
|
expect(frame).toContain("DiagramEnd")
|
|
expect(frame).toContain("x\u00b2")
|
|
expect(frame).toContain("y\u00b2")
|
|
expect(frame).toContain("initial final")
|
|
expect(frame).not.toContain("graph LR")
|
|
expect(frame).not.toContain("x^2")
|
|
expect(frame).not.toContain("y^2")
|
|
expect(frame).not.toContain("```")
|
|
})
|
|
|
|
test("keeps assistant footer metrics current after prepend, same-length refresh, and revert", async () => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_footer",
|
|
title: "Footer fixture",
|
|
projectID: "project",
|
|
location: { directory },
|
|
agent: "build",
|
|
model: { providerID: "fixture", id: "model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 100, updated: 5000 },
|
|
}
|
|
let refresh = false
|
|
await using setup = await createAppFixture({
|
|
width: 100,
|
|
height: 40,
|
|
state: state.path,
|
|
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide", tps: true } },
|
|
args: { sessionID: session.id },
|
|
fetch: (url) => {
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`) {
|
|
if (url.searchParams.get("cursor") === "older")
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_0001",
|
|
type: "system",
|
|
text: "Earlier instructions",
|
|
description: "Prepended instructions",
|
|
time: { created: 200 },
|
|
},
|
|
{ id: "msg_0000", type: "user", text: "Prepended input", time: { created: 100 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
return json({
|
|
data: [
|
|
...(refresh
|
|
? [
|
|
{
|
|
id: "msg_0005",
|
|
type: "assistant",
|
|
agent: session.agent,
|
|
model: session.model,
|
|
time: { created: 7000, streamed: 8000, completed: 9000 },
|
|
finish: "stop",
|
|
cost: 0,
|
|
tokens: { ...session.tokens, output: 50 },
|
|
content: [{ type: "text", text: "Later answer" }],
|
|
},
|
|
{ id: "msg_0004", type: "user", text: "Later input", time: { created: 6000 } },
|
|
]
|
|
: []),
|
|
{
|
|
id: "msg_0003",
|
|
type: "assistant",
|
|
agent: session.agent,
|
|
model: session.model,
|
|
time: { created: 2000, streamed: 3000, completed: 5000 },
|
|
finish: "stop",
|
|
cost: 0,
|
|
tokens: { ...session.tokens, output: 20 },
|
|
content: [{ type: "text", text: "Original answer" }],
|
|
},
|
|
{ id: "msg_0002", type: "user", text: "Current input", time: { created: 1000 } },
|
|
],
|
|
cursor: { next: "older" },
|
|
})
|
|
}
|
|
if (
|
|
url.pathname === `/api/session/${session.id}/inbox` ||
|
|
url.pathname === `/api/session/${session.id}/permission`
|
|
)
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
const initial = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) => frame.includes("Original answer") && frame.includes("20.0 tok/s"),
|
|
)
|
|
expect(initial).toContain("Current input")
|
|
expect(initial).toContain("4.0s \u00b7 20.0 tok/s")
|
|
expect(initial).not.toContain("Prepended input")
|
|
|
|
setup.mockInput.pressKey("g", { ctrl: true })
|
|
const prepended = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) =>
|
|
frame.includes("Prepended input") &&
|
|
frame.includes("Prepended instructions") &&
|
|
frame.includes("Original answer") &&
|
|
frame.includes("20.0 tok/s") &&
|
|
!frame.includes("Loading session history…"),
|
|
)
|
|
expect(prepended).toContain("Current input")
|
|
expect(prepended).toContain("Original answer")
|
|
expect(prepended).toContain("4.0s \u00b7 20.0 tok/s")
|
|
|
|
// Refresh the latest page: length stays four, but the retained assistant moves from index three to one.
|
|
refresh = true
|
|
setup.events.disconnect()
|
|
const refreshed = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) =>
|
|
frame.includes("Later input") &&
|
|
frame.includes("Later answer") &&
|
|
!frame.includes("Prepended input") &&
|
|
!frame.includes("Prepended instructions"),
|
|
{ maxPasses: 120 },
|
|
)
|
|
expect(refreshed).toContain("Current input")
|
|
expect(refreshed).toContain("Original answer")
|
|
expect(refreshed).toContain("4.0s \u00b7 20.0 tok/s")
|
|
expect(refreshed).toContain("3.0s \u00b7 50.0 tok/s")
|
|
|
|
setup.events.emit({
|
|
id: "evt_footer_reverted",
|
|
created: 10000,
|
|
type: "session.revert.committed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, to: "msg_0004" },
|
|
})
|
|
const reverted = await waitForHighlightedFrame(
|
|
setup,
|
|
(frame) => frame.includes("Original answer") && !frame.includes("Later input") && !frame.includes("Later answer"),
|
|
)
|
|
expect(reverted).toContain("Current input")
|
|
expect(reverted).toContain("4.0s \u00b7 20.0 tok/s")
|
|
expect(reverted).not.toContain("50.0 tok/s")
|
|
})
|
|
|
|
test("session startup prompt is submitted exactly once", async () => {
|
|
await using state = await tmpdir()
|
|
const setup = await createTestRenderer({ width: 80, height: 24 })
|
|
await setup.renderer.setupTerminal()
|
|
const events = createEventStream()
|
|
const cwd = process.cwd()
|
|
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Demo session",
|
|
projectID: "project",
|
|
location: { directory: cwd },
|
|
agent: "build",
|
|
model: { providerID: "provider", id: "model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const bodies: unknown[] = []
|
|
const promptSubmitted = Promise.withResolvers<void>()
|
|
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") return json({ data: session })
|
|
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
|
if (url.pathname === "/api/session/dummy/inbox") 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: {} })
|
|
}
|
|
}, events)
|
|
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: { get: async () => ({}), update: async () => ({}) },
|
|
packages: { prepare: async () => ({ directory: "" }) },
|
|
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
|
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
|
log: () => {},
|
|
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await Promise.race([
|
|
promptSubmitted.promise,
|
|
Bun.sleep(2000).then(() => {
|
|
throw new Error("startup prompt was not submitted")
|
|
}),
|
|
])
|
|
await Bun.sleep(20)
|
|
setup.renderer.destroy()
|
|
await task
|
|
|
|
expect(bodies).toHaveLength(1)
|
|
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
}
|
|
})
|
|
|
|
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()
|
|
const location = { directory: target, project: { id: "project", directory: target, canonical: target } }
|
|
const requests: URL[] = []
|
|
const created = Promise.withResolvers<unknown>()
|
|
const submitted = Promise.withResolvers<unknown>()
|
|
let session: unknown
|
|
await using setup = await createAppFixture({
|
|
state: state.path,
|
|
config: { animations: false, tabs: { mode: "off" }, keybinds: { "session.new": "f6" } },
|
|
fetch: async (url, request) => {
|
|
requests.push(url)
|
|
if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target)
|
|
return json({ message: "Directory does not exist on the server" }, { status: 500 })
|
|
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
|
if (url.pathname === "/api/location") return json(location)
|
|
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: "Remote 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")
|
|
created.resolve(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)) {
|
|
submitted.resolve(await request.json())
|
|
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 setup.waitForFrame((frame) => frame.includes("Build · Remote Model Provider"))
|
|
setup.mockInput.pressKey("F6")
|
|
await setup.renderOnce()
|
|
await setup.mockInput.typeText("REMOTE_READY")
|
|
await setup.waitForFrame((frame) => frame.includes("REMOTE_READY"))
|
|
setup.mockInput.pressEnter()
|
|
expect(
|
|
await Promise.race([
|
|
submitted.promise,
|
|
Bun.sleep(2_000).then(() => {
|
|
throw new Error("prompt was not submitted in the resolved server directory")
|
|
}),
|
|
]),
|
|
).toMatchObject({ text: "REMOTE_READY" })
|
|
expect(await created.promise).toMatchObject({ location: { directory: target } })
|
|
expect(requests[0]?.pathname).toBe("/api/fs/list")
|
|
expect(requests[0]?.searchParams.get("location[directory]")).toBe(process.cwd())
|
|
expect(
|
|
requests.filter((url) => url.pathname === "/api/location" && !url.searchParams.has("location[directory]")),
|
|
).toHaveLength(fallback ? 1 : 0)
|
|
expect(
|
|
requests
|
|
.slice(1)
|
|
.filter((url) => url.searchParams.has("location[directory]"))
|
|
.every((url) => url.searchParams.get("location[directory]") === target),
|
|
).toBe(true)
|
|
})
|
|
|
|
test("error investigations repeatedly seed editable home drafts without creating sessions", async () => {
|
|
const cwd = process.cwd()
|
|
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
|
let created = 0
|
|
await using setup = await createAppFixture({
|
|
config: { animations: false, keybinds: { "mcp.list": "f6" } },
|
|
fetch: (url, request) => {
|
|
if (url.pathname === "/api/location") return json(location)
|
|
if (url.pathname === "/api/mcp")
|
|
return json({
|
|
location,
|
|
data: [
|
|
{ name: "alpha", status: { status: "failed", error: "Alpha connection refused" } },
|
|
{ name: "beta", status: { status: "failed", error: "Beta initialization failed" } },
|
|
],
|
|
})
|
|
if (url.pathname === "/api/session" && request.method === "POST") created++
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
|
|
setup.mockInput.pressKey("F6")
|
|
await setup.waitForFrame((frame) => frame.includes("MCP servers") && frame.includes("alpha"))
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && frame.includes("investigate i"))
|
|
setup.mockInput.pressKey("i")
|
|
await setup.waitForFrame((frame) => frame.includes("Alpha connection refused") && !frame.includes("investigate i"))
|
|
|
|
setup.mockInput.pressKey("F6")
|
|
await setup.waitForFrame((frame) => frame.includes("MCP servers"))
|
|
setup.mockInput.pressArrow("down")
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Beta initialization failed") && frame.includes("investigate i"))
|
|
setup.mockInput.pressKey("i")
|
|
const draft = await setup.waitForFrame(
|
|
(frame) => frame.includes("Beta initialization failed") && !frame.includes("investigate i"),
|
|
)
|
|
|
|
expect(draft).not.toContain("Alpha connection refused")
|
|
expect(created).toBe(0)
|
|
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => !frame.includes("Beta initialization failed"))
|
|
})
|
|
|
|
test("completed user shell output replaces a partial live read when the final read fails", async () => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_shell_output",
|
|
title: "Shell output fixture",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const shell = {
|
|
id: "sh_output",
|
|
command: "echo shell-output-fixture",
|
|
status: "running" as const,
|
|
cwd: directory,
|
|
shell: "/bin/sh",
|
|
file: `${directory}/shell.out`,
|
|
metadata: { sessionID: session.id, background: true },
|
|
time: { started: 1 },
|
|
}
|
|
const partial = "first live output\n"
|
|
const completed = `${partial}last persisted output\n`
|
|
let finished = false
|
|
let failedReads = 0
|
|
await using setup = await createAppFixture({
|
|
state: state.path,
|
|
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
|
|
args: { sessionID: session.id },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_shell_output",
|
|
type: "shell",
|
|
shellID: shell.id,
|
|
command: shell.command,
|
|
status: "running",
|
|
metadata: { background: true },
|
|
time: { created: 1 },
|
|
},
|
|
],
|
|
cursor: {},
|
|
})
|
|
if (
|
|
url.pathname === `/api/session/${session.id}/inbox` ||
|
|
url.pathname === `/api/session/${session.id}/permission`
|
|
)
|
|
return json({ data: [] })
|
|
if (url.pathname === "/api/shell") return json({ location: { directory }, data: finished ? [] : [shell] })
|
|
if (url.pathname === `/api/shell/${shell.id}/output`) {
|
|
if (finished) {
|
|
failedReads++
|
|
return json({ message: "Shell output unavailable" }, { status: 404 })
|
|
}
|
|
return json({
|
|
location: { directory },
|
|
data: {
|
|
output: partial.slice(Number(url.searchParams.get("cursor") ?? 0)),
|
|
cursor: partial.length,
|
|
size: partial.length,
|
|
truncated: false,
|
|
},
|
|
})
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
const running = await setup.waitForFrame((frame) => frame.includes(partial.trim()))
|
|
expect(running).toContain(shell.command)
|
|
expect(running).not.toContain("Background")
|
|
|
|
finished = true
|
|
setup.events.emit({
|
|
id: "evt_shell_exited",
|
|
created: 2,
|
|
type: "shell.exited",
|
|
location: { directory },
|
|
data: { id: shell.id, status: "exited", exit: 0 },
|
|
})
|
|
await setup.waitFor(() => failedReads > 0)
|
|
setup.events.emit({
|
|
id: "evt_shell_ended",
|
|
created: 3,
|
|
type: "session.shell.ended",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: {
|
|
sessionID: session.id,
|
|
shell: { ...shell, status: "exited", exit: 0, time: { started: 1, completed: 2 } },
|
|
output: { output: completed, cursor: completed.length, size: completed.length, truncated: false },
|
|
},
|
|
})
|
|
const terminal = await setup.waitForFrame((frame) => frame.includes("last persisted output"))
|
|
expect(terminal).toContain(partial.trim())
|
|
expect(terminal).toContain(`$ ${shell.command}`)
|
|
expect(terminal).not.toContain("Background")
|
|
})
|
|
|
|
test("new session inherits the active session model", async () => {
|
|
const cwd = process.cwd()
|
|
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Demo session",
|
|
projectID: "project",
|
|
location: { directory: cwd },
|
|
agent: "build",
|
|
model: { providerID: "provider", id: "session-model" },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width: 80,
|
|
height: 24,
|
|
args: { sessionID: "dummy" },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
|
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/inbox") 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/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
|
if (url.pathname === "/api/model")
|
|
return json({
|
|
location,
|
|
data: [
|
|
{ id: "home-model", providerID: "provider", name: "Home Model", variants: [] },
|
|
{ id: "session-model", providerID: "provider", name: "Session Model", variants: [] },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
await setup.waitForFrame((frame) => frame.includes("Session Model"))
|
|
await setup.mockInput.typeText("/new")
|
|
setup.mockInput.pressEnter()
|
|
await Bun.sleep(50)
|
|
await setup.renderOnce()
|
|
const frame = setup.captureCharFrame()
|
|
expect(frame).toContain("Session Model")
|
|
})
|
|
|
|
test("keeps the prompt display stable while a new location catalog loads", async () => {
|
|
const source = process.cwd()
|
|
const target = path.join(path.parse(source).root, "opencode-target")
|
|
const locationCatalog = Promise.withResolvers<void>()
|
|
const catalog = Promise.withResolvers<void>()
|
|
const providerCatalog = Promise.withResolvers<void>()
|
|
const locationRequested = Promise.withResolvers<void>()
|
|
const modelRequested = Promise.withResolvers<void>()
|
|
await using setup = await createAppFixture({
|
|
fetch: async (url) => {
|
|
const requestedDirectory = url.searchParams.get("location[directory]") ?? source
|
|
const location = {
|
|
directory: requestedDirectory,
|
|
project: {
|
|
id: requestedDirectory === target ? "target" : "source",
|
|
directory: requestedDirectory,
|
|
canonical: requestedDirectory,
|
|
},
|
|
}
|
|
if (url.pathname === "/api/location") {
|
|
if (requestedDirectory === target) {
|
|
locationRequested.resolve()
|
|
await locationCatalog.promise
|
|
}
|
|
return json(location)
|
|
}
|
|
if (url.pathname === "/api/agent") {
|
|
if (requestedDirectory === target) await catalog.promise
|
|
return json({
|
|
location,
|
|
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
|
})
|
|
}
|
|
if (url.pathname === "/api/provider") {
|
|
if (requestedDirectory === target) await providerCatalog.promise
|
|
return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
|
}
|
|
if (url.pathname === "/api/model") {
|
|
if (requestedDirectory === target) {
|
|
modelRequested.resolve()
|
|
await catalog.promise
|
|
}
|
|
return json({
|
|
location,
|
|
data: [
|
|
{
|
|
id: requestedDirectory === target ? "target-model" : "source-model",
|
|
providerID: "provider",
|
|
name: requestedDirectory === target ? "Target Model" : "Source Model",
|
|
variants: [],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
return undefined
|
|
},
|
|
})
|
|
|
|
try {
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("Build · Source Model Provider"))
|
|
const agentSpan = () =>
|
|
setup
|
|
.captureSpans()
|
|
.lines.flatMap((line) => line.spans)
|
|
.find((span) => span.text.trim() === "Build")
|
|
const sourceAgentColor = agentSpan()?.fg.toInts()
|
|
expect(sourceAgentColor).toBeDefined()
|
|
await setup.mockInput.typeText(`/cd ${target}`)
|
|
await setup.renderOnce()
|
|
expect(setup.captureCharFrame()).toContain(`/cd ${target}`)
|
|
setup.mockInput.pressEscape()
|
|
setup.mockInput.pressEnter()
|
|
await Promise.race([
|
|
locationRequested.promise,
|
|
Bun.sleep(2_000).then(() => {
|
|
throw new Error("target location was not requested")
|
|
}),
|
|
])
|
|
await setup.renderOnce()
|
|
expect(setup.captureCharFrame()).toContain(target)
|
|
|
|
locationCatalog.resolve()
|
|
await Promise.race([
|
|
modelRequested.promise,
|
|
Bun.sleep(2_000).then(() => {
|
|
throw new Error("target model catalog was not requested")
|
|
}),
|
|
])
|
|
await setup.renderOnce()
|
|
|
|
expect(setup.captureCharFrame()).toContain("Build · Source Model Provider")
|
|
expect(agentSpan()?.fg.toInts()).toEqual(sourceAgentColor)
|
|
|
|
catalog.resolve()
|
|
const resolved = await setup.waitForFrame((frame) => frame.includes("Build · Target Model provider"))
|
|
expect(resolved).not.toContain("Source Model")
|
|
|
|
providerCatalog.resolve()
|
|
await setup.waitForFrame((frame) => frame.includes("Build · Target Model Provider"))
|
|
} finally {
|
|
locationCatalog.resolve()
|
|
catalog.resolve()
|
|
providerCatalog.resolve()
|
|
}
|
|
})
|
|
|
|
test("configured app binding opens settings", async () => {
|
|
await using setup = await createAppFixture({
|
|
config: { animations: false, keybinds: { "opencode.settings": "f6" } },
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
|
|
setup.mockInput.pressKey("F6")
|
|
const settings = await setup.waitForFrame((frame) => frame.includes("Settings"))
|
|
expect(settings).toContain("Color mode")
|
|
expect(settings).toContain("Animations")
|
|
})
|
|
|
|
test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => {
|
|
await using setup = await createAppFixture()
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
await setup.mockInput.typeText("/theme")
|
|
await setup.waitForFrame((frame) => frame.includes("Switch theme"))
|
|
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => !frame.includes("Switch theme"))
|
|
expect(setup.renderer.isDestroyed).toBe(false)
|
|
|
|
await setup.mockInput.typeText("!")
|
|
await setup.waitForFrame((frame) => frame.includes("Shell"))
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => !frame.includes("Shell"))
|
|
expect(setup.renderer.isDestroyed).toBe(false)
|
|
})
|
|
|
|
test.skipIf(process.platform === "win32").each(["manual", "select"] as const)(
|
|
"selection copy and pane management respect %s mode in the prompt and terminal pane",
|
|
async (copy) => {
|
|
await using state = await tmpdir()
|
|
const setup = await createTestRenderer({ width: 100, height: 30, kittyKeyboard: true })
|
|
await setup.renderer.setupTerminal()
|
|
setup.renderer.start()
|
|
const ready = Promise.withResolvers<void>()
|
|
const session = {
|
|
id: "dummy",
|
|
title: "Selection fixture",
|
|
projectID: "project",
|
|
location: { directory },
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
}
|
|
const pty = {
|
|
id: "pty_fixture",
|
|
sessionID: session.id,
|
|
title: "Terminal",
|
|
command: "/bin/sh",
|
|
args: [],
|
|
cwd: directory,
|
|
status: "running",
|
|
pid: 1,
|
|
foregroundProcess: null,
|
|
size: { cols: 48, rows: 24 },
|
|
output: { head: 0, tail: 0 },
|
|
}
|
|
const input: string[] = []
|
|
const calls = createFetch((url, request) => {
|
|
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/inbox") return json({ data: [] })
|
|
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
|
if (url.pathname === "/api/experimental/session/dummy/terminal")
|
|
return json({ data: request.method === "POST" ? pty : [pty] })
|
|
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/snapshot")
|
|
return json({
|
|
data: {
|
|
info: pty,
|
|
text: "alpha beta gamma",
|
|
checkpoint: Buffer.from("alpha beta gamma").toString("base64"),
|
|
cursor: { x: 16, y: 0 },
|
|
},
|
|
})
|
|
if (url.pathname === "/api/experimental/persistent-pty/pty_fixture/connect-token")
|
|
return json({ data: { ticket: "fixture" } })
|
|
return undefined
|
|
}, createEventStream())
|
|
const server = Bun.serve({
|
|
port: 0,
|
|
fetch(request, server) {
|
|
if (new URL(request.url).pathname.endsWith("/connect") && server.upgrade(request)) return undefined
|
|
return calls.fetch(request)
|
|
},
|
|
websocket: {
|
|
open(socket) {
|
|
socket.send(JSON.stringify({ type: "attached", inputProtocol: 1, role: "controller", info: pty }))
|
|
socket.send(JSON.stringify({ type: "replay_complete" }))
|
|
},
|
|
message(_socket, message) {
|
|
const data = Buffer.from(message)
|
|
if (data[0] === 1) input.push(data.subarray(5).toString())
|
|
},
|
|
},
|
|
})
|
|
|
|
try {
|
|
const { run } = await import("../src/app")
|
|
const task = Effect.runPromise(
|
|
run({
|
|
app: { name: "test", version: "test", channel: "test" },
|
|
server: { endpoint: { url: server.url.toString() } },
|
|
config: {
|
|
get: async () => ({
|
|
animations: false,
|
|
terminal: { copy },
|
|
}),
|
|
update: async () => ({}),
|
|
},
|
|
packages: { prepare: async () => ({ directory: "" }) },
|
|
args: { sessionID: session.id },
|
|
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
|
log: () => {},
|
|
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
|
)
|
|
|
|
await ready.promise
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
await setup.mockInput.typeText("selection audit draft")
|
|
setup.mockInput.pressKey("a", { ctrl: true, shift: true })
|
|
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("selection audit draft")
|
|
|
|
setup.mockInput.pressEscape()
|
|
expect(setup.renderer.hasSelection).toBeFalse()
|
|
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("selection audit draft")
|
|
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => !frame.includes("selection audit draft"))
|
|
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
|
expect(setup.renderer.hasSelection).toBeFalse()
|
|
expect(setup.renderer.isDestroyed).toBeFalse()
|
|
|
|
await setup.mockInput.typeText("/terminal")
|
|
await setup.waitForFrame((frame) => frame.includes("New terminal"))
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
|
setup.mockInput.pressKey("x", { ctrl: true })
|
|
setup.mockInput.pressArrow("right")
|
|
const terminal = setup.renderer.currentFocusedRenderable
|
|
if (!(terminal instanceof EmbeddedTerminalRenderable)) throw new Error("Terminal was not focused")
|
|
setup.renderer.startSelection(terminal, terminal.x + 6, terminal.y)
|
|
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
|
|
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
|
|
|
setup.mockInput.pressEscape()
|
|
expect(setup.renderer.hasSelection).toBeFalse()
|
|
expect(terminal.hasSelection()).toBeFalse()
|
|
|
|
await setup.mockMouse.click(terminal.x + 6, terminal.y)
|
|
if (copy === "select") {
|
|
setup.renderer.updateSelection(terminal, terminal.x + 9, terminal.y, { finishDragging: true })
|
|
expect(setup.renderer.getSelection()?.getSelectedText()).toBe("beta")
|
|
}
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitFor(() => input.length > 0)
|
|
expect(input).toEqual(["\x03"])
|
|
expect(setup.renderer.hasSelection).toBeFalse()
|
|
expect(setup.renderer.isDestroyed).toBeFalse()
|
|
|
|
setup.mockInput.pressKey("x", { ctrl: true })
|
|
setup.mockInput.pressArrow("up")
|
|
await setup.waitFor(() => terminal.isDestroyed)
|
|
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
|
setup.mockInput.pressKey("x", { ctrl: true })
|
|
setup.mockInput.pressKey("t")
|
|
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
|
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
|
|
setup.mockInput.pressKey("x", { ctrl: true })
|
|
setup.mockInput.pressArrow("down")
|
|
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
|
|
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
|
|
|
|
setup.renderer.destroy()
|
|
await task
|
|
} finally {
|
|
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
|
await server.stop()
|
|
}
|
|
},
|
|
)
|
|
|
|
test.each([100, 44])(
|
|
"execution failure keeps the empty session composer and draft usable at width %s",
|
|
async (width) => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_failure",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Failure fixture",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [], cursor: {} })
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
setup.mockInput.pressKey("u", { ctrl: true })
|
|
await setup.mockInput.typeText("Keep this draft")
|
|
await setup.waitForFrame((frame) => frame.includes("Keep this draft"))
|
|
setup.events.emit({
|
|
id: "evt_execution_failed",
|
|
created: 2,
|
|
type: "session.execution.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: {
|
|
sessionID: session.id,
|
|
error: { type: "unknown", message: 'Plugin "broken-skills" failed during skill.transform.' },
|
|
},
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Session failed"))
|
|
expect(setup.captureCharFrame()).toContain("broken-skills")
|
|
expect(setup.captureCharFrame()).toContain("skill.transform")
|
|
expect(setup.captureCharFrame()).toContain("Keep this draft")
|
|
expect(setup.captureCharFrame()).not.toContain("Select directory")
|
|
await setup.mockInput.typeText(" intact")
|
|
await setup.waitForFrame((frame) => frame.includes("Keep this draft intact"))
|
|
},
|
|
)
|
|
|
|
test.each([
|
|
[100, true],
|
|
[44, true],
|
|
[100, false],
|
|
[44, false],
|
|
] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => {
|
|
await using state = await tmpdir()
|
|
const failure: PluginInfo["state"] = {
|
|
status: "failed",
|
|
error: "Plugin disabled after command.transform failed. Check server logs for details.",
|
|
ref: "err_fixture",
|
|
}
|
|
let inventory: PluginInfo[] = [
|
|
{
|
|
id: "broken",
|
|
source: { type: "builtin" },
|
|
features: { server: true },
|
|
state: initial ? failure : { status: "active" },
|
|
},
|
|
{ id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } },
|
|
]
|
|
let requests = 0
|
|
await using setup = await createAppFixture({
|
|
width,
|
|
state: state.path,
|
|
fetch: (url) => {
|
|
if (url.pathname !== "/api/plugin") return undefined
|
|
requests++
|
|
return json({
|
|
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
|
data: inventory,
|
|
})
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("commands"))
|
|
if (!initial) {
|
|
expect(setup.captureCharFrame()).not.toContain("Plugin failed")
|
|
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
|
setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} })
|
|
}
|
|
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
|
expect(setup.captureCharFrame()).toContain("/plugins")
|
|
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
|
|
|
const lines = setup.captureCharFrame().split("\n")
|
|
const row = lines.findIndex((line) => line.includes("Open plugins"))
|
|
expect(row).toBeGreaterThanOrEqual(0)
|
|
const line = lines[row]
|
|
if (!line) throw new Error("Open plugins action is missing")
|
|
await setup.mockMouse.click(line.indexOf("Open plugins"), row)
|
|
await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken"))
|
|
expect(setup.captureCharFrame()).toContain("broken")
|
|
expect(setup.captureCharFrame()).not.toContain("healthy")
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed"))
|
|
expect(setup.captureCharFrame()).toContain("Plugin disabled")
|
|
expect(setup.captureCharFrame()).toContain("transform failed")
|
|
expect(setup.captureCharFrame()).toContain("err_fixture")
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => frame.includes("ctrl+a"))
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("ctrl+a"))
|
|
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
|
|
|
const seen = requests
|
|
setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} })
|
|
setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} })
|
|
await setup.waitFor(() => requests >= seen + 2)
|
|
await setup.flush()
|
|
expect(setup.captureCharFrame()).not.toContain("Plugin failed:")
|
|
|
|
inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } }))
|
|
setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} })
|
|
await setup.waitForFrame((frame) => !frame.includes("1 plugin failed"))
|
|
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
|
setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} })
|
|
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
|
})
|
|
|
|
test("server plugin failures share one notice and use source names before an ID is known", async () => {
|
|
await using state = await tmpdir()
|
|
await using setup = await createAppFixture({
|
|
state: state.path,
|
|
fetch: (url) =>
|
|
url.pathname === "/api/plugin"
|
|
? json({
|
|
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
|
data: [
|
|
{
|
|
source: { type: "package", target: "missing-package" },
|
|
features: {},
|
|
state: { status: "failed", error: "Package missing" },
|
|
},
|
|
{
|
|
source: { type: "local", path: "/fixture/broken.ts" },
|
|
features: {},
|
|
state: { status: "failed", error: "Invalid plugin" },
|
|
},
|
|
],
|
|
})
|
|
: undefined,
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("2 plugins failed"))
|
|
expect(setup.captureCharFrame()).toContain("missing-package")
|
|
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
|
expect(setup.captureCharFrame()).toContain("Open plugins")
|
|
})
|
|
|
|
test("free usage limit offers OpenCode Go once per 24h, transitions Subscribe to Connect Go, and restores focus", async () => {
|
|
await using state = await tmpdir()
|
|
const baseNow = 1_700_000_000_000
|
|
let currentNow = baseNow
|
|
using _now = spyOn(Date, "now").mockImplementation(() => currentNow)
|
|
const session = {
|
|
id: "ses_free_limit",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Free limit",
|
|
agent: "build",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const model = { id: "big-pickle", providerID: "opencode" }
|
|
const freeModel = {
|
|
id: "big-pickle",
|
|
providerID: "opencode",
|
|
name: "Big Pickle",
|
|
status: "active",
|
|
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
|
capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false },
|
|
limit: { context: 200_000, output: 8_192 },
|
|
variants: [],
|
|
time: { released: 1 },
|
|
}
|
|
const goModel = {
|
|
id: "glm-5-go",
|
|
providerID: "opencode-go",
|
|
name: "GLM-5 Go",
|
|
status: "active",
|
|
cost: [{ input: 1, output: 2, cache: { read: 0, write: 0 } }],
|
|
capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false },
|
|
limit: { context: 200_000, output: 8_192 },
|
|
variants: [],
|
|
time: { released: 2 },
|
|
}
|
|
const error = {
|
|
type: "provider.free-limit",
|
|
message: "Free usage limit reached.",
|
|
status: 429,
|
|
retryAfterMs: 3_600_000,
|
|
}
|
|
let authorized = false
|
|
let releaseOAuth!: () => void
|
|
const oauthGate = new Promise<void>((resolve) => {
|
|
releaseOAuth = resolve
|
|
})
|
|
const oauthRequests: unknown[] = []
|
|
const switchedModels: unknown[] = []
|
|
const resentPrompts: unknown[] = []
|
|
const promptResent = Promise.withResolvers<void>()
|
|
await using setup = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: async (url, request) => {
|
|
const location = { directory, project: { id: "proj_test", directory, canonical: directory } }
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
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: "opencode", name: "OpenCode Zen" },
|
|
...(authorized ? [{ id: "opencode-go", integrationID: "opencode", name: "OpenCode Go" }] : []),
|
|
],
|
|
})
|
|
if (url.pathname === "/api/model")
|
|
return json({
|
|
location,
|
|
data: authorized ? [goModel, freeModel] : [freeModel],
|
|
})
|
|
if (url.pathname === "/api/integration")
|
|
return json({
|
|
location,
|
|
data: [
|
|
{
|
|
id: "opencode",
|
|
name: "OpenCode Console",
|
|
methods: [
|
|
{
|
|
id: "device",
|
|
type: "oauth",
|
|
label: "OpenCode Console account",
|
|
form: [
|
|
{
|
|
key: "server",
|
|
type: "string",
|
|
format: "uri",
|
|
hidden: true,
|
|
default: "https://opencode.ai/console",
|
|
},
|
|
],
|
|
},
|
|
{ type: "key", label: "API key (service account)" },
|
|
],
|
|
connections: authorized
|
|
? [{ type: "credential", method: "oauth", id: "cred_console", label: "Anomaly" }]
|
|
: [],
|
|
},
|
|
{
|
|
id: "opencode-go",
|
|
name: "OpenCode Go",
|
|
methods: [{ type: "key", label: "Enter Go API key" }],
|
|
connections: [],
|
|
},
|
|
],
|
|
})
|
|
if (url.pathname === "/api/integration/opencode/connect/oauth" && request.method === "POST") {
|
|
oauthRequests.push(await request.json())
|
|
return json({
|
|
location,
|
|
data: {
|
|
attemptID: "attempt_go",
|
|
mode: "auto",
|
|
url: "https://opencode.ai/console/device?user_code=GOGO-1234",
|
|
instructions: "Enter code: GOGO-1234",
|
|
},
|
|
})
|
|
}
|
|
if (url.pathname === "/api/integration/opencode/connect/oauth/attempt_go") {
|
|
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
|
await oauthGate
|
|
authorized = true
|
|
return json({ location, data: { status: "complete" } })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/model` && request.method === "POST") {
|
|
switchedModels.push(await request.json())
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/prompt` && request.method === "POST") {
|
|
resentPrompts.push(await request.json())
|
|
promptResent.resolve()
|
|
return json({ data: {} })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_free_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
time: { created: 2 },
|
|
},
|
|
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if (url.pathname === `/api/session/${session.id}/inbox`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_queued_user",
|
|
sessionID: session.id,
|
|
type: "user",
|
|
delivery: "queue",
|
|
payload: { text: "Queued follow-up" },
|
|
time: { created: 3 },
|
|
},
|
|
],
|
|
})
|
|
if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
setup.events.emit({
|
|
id: "evt_free_limit_1",
|
|
created: baseNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_exec",
|
|
created: baseNow,
|
|
type: "session.execution.failed",
|
|
durable: { aggregateID: session.id, seq: 2, version: 1 },
|
|
data: { sessionID: session.id, error },
|
|
})
|
|
using openSpy = spyOn(Open, "openUrl").mockResolvedValue(undefined as never)
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
const upsell = setup.captureCharFrame()
|
|
expect(upsell).toContain("OpenCode Go gives you reliable access")
|
|
expect(upsell).toContain("▀▀▀▀ ▀▀▀▀")
|
|
expect(upsell).toContain("opencode.ai/go")
|
|
expect(upsell).toContain("don't show again")
|
|
expect(upsell).toContain("dismiss")
|
|
expect(upsell).toContain("subscribe")
|
|
expect(upsell).not.toContain("Connect OpenCode Go")
|
|
expect(upsell).not.toContain("Session failed")
|
|
// Dismiss closes the modal, leaves the muted notice, and restores focus to the composer.
|
|
setup.mockInput.pressArrow("left")
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => !frame.includes("Free limit reached"))
|
|
const frame = setup.captureCharFrame()
|
|
expect(frame).toContain("You've used today's free messages. They reset at")
|
|
expect(frame).toContain("Upgrade to OpenCode Go → /connect · Switch model → /models")
|
|
expect(frame).not.toContain("Error:")
|
|
expect(frame).not.toContain("Session failed")
|
|
// The delivered prompt (not the queued one) shows once in the transcript and once more back in the composer.
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 3)
|
|
expect(setup.captureCharFrame().split("Queued follow-up").length).toBe(2)
|
|
|
|
// Clear the composer (which also verifies composer focus was restored after Dismiss): a duplicate event does not re-restore.
|
|
await Bun.sleep(10)
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_dup",
|
|
created: baseNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 3, version: 1 },
|
|
data: {
|
|
sessionID: session.id,
|
|
assistantMessageID: "msg_free_assistant",
|
|
error: { ...error, retryAfterMs: undefined },
|
|
},
|
|
})
|
|
await setup.waitForFrame((frame) => !frame.includes("They reset at"))
|
|
expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(2)
|
|
|
|
// Just before 24h (86_400_000 - 1 ms) with an empty composer: recalls the next turn's prompt without reopening Free limit reached.
|
|
currentNow = baseNow + 86_400_000 - 1
|
|
setup.events.emit({
|
|
id: "evt_free_limit_delivered",
|
|
created: 4,
|
|
type: "session.inbox.delivered",
|
|
durable: { aggregateID: session.id, seq: 4, version: 1 },
|
|
data: { sessionID: session.id, inboxID: "msg_queued_user" },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_2",
|
|
created: 5,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 5, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 5 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_3",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 6, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error },
|
|
})
|
|
await setup.waitForFrame(
|
|
(frame) =>
|
|
frame.split("You've used today's free messages").length === 3 && frame.split("Queued follow-up").length === 3,
|
|
)
|
|
expect(setup.captureCharFrame()).not.toContain("Free limit reached")
|
|
expect(setup.captureCharFrame()).not.toContain("Connect OpenCode Go")
|
|
|
|
// Exactly at 24h (86_400_000 ms) with an empty composer: reopens Free limit reached, and Subscribe starts OpenCode device OAuth directly, selects Go, and resends the saved prompt.
|
|
currentNow = baseNow + 86_400_000
|
|
setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Queued follow-up").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_4",
|
|
created: 7,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 7, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", agent: "build", model, started: 7 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_5",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 8, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("GOGO-1234"))
|
|
expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go")
|
|
expect(oauthRequests).toEqual([
|
|
{ methodID: "device", answer: { server: "https://opencode.ai/console" } },
|
|
])
|
|
const connectFrame = setup.captureCharFrame()
|
|
expect(connectFrame).toContain("OpenCode Console account")
|
|
expect(connectFrame).not.toContain("Enter Go API key")
|
|
expect(connectFrame).not.toContain("API key (service account)")
|
|
releaseOAuth()
|
|
await promptResent.promise
|
|
await setup.waitForFrame((frame) => !frame.includes("GOGO-1234") && frame.includes("GLM-5 Go"))
|
|
expect(switchedModels).toEqual([{ model: { providerID: "opencode-go", id: "glm-5-go" } }])
|
|
expect(resentPrompts).toHaveLength(1)
|
|
expect(resentPrompts[0]).toMatchObject({
|
|
text: "Queued follow-up",
|
|
})
|
|
})
|
|
|
|
test("free usage limit Subscribe handles mid-auth/mid-sync Escape, sync failure, already-connected accounts, and Don't show again across restart", async () => {
|
|
await using state = await tmpdir()
|
|
const baseNow = 1_700_000_000_000
|
|
let currentNow = baseNow
|
|
using _now = spyOn(Date, "now").mockImplementation(() => currentNow)
|
|
using openSpy = spyOn(Open, "openUrl").mockResolvedValue(undefined as never)
|
|
const session = {
|
|
id: "ses_free_limit_key",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Free limit no Go subscription",
|
|
agent: "build",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const model = { id: "big-pickle", providerID: "opencode" }
|
|
const freeModel = {
|
|
id: "big-pickle",
|
|
providerID: "opencode",
|
|
name: "Big Pickle",
|
|
status: "active",
|
|
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
|
capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false },
|
|
limit: { context: 200_000, output: 8_192 },
|
|
variants: [],
|
|
time: { released: 1 },
|
|
}
|
|
const goModel = {
|
|
id: "glm-5-go",
|
|
providerID: "opencode-go",
|
|
name: "GLM-5 Go",
|
|
status: "active",
|
|
cost: [{ input: 1, output: 2, cache: { read: 0, write: 0 } }],
|
|
capabilities: { input: ["text"], output: ["text"], reasoning: false, toolCall: true, attachment: false },
|
|
limit: { context: 200_000, output: 8_192 },
|
|
variants: [],
|
|
time: { released: 2 },
|
|
}
|
|
const error = {
|
|
type: "provider.free-limit",
|
|
message: "Free usage limit reached.",
|
|
status: 429,
|
|
retryAfterMs: 3_600_000,
|
|
}
|
|
let releaseCatalog!: () => void
|
|
const catalogGate = new Promise<void>((resolve) => {
|
|
releaseCatalog = resolve
|
|
})
|
|
let oauthGate = Promise.resolve()
|
|
let modelSyncGate = Promise.resolve()
|
|
let onModelSyncRequest: (() => void) | undefined
|
|
let failModelSync = false
|
|
let connected = false
|
|
let needsAuth = false
|
|
let goEntitled = false
|
|
let oauthConnectCalls = 0
|
|
const switchedModels: unknown[] = []
|
|
const resentPrompts: unknown[] = []
|
|
const promptResent = Promise.withResolvers<void>()
|
|
await using setup = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: async (url, request) => {
|
|
const location = { directory, project: { id: "proj_test", directory, canonical: directory } }
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
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: "opencode", name: "OpenCode Zen" },
|
|
...(goEntitled ? [{ id: "opencode-go", integrationID: "opencode", name: "OpenCode Go" }] : []),
|
|
],
|
|
})
|
|
if (url.pathname === "/api/model") {
|
|
onModelSyncRequest?.()
|
|
await modelSyncGate
|
|
if (failModelSync) return json({ message: "Model catalog sync failed" }, { status: 500 })
|
|
return json({ location, data: goEntitled ? [goModel, freeModel] : [freeModel] })
|
|
}
|
|
if (url.pathname === "/api/integration") {
|
|
await catalogGate
|
|
return json({
|
|
location,
|
|
data: [
|
|
{
|
|
id: "opencode",
|
|
name: "OpenCode Console",
|
|
methods: [
|
|
{ id: "device", type: "oauth", label: "OpenCode Console account" },
|
|
{ type: "key", label: "API key (service account)" },
|
|
],
|
|
connections: connected
|
|
? [
|
|
{
|
|
type: "credential",
|
|
method: "oauth",
|
|
id: "cred_console",
|
|
label: "Personal",
|
|
...(needsAuth ? { status: { type: "needs_auth", message: "Session expired" } } : {}),
|
|
},
|
|
{ type: "credential", method: "oauth", id: "cred_inactive", label: "Inactive backup" },
|
|
]
|
|
: [],
|
|
},
|
|
],
|
|
})
|
|
}
|
|
if (url.pathname === "/api/integration/opencode/connect/oauth" && request.method === "POST") {
|
|
oauthConnectCalls += 1
|
|
return json({
|
|
location,
|
|
data: {
|
|
attemptID: "attempt_no_go",
|
|
mode: "auto",
|
|
url: "https://opencode.ai/console/device?user_code=NOGO-5678",
|
|
instructions: "Enter code: NOGO-5678",
|
|
},
|
|
})
|
|
}
|
|
if (url.pathname === "/api/integration/opencode/connect/oauth/attempt_no_go") {
|
|
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
|
await oauthGate
|
|
if (failModelSync) connected = true
|
|
return json({ location, data: { status: "complete" } })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/model` && request.method === "POST") {
|
|
switchedModels.push(await request.json())
|
|
return new Response(null, { status: 204 })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/prompt` && request.method === "POST") {
|
|
resentPrompts.push(await request.json())
|
|
promptResent.resolve()
|
|
return json({ data: {} })
|
|
}
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_free_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
time: { created: 2 },
|
|
},
|
|
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
|
|
// 1a. Escape mid-auth: cancelling while oauth.status is in flight must not select a model or resubmit the prompt.
|
|
let releaseMidAuth!: () => void
|
|
oauthGate = new Promise<void>((resolve) => {
|
|
releaseMidAuth = resolve
|
|
})
|
|
goEntitled = true
|
|
setup.events.emit({
|
|
id: "evt_free_limit_mid_auth",
|
|
created: baseNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressEnter()
|
|
expect(openSpy).toHaveBeenCalledWith("https://opencode.ai/go")
|
|
await setup.waitForFrame((frame) => frame.includes("Connect an integration"))
|
|
releaseCatalog()
|
|
await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…"))
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…"))
|
|
releaseMidAuth()
|
|
await waitForGoUpsellConnect()
|
|
expect(switchedModels).toHaveLength(0)
|
|
expect(resentPrompts).toHaveLength(0)
|
|
expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3)
|
|
|
|
// 1b. Escape mid-sync: cancelling while model sync is awaited must not select a model or resubmit the prompt.
|
|
currentNow = baseNow + 86_400_000
|
|
oauthGate = Promise.resolve()
|
|
let releaseMidSync!: () => void
|
|
const midSyncStarted = Promise.withResolvers<void>()
|
|
onModelSyncRequest = () => midSyncStarted.resolve()
|
|
modelSyncGate = new Promise<void>((resolve) => {
|
|
releaseMidSync = resolve
|
|
})
|
|
await setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_mid_sync_start",
|
|
created: 3,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 2, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", agent: "build", model, started: 3 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_mid_sync_fail",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 3, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_2", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…"))
|
|
await midSyncStarted.promise
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…"))
|
|
releaseMidSync()
|
|
await waitForGoUpsellConnect()
|
|
onModelSyncRequest = undefined
|
|
expect(switchedModels).toHaveLength(0)
|
|
expect(resentPrompts).toHaveLength(0)
|
|
expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3)
|
|
|
|
// 2. Model-sync rejection: catches the failure, closes "Waiting for authorization…", shows the error, and keeps the recalled prompt.
|
|
currentNow = baseNow + 2 * 86_400_000
|
|
modelSyncGate = Promise.resolve()
|
|
failModelSync = true
|
|
await setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_sync_err_start",
|
|
created: 5,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 4, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", agent: "build", model, started: 5 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_sync_err_fail",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 5, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame(
|
|
(frame) => !frame.includes("Waiting for authorization…") && frame.includes("UnexpectedStatus: 500"),
|
|
)
|
|
expect(switchedModels).toHaveLength(0)
|
|
expect(resentPrompts).toHaveLength(0)
|
|
expect(setup.captureCharFrame().split("Refactor the parser").length).toBe(3)
|
|
|
|
// 3a. Expired/needs_auth active credential (even with a status-free inactive backup): refresh detects needs_auth and routes to device sign-in without submitting.
|
|
failModelSync = false
|
|
goEntitled = true
|
|
connected = true
|
|
needsAuth = true
|
|
let releaseNeedsAuthOAuth!: () => void
|
|
oauthGate = new Promise<void>((resolve) => {
|
|
releaseNeedsAuthOAuth = resolve
|
|
})
|
|
currentNow = baseNow + 3 * 86_400_000
|
|
await setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_expired_start",
|
|
created: 6,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 6, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_expired", agent: "build", model, started: 6 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_expired_fail",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 7, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_expired", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
const oauthCallsBeforeExpired = oauthConnectCalls
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Waiting for authorization…") && frame.includes("NOGO-5678"))
|
|
expect(oauthConnectCalls).toBe(oauthCallsBeforeExpired + 1)
|
|
expect(switchedModels).toHaveLength(0)
|
|
expect(resentPrompts).toHaveLength(0)
|
|
// Complete sign-in so the refreshed active credential becomes valid again, then cancel before testing already-connected.
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Waiting for authorization…"))
|
|
releaseNeedsAuthOAuth()
|
|
await waitForGoUpsellConnect()
|
|
|
|
// 3b. Cached active credential with needs_auth (and status-free inactive backup): goes straight to device sign-in, then once re-authenticated without Go lands on /models.
|
|
oauthGate = Promise.resolve()
|
|
goEntitled = false
|
|
currentNow = baseNow + 4 * 86_400_000
|
|
await setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_no_go_start",
|
|
created: 7,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 8, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_4", agent: "build", model, started: 7 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_no_go_fail",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 9, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_4", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
needsAuth = false
|
|
const oauthCallsBeforeCachedExpired = oauthConnectCalls
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => frame.includes("Select model") && frame.includes("Big Pickle"))
|
|
expect(oauthConnectCalls).toBe(oauthCallsBeforeCachedExpired + 1)
|
|
setup.mockInput.pressEscape()
|
|
await setup.waitForFrame((frame) => !frame.includes("Select model"))
|
|
|
|
// 3c. Already-connected valid account with Go entitlement: skips device OAuth, refreshes catalog, selects Go, and resends the prompt.
|
|
const oauthCallsBeforeAlreadyConnected = oauthConnectCalls
|
|
goEntitled = true
|
|
currentNow = baseNow + 5 * 86_400_000
|
|
await setup.mockInput.pressKey("c", { ctrl: true })
|
|
await setup.waitForFrame((frame) => frame.split("Refactor the parser").length === 2)
|
|
setup.events.emit({
|
|
id: "evt_free_limit_already_go_start",
|
|
created: 9,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 10, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_5", agent: "build", model, started: 9 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_already_go_fail",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 11, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_5", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressEnter()
|
|
await promptResent.promise
|
|
await setup.waitForFrame((frame) => !frame.includes("Free limit reached") && frame.includes("GLM-5 Go"))
|
|
expect(oauthConnectCalls).toBe(oauthCallsBeforeAlreadyConnected)
|
|
expect(switchedModels).toEqual([{ model: { providerID: "opencode-go", id: "glm-5-go" } }])
|
|
expect(resentPrompts).toHaveLength(1)
|
|
expect(resentPrompts[0]).toMatchObject({ text: "Refactor the parser" })
|
|
|
|
// Advance past 24h, trigger again, and choose "don't show again".
|
|
currentNow = baseNow + 6 * 86_400_000
|
|
setup.events.emit({
|
|
id: "evt_free_limit_key_2",
|
|
created: 11,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 12, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_6", agent: "build", model, started: 11 },
|
|
})
|
|
setup.events.emit({
|
|
id: "evt_free_limit_key_3",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 13, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_6", error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
setup.mockInput.pressArrow("right")
|
|
setup.mockInput.pressEnter()
|
|
await setup.waitForFrame((frame) => !frame.includes("Free limit reached"))
|
|
await Bun.sleep(30)
|
|
|
|
// Restart against the same state directory past another 48h: fresh in-memory store still respects persisted dontShow.
|
|
currentNow = baseNow + 8 * 86_400_000
|
|
await using restarted = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === "/api/integration")
|
|
return json({
|
|
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
|
data: [{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] }],
|
|
})
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_free_assistant_3",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
time: { created: 8 },
|
|
},
|
|
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await restarted.ready
|
|
await restarted.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
restarted.events.emit({
|
|
id: "evt_free_limit_key_restart",
|
|
created: currentNow,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant_3", error },
|
|
})
|
|
await restarted.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at"))
|
|
await Bun.sleep(30)
|
|
await restarted.renderOnce()
|
|
expect(restarted.captureCharFrame()).not.toContain("Free limit reached")
|
|
})
|
|
|
|
test("free usage limit claims suppression atomically across two TUI instances and protects typing drafts", async () => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_free_limit_race",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Free limit race",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const model = { id: "big-pickle", providerID: "opencode" }
|
|
const error = {
|
|
type: "provider.free-limit",
|
|
message: "Free usage limit reached.",
|
|
status: 429,
|
|
retryAfterMs: 3_600_000,
|
|
}
|
|
const fetch = (url: URL) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === "/api/integration")
|
|
return json({
|
|
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
|
data: [
|
|
{
|
|
id: "opencode-go",
|
|
name: "OpenCode Go",
|
|
methods: [{ id: "device", type: "oauth", label: "Sign in with your OpenCode account" }, { type: "key" }],
|
|
connections: [],
|
|
},
|
|
],
|
|
})
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_free_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
time: { created: 2 },
|
|
},
|
|
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
}
|
|
await using first = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch,
|
|
})
|
|
await using second = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch,
|
|
})
|
|
await Promise.all([first.ready, second.ready])
|
|
await Promise.all([
|
|
first.waitForFrame((frame) => frame.includes("Refactor the parser")),
|
|
second.waitForFrame((frame) => frame.includes("Refactor the parser")),
|
|
])
|
|
// Emit concurrently in both processes: only one claims the 24h window and opens the modal.
|
|
const event = {
|
|
id: "evt_free_limit_race_1",
|
|
created: 3,
|
|
type: "session.step.failed" as const,
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 as const },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
|
|
}
|
|
first.events.emit(event)
|
|
second.events.emit(event)
|
|
await Promise.all([
|
|
first.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")),
|
|
second.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at")),
|
|
])
|
|
await first.waitForFrame(
|
|
() =>
|
|
first.captureCharFrame().includes("Free limit reached") ||
|
|
second.captureCharFrame().includes("Free limit reached"),
|
|
)
|
|
await Bun.sleep(30)
|
|
await Promise.all([first.renderOnce(), second.renderOnce()])
|
|
const opened = [first.captureCharFrame(), second.captureCharFrame()].filter((frame) =>
|
|
frame.includes("Free limit reached"),
|
|
)
|
|
expect(opened).toHaveLength(1)
|
|
|
|
// Typing draft protection: does not overwrite a draft or open Free limit reached while typing.
|
|
await using typingState = await tmpdir()
|
|
await using typing = await createAppFixture({
|
|
width: 100,
|
|
state: typingState.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch,
|
|
})
|
|
await typing.ready
|
|
await typing.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
await typing.mockInput.typeText("Draft in progress")
|
|
await typing.waitForFrame((frame) => frame.includes("Draft in progress"))
|
|
typing.events.emit(event)
|
|
await typing.waitForFrame((frame) => frame.includes("You've used today's free messages. They reset at"))
|
|
const frame = typing.captureCharFrame()
|
|
expect(frame).toContain("Draft in progress")
|
|
expect(frame.split("Refactor the parser").length).toBe(2)
|
|
expect(frame).not.toContain("Free limit reached")
|
|
expect(frame).not.toContain("Connect OpenCode Go")
|
|
})
|
|
|
|
test("Go usage limit restores the prompt and explains the limit with its reset time instead of an error", async () => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_go_limit",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Go limit",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const error = {
|
|
type: "provider.go-limit",
|
|
message: "OpenCode Go 5 hour usage limit reached.",
|
|
status: 429,
|
|
response: {
|
|
body: JSON.stringify({
|
|
type: "error",
|
|
error: { type: "GoUsageLimitError", message: "Go usage limit exceeded" },
|
|
metadata: { workspace: "wrk_go_limit", limitName: "5 hour" },
|
|
}),
|
|
},
|
|
retryAfterMs: 3_600_000,
|
|
}
|
|
await using setup = await createAppFixture({
|
|
width: 100,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_go_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model: { id: "glm-5-go", providerID: "opencode-go" },
|
|
content: [],
|
|
time: { created: 2 },
|
|
},
|
|
{ id: "msg_go_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
setup.events.emit({
|
|
id: "evt_go_limit_1",
|
|
created: 3,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_go_assistant", error },
|
|
})
|
|
await setup.waitForFrame(
|
|
(frame) =>
|
|
frame.includes("OpenCode Go 5 hour usage limit reached") &&
|
|
frame.includes("Resets ") &&
|
|
frame.split("Refactor the parser").length === 3,
|
|
)
|
|
const frame = setup.captureCharFrame()
|
|
expect(frame).toContain("Switch to a free model with")
|
|
expect(frame).toContain("https://opencode.ai/console/wrk_go_limit/go")
|
|
expect(frame).not.toContain("Error:")
|
|
expect(frame).not.toContain("Free limit reached")
|
|
})
|
|
|
|
test("free usage limit modal manages live animation FPS and adapts to 44x24 and 60x12 terminals", async () => {
|
|
const session = {
|
|
id: "ses_free_limit_anim",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Free limit anim",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const model = { id: "big-pickle", providerID: "opencode" }
|
|
const error = {
|
|
type: "provider.free-limit",
|
|
message: "Free usage limit reached.",
|
|
status: 429,
|
|
retryAfterMs: 3_600_000,
|
|
}
|
|
const fetch = (url: URL) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_free_assistant",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
time: { created: 2 },
|
|
},
|
|
{ id: "msg_free_user", type: "user", text: "Refactor the parser", time: { created: 1 } },
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
}
|
|
const findArt = (node: Renderable): GoUpsellArtRenderable | undefined => {
|
|
if (node instanceof GoUpsellArtRenderable) return node
|
|
for (const child of node.getChildren()) {
|
|
const found = findArt(child)
|
|
if (found) return found
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
await using animState = await tmpdir()
|
|
await using anim = await createAppFixture({
|
|
width: 100,
|
|
height: 30,
|
|
state: animState.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: true, tabs: { mode: "off" } },
|
|
fetch,
|
|
})
|
|
await anim.ready
|
|
await anim.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
const initialTargetFps = anim.renderer.targetFps
|
|
const initialMaxFps = anim.renderer.maxFps
|
|
const initialLive = anim.renderer.liveRequestCount
|
|
anim.events.emit({
|
|
id: "evt_free_limit_anim_1",
|
|
created: 3,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
|
|
})
|
|
await anim.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
const art = findArt(anim.renderer.root)
|
|
expect(art).toBeDefined()
|
|
expect(art!.live).toBe(true)
|
|
expect(art!.isDestroyed).toBe(false)
|
|
expect(anim.renderer.targetFps).toBe(30)
|
|
expect(anim.renderer.maxFps).toBe(30)
|
|
// Toggling animate off disables live updates; toggling back on re-enables them.
|
|
art!.animate = false
|
|
expect(art!.live).toBe(false)
|
|
art!.animate = true
|
|
expect(art!.live).toBe(true)
|
|
// Closing the modal destroys the art renderable and restores FPS.
|
|
anim.mockInput.pressEscape()
|
|
await anim.waitForFrame((frame) => !frame.includes("Free limit reached"))
|
|
expect(art!.isDestroyed).toBe(true)
|
|
expect(art!.live).toBe(false)
|
|
expect(findArt(anim.renderer.root)).toBeUndefined()
|
|
expect(anim.renderer.liveRequestCount).toBe(initialLive)
|
|
expect(anim.renderer.targetFps).toBe(initialTargetFps)
|
|
expect(anim.renderer.maxFps).toBe(initialMaxFps)
|
|
|
|
// Compact sizes: 44x24 (narrow, stacked buttons) and 60x12 (short, scrollable body).
|
|
for (const [width, height] of [
|
|
[44, 24],
|
|
[60, 12],
|
|
] as const) {
|
|
await using compactState = await tmpdir()
|
|
await using compact = await createAppFixture({
|
|
width,
|
|
height,
|
|
state: compactState.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: true, tabs: { mode: "off" } },
|
|
fetch,
|
|
})
|
|
await compact.ready
|
|
await compact.waitForFrame((frame) => frame.includes("Refactor the parser"))
|
|
compact.events.emit({
|
|
id: `evt_free_limit_${width}x${height}`,
|
|
created: 3,
|
|
type: "session.step.failed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_free_assistant", error },
|
|
})
|
|
await compact.waitForFrame((frame) => frame.includes("Free limit reached"))
|
|
const frame = compact.captureCharFrame()
|
|
expect(frame).toContain("Free limit reached")
|
|
expect(frame).toContain("don't show again")
|
|
expect(frame).toContain("dismiss")
|
|
expect(frame).toContain("subscribe")
|
|
expect(findArt(compact.renderer.root)).toBeUndefined()
|
|
// Locate "dismiss" in the rendered frame and click it with the mouse.
|
|
const lines = frame.split("\n")
|
|
const row = lines.findIndex((line) => line.includes("dismiss"))
|
|
expect(row).toBeGreaterThanOrEqual(0)
|
|
const col = lines[row]!.indexOf("dismiss") + 2
|
|
await compact.mockMouse.click(col, row)
|
|
await compact.waitForFrame((next) => !next.includes("Free limit reached"))
|
|
}
|
|
})
|
|
|
|
test.each([44, 100])(
|
|
"retry countdown updates and clears with the retry lifecycle at width %s",
|
|
async (width) => {
|
|
await using state = await tmpdir()
|
|
const session = {
|
|
id: "ses_countdown",
|
|
projectID: "proj_test",
|
|
location: { directory },
|
|
title: "Retry countdown",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 1, updated: 1 },
|
|
}
|
|
const model = { id: "model", providerID: "provider" }
|
|
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
|
|
await using setup = await createAppFixture({
|
|
width,
|
|
state: state.path,
|
|
args: { sessionID: session.id },
|
|
config: { animations: false, tabs: { mode: "off" } },
|
|
fetch: (url) => {
|
|
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
|
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
|
if (url.pathname === `/api/session/${session.id}/message`)
|
|
return json({
|
|
data: [
|
|
{
|
|
id: "msg_countdown",
|
|
type: "assistant",
|
|
agent: "build",
|
|
model,
|
|
content: [],
|
|
error,
|
|
retry: { attempt: 2, at: Date.now() + 2_500, error },
|
|
time: { created: 1 },
|
|
},
|
|
],
|
|
cursor: {},
|
|
})
|
|
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
|
return json({ data: [] })
|
|
return undefined
|
|
},
|
|
})
|
|
await setup.ready
|
|
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
|
|
expect(setup.captureCharFrame()).toContain("attempt 2")
|
|
expect(setup.captureCharFrame()).toContain("Provider unavailable")
|
|
expect(setup.captureCharFrame()).not.toContain("Error:")
|
|
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
|
|
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
|
|
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
|
|
expect(setup.captureCharFrame()).not.toContain("in 0s")
|
|
|
|
setup.events.emit({
|
|
id: "evt_countdown_rescheduled",
|
|
created: 2,
|
|
type: "session.retry.scheduled",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
|
|
setup.events.emit({
|
|
id: "evt_countdown_started",
|
|
created: 3,
|
|
type: "session.step.started",
|
|
durable: { aggregateID: session.id, seq: 2, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model, started: 3 },
|
|
})
|
|
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
|
|
|
|
setup.events.emit({
|
|
id: "evt_countdown_expired",
|
|
created: 4,
|
|
type: "session.retry.scheduled",
|
|
durable: { aggregateID: session.id, seq: 3, version: 1 },
|
|
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
|
|
})
|
|
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
|
|
expect(setup.captureCharFrame()).not.toContain("in -")
|
|
setup.events.emit({
|
|
id: "evt_countdown_interrupted",
|
|
created: 5,
|
|
type: "session.execution.interrupted",
|
|
durable: { aggregateID: session.id, seq: 4, version: 1 },
|
|
data: { sessionID: session.id, reason: "shutdown" },
|
|
})
|
|
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
|
|
},
|
|
15_000,
|
|
)
|
|
|