Compare commits

..
8 changed files with 180 additions and 122 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
- The default branch in this repo is `v2`.
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
- Default new branches and worktrees to `v2`, or `origin/v2` when the local `v2` ref is unavailable, and default pull requests to target `v2`. Use another base or target branch when the requester explicitly instructs it.
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
## Live V2 TUI Testing
@@ -54,7 +54,11 @@ function managedService(options: EnsureOptions) {
const reconnectOptions = { ...options, version: undefined }
return {
reconnect: () => Service.ensure(reconnectOptions),
restart: () => Service.replace(reconnectOptions).pipe(Effect.asVoid),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.ensure(reconnectOptions)
}),
}
}
+15 -28
View File
@@ -51,20 +51,10 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
// becomes discoverable. A contender is never killed merely for slow startup.
/** Ensure a healthy, compatible local service is running. */
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
return yield* ensureService(options, false)
})
/** Replace the registered local service while preserving persistent terminals. */
export const replace = Effect.fn("service.replace")(function* (options: EnsureOptions = {}) {
return yield* ensureService(options, true)
})
const ensureService = Effect.fnUntraced(function* (options: EnsureOptions, forceReplacement: boolean) {
const timing = ensureTiming(options)
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let replaceCurrent = forceReplacement
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
@@ -98,7 +88,6 @@ const ensureService = Effect.fnUntraced(function* (options: EnsureOptions, force
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
yield* terminate(info, options, timing)
replaceCurrent = false
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -106,29 +95,23 @@ const ensureService = Effect.fnUntraced(function* (options: EnsureOptions, force
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && matchesVersion(service.version, options)
if (!replaceCurrent && compatible && service.state === "ready") {
if (compatible && service.state === "ready") {
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
return Option.some(service)
}
if (!replaceCurrent && compatible && service.state === "failed")
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
if (!replaceCurrent && compatible) return Option.none<LocalService>()
if (compatible) return Option.none<LocalService>()
yield* announce("version-mismatch", service.version)
replaceCurrent = false
if (!service.legacy && service.state === "ready")
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout),
)
else {
if (!service.legacy)
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
}
yield* terminate(service.info, options, timing).pipe(Effect.ignore)
if (!service.legacy && service.state !== "ready")
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
yield* stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).pipe(Effect.ignore)
lastSpawn = 0
return Option.none<LocalService>()
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
else if (info === undefined) replaceCurrent = false
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
@@ -158,8 +141,12 @@ const ensureService = Effect.fnUntraced(function* (options: EnsureOptions, force
/** Stop the registered local service. */
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
const info = yield* read(options.file)
if (options.pty === "handoff" && info !== undefined)
yield* Effect.tryPromise(() =>
PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout),
)
else yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
if (info !== undefined) yield* terminate(info, options, defaultEnsureTiming)
})
@@ -307,4 +294,4 @@ const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly f
})
/** Effect-based local service lifecycle operations. */
export const Service = { discover, incumbent, ensure, replace, stop, headers, Info }
export const Service = { discover, incumbent, ensure, stop, headers, Info }
+13 -27
View File
@@ -31,21 +31,11 @@ export async function discover(options: DiscoverOptions = {}) {
/** Ensure a healthy, compatible local service is running. */
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
return ensureService(options, false)
}
/** Replace the registered local service while preserving persistent terminals. */
export async function replace(options: EnsureOptions = {}): Promise<Endpoint> {
return ensureService(options, true)
}
async function ensureService(options: EnsureOptions, forceReplacement: boolean): Promise<Endpoint> {
const timing = ensureTiming(options)
const deadline = Date.now() + timing.promiseTimeout
const contenders = new Set<ServiceContender>()
let timeouts: { readonly info: Info; readonly count: number } | undefined
let announced = false
let replaceCurrent = forceReplacement
let lastSpawn = 0
let spawnDelay = timing.spawnDelay
@@ -78,7 +68,6 @@ async function ensureService(options: EnsureOptions, forceReplacement: boolean):
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
await terminate(registration.info, options, timing)
replaceCurrent = false
timeouts = undefined
lastSpawn = Date.now() - spawnDelay
}
@@ -88,27 +77,22 @@ async function ensureService(options: EnsureOptions, forceReplacement: boolean):
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && matchesVersion(service.version, options)
if (!replaceCurrent && compatible && service.state === "ready") {
if (compatible && service.state === "ready") {
await PtyHandoff.complete(options.file ?? fallback(), service.info)
return service.endpoint
}
if (!replaceCurrent && compatible && service.state === "failed")
throw new Error("Background service failed to start")
if (replaceCurrent || !compatible) {
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
announce("version-mismatch", service.version)
replaceCurrent = false
if (!service.legacy && service.state === "ready")
await PtyHandoff.prepare(options.file ?? fallback(), service.info, timing.requestTimeout)
else {
if (!service.legacy)
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await PtyHandoff.clear(options.file ?? fallback())
}
await terminate(service.info, options, timing).catch(() => undefined)
if (!service.legacy && service.state !== "ready")
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
await stop({
file: options.file,
pty: !service.legacy && service.state === "ready" ? "handoff" : "clear",
}).catch(() => undefined)
lastSpawn = 0
}
} else {
if (registration.info === undefined) replaceCurrent = false
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
const finished = [...contenders].filter(contenderFinished)
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
@@ -133,8 +117,10 @@ async function ensureService(options: EnsureOptions, forceReplacement: boolean):
/** Stop the registered local service. */
export async function stop(options: StopOptions = {}) {
await PtyHandoff.clear(options.file ?? fallback())
const info = await read(options.file)
if (options.pty === "handoff" && info !== undefined)
await PtyHandoff.prepare(options.file ?? fallback(), info, defaultEnsureTiming.requestTimeout)
else await PtyHandoff.clear(options.file ?? fallback())
if (info !== undefined) await terminate(info, options, defaultEnsureTiming)
}
@@ -266,4 +252,4 @@ function delay(milliseconds: number) {
}
/** Promise-based local service lifecycle operations. */
export const Service = { discover, ensure, replace, stop, headers }
export const Service = { discover, ensure, stop, headers }
+2
View File
@@ -38,6 +38,8 @@ export type EnsureOptions = DiscoverOptions & {
export type StopOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** How to handle persistent terminals before stopping the service. */
readonly pty?: "clear" | "handoff"
}
/** Contents of the local service registration file. */
+31 -4
View File
@@ -1269,6 +1269,25 @@ export function Prompt(props: PromptProps) {
const target = sessionID
history.append(entry)
const prepareActionSelection = async () => {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
if (
session?.model?.providerID === selection.providerID &&
session.model.id === selection.modelID &&
(session.model.variant ?? "default") === (variant ?? "default")
)
return
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(target, model)
await client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
}
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
if (setup) void setup.gate.then(send).catch(setup.recover)
@@ -1276,11 +1295,15 @@ export function Prompt(props: PromptProps) {
}
if (currentMode === "shell") {
move.startSubmit()
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
dispatch(async () => {
await prepareActionSelection()
return client.api.session.shell({ sessionID: target, command: inputText })
})
setStore("mode", "normal")
} else if (slashHead && isCommand) {
const send = () =>
client.api.session.command({
const send = async () => {
await prepareActionSelection()
return client.api.session.command({
sessionID: target,
command: slashHead.name,
text: slashHead.arguments,
@@ -1289,6 +1312,7 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
})
}
const setup = newSession
void (setup ? setup.gate.then(send) : send()).catch((error) => {
if (setup) return setup.recover(error)
@@ -1297,7 +1321,10 @@ export function Prompt(props: PromptProps) {
})
} else if (isSkill) {
move.startSubmit()
dispatch(() => client.api.session.skill({ sessionID: target, skill: slashHead.name }))
dispatch(async () => {
await prepareActionSelection()
return client.api.session.skill({ sessionID: target, skill: slashHead.name })
})
} else {
move.startSubmit()
try {
+1 -61
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -921,66 +921,6 @@ test("error investigations repeatedly seed editable home drafts without creating
await setup.waitForFrame((frame) => !frame.includes("Beta initialization failed"))
})
test("shows jump to latest after scrolling one line above the final message", async () => {
const session = {
id: "dummy",
title: "Demo session",
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 },
}
const messages = Array.from({ length: 8 }, (_, index) => ({
id: `message-${index}`,
type: "user",
text: index === 7 ? "Final visible message" : `Earlier message ${index}`,
time: { created: index },
}))
await using setup = await createAppFixture({
width: 80,
height: 20,
config: { animations: false, keybinds: { "session.line.up": "f6", "session.line.down": "f7" } },
args: { sessionID: "dummy" },
fetch: (url) => {
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: messages.toReversed(), cursor: {} })
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
},
})
await setup.waitForFrame((frame) => frame.includes("Final visible message"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-7")
? root
: root.getChildren().map(findScrollBox).find(Boolean)
const scroll = findScrollBox(setup.renderer.root)
expect(scroll).toBeDefined()
if (!scroll) throw new Error("session transcript scrollbox was not found")
const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height)
expect(scroll.scrollTop).toBe(maximum())
const initial = setup.captureCharFrame().split("\n")
expect(initial.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(initial[initial.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
setup.mockInput.pressKey("F6")
const clipped = (await setup.waitForFrame((frame) => frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum() - 1)
expect(clipped.find((line) => line.includes("Jump to latest"))).toBeDefined()
expect(clipped[clipped.findIndex((line) => line.includes("Final visible message")) + 1]).not.toContain("┃")
setup.mockInput.pressKey("F7")
const restored = (await setup.waitForFrame((frame) => !frame.includes("Jump to latest"))).split("\n")
expect(scroll.scrollTop).toBe(maximum())
expect(restored.find((line) => line.includes("Jump to latest"))).toBeUndefined()
expect(restored[restored.findIndex((line) => line.includes("Final visible message")) + 1]).toContain("┃")
})
test("completed user shell output replaces a partial live read when the final read fails", async () => {
await using state = await tmpdir()
const session = {
@@ -233,3 +233,115 @@ test.each(["first", "second"])(
}
},
)
test.each([
{ name: "command", text: "/deploy now", endpoint: "command", change: true },
{ name: "skill", text: "/tiger", endpoint: "skill", change: true },
{ name: "shell", text: "echo hi", endpoint: "shell", change: true },
{ name: "unchanged command", text: "/deploy now", endpoint: "command", change: false },
])("prepares the prompt selection before $name submission", async ({ name, text, endpoint, change }) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const invoked = Promise.withResolvers<void>()
const events = createEventStream()
const sessionID = `ses_${endpoint}_${change ? "changed" : "unchanged"}`
const location = { directory, project: { id: "project", directory, canonical: directory } }
const mutations: string[] = []
const calls = createFetch(async (url, request) => {
if (url.pathname === `/api/session/${sessionID}`)
return json({
data: {
id: sessionID,
projectID: "project",
title: `${name} selection fixture`,
agent: "review",
model: { providerID: "demo", id: "current" },
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/${sessionID}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${sessionID}/inbox` || url.pathname === `/api/session/${sessionID}/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: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({
location,
data: ["current", "selected"].map((id) => ({
id,
providerID: "demo",
name: `${id} model`,
variants: [],
cost: [],
time: { released: 0 },
})),
})
if (url.pathname === "/api/command")
return json({ location, data: [{ name: "deploy", description: "Deploy", template: "" }] })
if (url.pathname === "/api/skill")
return json({
location,
data: [{ id: "tiger", name: "Tiger", description: "Tiger", slash: true, location: directory, content: "" }],
})
if (request.method === "POST" && url.pathname === `/api/session/${sessionID}/agent`) {
mutations.push(`agent:${(await request.json()).agent}`)
return new Response(null, { status: 204 })
}
if (request.method === "POST" && url.pathname === `/api/session/${sessionID}/model`) {
mutations.push(`model:${(await request.json()).model.id}`)
return new Response(null, { status: 204 })
}
if (request.method === "POST" && url.pathname === `/api/session/${sessionID}/${endpoint}`) {
mutations.push(endpoint)
invoked.resolve()
return new Response(null, { status: 204 })
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
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 }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: { sessionID },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("current model"))
if (change) {
await setup.mockInput.typeText("/models")
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
await setup.mockInput.typeText("selected")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("selected model") && !frame.includes("Select model"))
}
if (name === "shell") setup.mockInput.typeText("!")
await setup.mockInput.typeText(text)
if (name === "skill") setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await invoked.promise
expect(mutations).toEqual(["agent:build", ...(change ? ["model:selected"] : []), endpoint])
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
})