From 7731f1cfe234a01978b430b6c23fed074ad6e702 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 30 Aug 2026 23:36:39 +0200 Subject: [PATCH] fix(coding-agent): remove obsolete plugin app fixture --- .../test/fixtures/plugin-app/README.md | 44 --- .../test/fixtures/plugin-app/lib/api.ts | 52 ---- .../test/fixtures/plugin-app/lib/client.ts | 167 ----------- .../test/fixtures/plugin-app/lib/index.ts | 4 - .../test/fixtures/plugin-app/lib/protocol.ts | 19 -- .../test/fixtures/plugin-app/lib/session.ts | 158 ---------- .../test/fixtures/plugin-app/lib/transport.ts | 266 ----------------- .../fixtures/plugin-app/model-app/plugins.ts | 158 ---------- .../plugin-app/model-app/providers.ts | 152 ---------- .../plugin-app/model-app/run-session.ts | 40 --- .../fixtures/plugin-app/model-app/run-tui.ts | 29 -- .../fixtures/plugin-app/model-app/services.ts | 45 --- .../fixtures/plugin-app/model-app/tui/app.ts | 272 ------------------ .../plugin-app/model-app/tui/footer.ts | 43 --- .../model-app/tui/model-selector.ts | 171 ----------- .../plugin-app/model-app/tui/shared.ts | 47 --- .../test/fixtures/plugin-app/run.sh | 33 --- .../plugin-app-model-vertical-slice.test.ts | 112 -------- .../coding-agent/test/plugin-app-tui.test.ts | 201 ------------- 19 files changed, 2013 deletions(-) delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/README.md delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/api.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/client.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/index.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/protocol.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/session.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/lib/transport.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/providers.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/run-session.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/run-tui.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts delete mode 100644 packages/coding-agent/test/fixtures/plugin-app/model-app/tui/shared.ts delete mode 100755 packages/coding-agent/test/fixtures/plugin-app/run.sh delete mode 100644 packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts delete mode 100644 packages/coding-agent/test/plugin-app-tui.test.ts diff --git a/packages/coding-agent/test/fixtures/plugin-app/README.md b/packages/coding-agent/test/fixtures/plugin-app/README.md deleted file mode 100644 index 2442c3cad..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Remote plugin application prototype - -Run both processes from the repository root: - -```bash -packages/coding-agent/test/fixtures/plugin-app/run.sh -``` - -## Layout - -```text -lib/ Reusable plugin and transport library - api.ts Service tokens, plugins, ReplicatedState, RPC cancellation - client.ts Typed client proxies and replicated state - session.ts Session service host and lifecycle - protocol.ts Generic JSON wire messages - transport.ts TCP and loopback transports - -model-app/ Example application built on the library - services.ts Shared model service contract - plugins.ts Session and client plugin halves - providers.ts Application-specific provider composition - tui/ Client-local commands, views, and rendering - run-session.ts Session process entry point - run-tui.ts TUI process entry point -``` - -The library knows nothing about models, providers, commands, or TUI components. The application knows nothing about snapshots, RPC request IDs, cancellation messages, or TCP framing. - -## Plugin author model - -1. Define a typed service token in `model-app/services.ts`. -2. In `plugin.session()`, create replicated state and provide the service implementation. -3. In `plugin.client()`, use the typed proxy and register client-local UI. -4. Add the plugin to the application's plugin list. - -Replicated state and ordinary method arguments/results must be JSON-serializable. `RpcOptions` are transport metadata and travel out of band. - -## Disconnect contract - -- A client disconnect removes its state subscription and aborts its in-flight RPC calls. The session continues so another client can reconnect. -- A session disconnect rejects pending and future client RPC calls. The client keeps its last snapshot as stale display data and exposes `store.connection` as disconnected. -- Reconnection creates a new transport and client and starts from a fresh authoritative snapshot. Replay and automatic reconnect are intentionally outside this prototype. -- The application decides when an unattended session should exit, for example immediately, after an idle timeout, or never. diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts deleted file mode 100644 index 4abeab3f5..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/api.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Shared tokens and two-sided plugin shape. - * Replicated state values and ordinary remote method arguments/results must be JSON-serializable. - * RpcOptions are recognized by the proxy and sent through the transport control plane. - */ - -export interface Service { - readonly id: string; - readonly _type?: T; -} - -export function defineService(id: string): Service { - return { id }; -} - -const rpcOptionsMarker = Symbol("rpc-options"); - -export interface RpcOptions { - /** Set by the session runtime. Client callers cannot override it. */ - readonly clientId?: string; - readonly signal?: AbortSignal; - readonly [rpcOptionsMarker]: true; -} - -export function rpcOptions(options: { clientId?: string; signal?: AbortSignal } = {}): RpcOptions { - return { ...options, [rpcOptionsMarker]: true }; -} - -export function isRpcOptions(value: unknown): value is RpcOptions { - return typeof value === "object" && value !== null && rpcOptionsMarker in value; -} - -export interface ReplicatedState { - readonly value: T; - subscribe(listener: (value: T) => void): () => void; -} - -export interface MutableReplicatedState extends ReplicatedState { - set(value: T): void; -} - -export interface AppPlugin { - id: string; - session?(context: SessionContext): void | Promise; - client?(context: ClientContext): void | Promise; -} - -export function definePlugin( - plugin: AppPlugin, -): AppPlugin { - return plugin; -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts deleted file mode 100644 index 8ff4b76cc..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/client.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { isRpcOptions, type ReplicatedState, type Service } from "./api.ts"; -import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; - -export interface ClientTransport { - close(): void; - request(request: SessionRequest, signal?: AbortSignal): Promise; - start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void; -} - -export type ConnectionState = - | { status: "connecting" } - | { status: "connected" } - | { status: "disconnected"; reason: string }; - -class ClientReplicatedState implements ReplicatedState { - private readonly listeners = new Set<(value: T) => void>(); - private current: T; - - constructor(initial: T) { - this.current = structuredClone(initial); - } - - get value(): T { - return this.current; - } - - set(value: T): void { - this.current = structuredClone(value); - for (const listener of this.listeners) listener(this.current); - } - - subscribe(listener: (value: T) => void): () => void { - this.listeners.add(listener); - listener(this.current); - return () => this.listeners.delete(listener); - } -} - -export class ClientStateStore { - readonly connection = new ClientReplicatedState({ status: "connecting" }); - updates = 0; - private readonly listeners = new Set<() => void>(); - private readonly states = new Map>(); - - apply(message: ServerWireMessage): void { - if (message.type === "response") return; - if (message.type === "snapshot") this.replace(message.states); - else this.update(message.service, message.property, message.value); - this.notify(); - } - - connected(): void { - this.connection.set({ status: "connected" }); - this.notify(); - } - - disconnected(error?: Error): void { - this.connection.set({ status: "disconnected", reason: error?.message ?? "Connection closed" }); - this.notify(); - } - - get(service: string, property: string): ReplicatedState { - const state = this.states.get(`${service}.${property}`); - if (!state) throw new Error(`Replicated state not provided: ${service}.${property}`); - return state as ReplicatedState; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - listener(); - return () => this.listeners.delete(listener); - } - - private notify(): void { - for (const listener of this.listeners) listener(); - } - - private replace(snapshot: StateSnapshot): void { - for (const [service, properties] of Object.entries(snapshot)) { - for (const [property, value] of Object.entries(properties)) this.update(service, property, value, false); - } - } - - private update(service: string, property: string, value: unknown, count = true): void { - const key = `${service}.${property}`; - const current = this.states.get(key); - if (current) current.set(value); - else this.states.set(key, new ClientReplicatedState(value)); - if (count) this.updates++; - } -} - -export class SessionClient { - readonly ready: Promise; - readonly store = new ClientStateStore(); - private readonly proxies = new Map(); - private readonly rejectReady: (error: Error) => void; - private readonly resolveReady: () => void; - private readonly transport: ClientTransport; - private receivedSnapshot = false; - - constructor(transport: ClientTransport) { - this.transport = transport; - let settleReady!: () => void; - let failReady!: (error: Error) => void; - this.ready = new Promise((resolve, reject) => { - settleReady = resolve; - failReady = reject; - }); - this.rejectReady = failReady; - this.resolveReady = settleReady; - transport.start( - (message) => { - this.store.apply(message); - if (message.type === "snapshot" && !this.receivedSnapshot) { - this.receivedSnapshot = true; - this.store.connected(); - this.resolveReady(); - } - }, - (error) => { - this.store.disconnected(error); - if (!this.receivedSnapshot) this.rejectReady(error ?? new Error("Session disconnected before snapshot")); - }, - ); - } - - close(): void { - this.transport.close(); - } - - use(service: Service): T { - let proxy = this.proxies.get(service.id); - if (!proxy) { - proxy = new Proxy( - {}, - { - get: (_target, property) => { - if (typeof property !== "string") return undefined; - try { - return this.store.get(service.id, property); - } catch { - return (...args: unknown[]) => { - const options = args.at(-1); - if (!isRpcOptions(options)) { - return this.transport.request({ type: "rpc", service: service.id, method: property, args }); - } - return this.transport.request( - { - type: "rpc", - service: service.id, - method: property, - args: args.slice(0, -1), - rpcOptions: true, - }, - options.signal, - ); - }; - } - }, - }, - ); - this.proxies.set(service.id, proxy); - } - return proxy as T; - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/index.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/index.ts deleted file mode 100644 index 8c36c3657..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./api.ts"; -export * from "./client.ts"; -export * from "./session.ts"; -export * from "./transport.ts"; diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/protocol.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/protocol.ts deleted file mode 100644 index 0bc33d30f..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/protocol.ts +++ /dev/null @@ -1,19 +0,0 @@ -export type StateSnapshot = Record>; - -export type SessionRequest = { - type: "rpc"; - service: string; - method: string; - args: unknown[]; - rpcOptions?: true; -}; - -export type ClientWireMessage = - | { type: "hello"; clientId: string } - | { type: "request"; id: number; request: SessionRequest } - | { type: "cancel"; id: number }; - -export type ServerWireMessage = - | { type: "snapshot"; states: StateSnapshot } - | { type: "state_update"; service: string; property: string; value: unknown } - | { type: "response"; id: number; result?: unknown; error?: string }; diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts deleted file mode 100644 index 411018701..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/session.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type AppPlugin, type MutableReplicatedState, rpcOptions, type Service } from "./api.ts"; -import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; - -class SessionReplicatedState implements MutableReplicatedState { - private readonly listeners = new Set<(value: T) => void>(); - private current: T; - - constructor(initial: T) { - this.current = structuredClone(initial); - } - - get value(): T { - return this.current; - } - - set(value: T): void { - this.current = structuredClone(value); - for (const listener of this.listeners) listener(this.current); - } - - subscribe(listener: (value: T) => void): () => void { - this.listeners.add(listener); - listener(this.current); - return () => this.listeners.delete(listener); - } -} - -class ClientHub { - private readonly connections = new Map void>(); - - connect(clientId: string, snapshot: () => StateSnapshot, send: (message: ServerWireMessage) => void): () => void { - const pending: ServerWireMessage[] = []; - let ready = false; - const connection = (message: ServerWireMessage) => { - if (ready) send(message); - else pending.push(message); - }; - this.connections.set(clientId, connection); - send({ type: "snapshot", states: structuredClone(snapshot()) }); - ready = true; - for (const message of pending) send(message); - return () => { - if (this.connections.get(clientId) === connection) this.connections.delete(clientId); - }; - } - - broadcast(message: ServerWireMessage): void { - for (const send of this.connections.values()) send(structuredClone(message)); - } -} - -export interface SessionContext { - onActivate(callback: () => void | Promise): void; - onClientConnect(callback: (clientId: string) => void): void; - onClientDisconnect(callback: (clientId: string) => void): void; - onClose(callback: () => void): void; - provide(service: Service, implementation: T): void; - replicatedState(initial: T): MutableReplicatedState; - use(service: Service): T; -} - -export interface SessionDriver { - readonly trace: readonly string[]; - connect(clientId: string, send: (message: ServerWireMessage) => void): () => void; - request(clientId: string, request: SessionRequest, signal: AbortSignal): Promise; - use(service: Service): T; -} - -export class SessionRuntime { - readonly driver: SessionDriver; - private readonly activateCallbacks: Array<() => void | Promise> = []; - private readonly clientConnectCallbacks: Array<(clientId: string) => void> = []; - private readonly clientDisconnectCallbacks: Array<(clientId: string) => void> = []; - private readonly closeCallbacks: Array<() => void> = []; - private readonly hub = new ClientHub(); - private readonly plugins: readonly AppPlugin[]; - private readonly services = new Map(); - private readonly trace: string[] = []; - - constructor(plugins: readonly AppPlugin[]) { - this.plugins = plugins; - this.driver = { - trace: this.trace, - connect: (clientId, send) => { - const disconnect = this.hub.connect(clientId, () => this.snapshot(), send); - for (const callback of this.clientConnectCallbacks) callback(clientId); - let connected = true; - return () => { - if (!connected) return; - connected = false; - disconnect(); - for (const callback of this.clientDisconnectCallbacks) callback(clientId); - }; - }, - request: async (clientId, request, signal) => { - const service = this.services.get(request.service) as Record | undefined; - const method = service?.[request.method]; - if (typeof method !== "function") throw new Error(`Unknown RPC: ${request.service}.${request.method}`); - this.trace.push(`rpc:${request.service}.${request.method}`); - const args = request.rpcOptions ? [...request.args, rpcOptions({ clientId, signal })] : request.args; - return Reflect.apply(method, service, args); - }, - use: (service) => this.use(service), - }; - } - - async start(): Promise { - const context: SessionContext = { - onActivate: (callback) => this.activateCallbacks.push(callback), - onClientConnect: (callback) => this.clientConnectCallbacks.push(callback), - onClientDisconnect: (callback) => this.clientDisconnectCallbacks.push(callback), - onClose: (callback) => this.closeCallbacks.push(callback), - provide: (service, implementation) => this.provide(service, implementation), - replicatedState: (initial) => new SessionReplicatedState(initial), - use: (service) => this.use(service), - }; - for (const plugin of this.plugins) await plugin.session?.(context); - for (const activate of this.activateCallbacks) await activate(); - } - - close(): void { - for (const close of this.closeCallbacks.splice(0).reverse()) close(); - } - - private provide(service: Service, implementation: T): void { - if (this.services.has(service.id)) throw new Error(`Service already provided: ${service.id}`); - this.services.set(service.id, implementation as object); - for (const [property, value] of Object.entries(implementation as object)) { - if (!(value instanceof SessionReplicatedState)) continue; - value.subscribe((next) => { - this.hub.broadcast({ - type: "state_update", - service: service.id, - property, - value: structuredClone(next), - }); - }); - } - } - - private use(service: Service): T { - const implementation = this.services.get(service.id); - if (!implementation) throw new Error(`Service not provided: ${service.id}`); - return implementation as T; - } - - private snapshot(): StateSnapshot { - const snapshot: StateSnapshot = {}; - for (const [serviceId, implementation] of this.services) { - const states: Record = {}; - for (const [property, value] of Object.entries(implementation)) { - if (value instanceof SessionReplicatedState) states[property] = structuredClone(value.value); - } - if (Object.keys(states).length > 0) snapshot[serviceId] = states; - } - return snapshot; - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/lib/transport.ts b/packages/coding-agent/test/fixtures/plugin-app/lib/transport.ts deleted file mode 100644 index a4f2b2e0e..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/lib/transport.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { createConnection, createServer, type Server, type Socket } from "node:net"; -import type { ClientTransport } from "./client.ts"; -import type { ClientWireMessage, ServerWireMessage, SessionRequest } from "./protocol.ts"; -import type { SessionDriver } from "./session.ts"; - -class JsonLineSocket { - private buffer = ""; - private readonly listeners = new Set<(message: Incoming) => void>(); - private readonly socket: Socket; - - constructor(socket: Socket) { - this.socket = socket; - socket.setEncoding("utf8"); - socket.on("data", (chunk: string) => { - this.buffer += chunk; - for (;;) { - const newline = this.buffer.indexOf("\n"); - if (newline < 0) break; - const line = this.buffer.slice(0, newline); - this.buffer = this.buffer.slice(newline + 1); - if (!line) continue; - const message = JSON.parse(line) as Incoming; - for (const listener of this.listeners) listener(message); - } - }); - } - - onMessage(listener: (message: Incoming) => void): void { - this.listeners.add(listener); - } - - send(message: Outgoing): void { - this.socket.write(`${JSON.stringify(message)}\n`); - } -} - -export class SessionTcpServer { - private readonly driver: SessionDriver; - private readonly host: string; - private readonly port: number; - private readonly sockets = new Set(); - private server: Server | undefined; - - constructor(driver: SessionDriver, options: { host: string; port: number }) { - this.driver = driver; - this.host = options.host; - this.port = options.port; - } - - async start(): Promise<{ host: string; port: number }> { - if (this.server) throw new Error("Session server already started"); - this.server = createServer((socket) => this.accept(socket)); - await new Promise((resolve, reject) => { - this.server!.once("error", reject); - this.server!.listen(this.port, this.host, () => { - this.server!.removeListener("error", reject); - resolve(); - }); - }); - const address = this.server.address(); - if (!address || typeof address === "string") throw new Error("Session server has no TCP address"); - return { host: this.host, port: address.port }; - } - - async close(): Promise { - for (const socket of this.sockets) socket.destroy(); - this.sockets.clear(); - const server = this.server; - this.server = undefined; - if (!server) return; - await new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }); - } - - private accept(socket: Socket): void { - this.sockets.add(socket); - const peer = new JsonLineSocket(socket); - const requests = new Map(); - let clientId: string | undefined; - let disconnect: (() => void) | undefined; - peer.onMessage((message) => { - if (message.type === "hello") { - disconnect?.(); - clientId = message.clientId; - disconnect = this.driver.connect(clientId, (outgoing) => peer.send(outgoing)); - return; - } - if (!clientId) { - peer.send({ type: "response", id: message.id, error: "Client has not sent hello" }); - return; - } - if (message.type === "cancel") { - requests.get(message.id)?.abort(); - return; - } - const controller = new AbortController(); - requests.set(message.id, controller); - void this.driver - .request(clientId, message.request, controller.signal) - .then( - (result) => peer.send({ type: "response", id: message.id, result }), - (error: unknown) => - peer.send({ - type: "response", - id: message.id, - error: error instanceof Error ? error.message : String(error), - }), - ) - .finally(() => requests.delete(message.id)); - }); - socket.once("close", () => { - for (const controller of requests.values()) controller.abort(); - requests.clear(); - disconnect?.(); - this.sockets.delete(socket); - }); - } -} - -export class TcpClientTransport implements ClientTransport { - private readonly buffered: ServerWireMessage[] = []; - private readonly peer: JsonLineSocket; - private readonly pending = new Map< - number, - { resolve(value: unknown): void; reject(error: unknown): void; stopAbort(): void } - >(); - private readonly socket: Socket; - private closed = false; - private disconnectError: Error | undefined; - private disconnectListener: ((error?: Error) => void) | undefined; - private listener: ((message: ServerWireMessage) => void) | undefined; - private nextRequestId = 1; - - private constructor(socket: Socket, clientId: string) { - this.socket = socket; - this.peer = new JsonLineSocket(socket); - this.peer.onMessage((message) => { - if (message.type === "response") { - const pending = this.pending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - pending.stopAbort(); - if (message.error) pending.reject(new Error(message.error)); - else pending.resolve(message.result); - } else if (this.listener) { - this.listener(message); - } else { - this.buffered.push(message); - } - }); - socket.on("error", (error) => { - this.disconnectError = error; - }); - socket.once("close", () => { - this.closed = true; - const error = this.disconnectError ?? new Error("Session connection closed"); - for (const pending of this.pending.values()) { - pending.stopAbort(); - pending.reject(error); - } - this.pending.clear(); - this.disconnectListener?.(error); - }); - this.peer.send({ type: "hello", clientId }); - } - - static async connect(options: { host: string; port: number; clientId: string }): Promise { - const socket = createConnection({ host: options.host, port: options.port }); - await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); - }); - return new TcpClientTransport(socket, options.clientId); - } - - start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void { - this.listener = listener; - this.disconnectListener = onDisconnect; - for (const message of this.buffered.splice(0)) listener(message); - if (this.closed) onDisconnect(this.disconnectError ?? new Error("Session connection closed")); - } - - request(request: SessionRequest, signal?: AbortSignal): Promise { - if (this.closed) return Promise.reject(this.disconnectError ?? new Error("Session connection closed")); - if (signal?.aborted) return Promise.reject(signal.reason); - const id = this.nextRequestId++; - return new Promise((resolve, reject) => { - const abort = () => { - if (!this.pending.delete(id)) return; - this.peer.send({ type: "cancel", id }); - reject(signal?.reason ?? new Error("RPC aborted")); - }; - const stopAbort = () => signal?.removeEventListener("abort", abort); - this.pending.set(id, { resolve, reject, stopAbort }); - signal?.addEventListener("abort", abort, { once: true }); - this.peer.send({ type: "request", id, request }); - }); - } - - close(): void { - if (!this.closed) this.socket.destroy(); - } -} - -export class LoopbackTransport implements ClientTransport { - private readonly active = new Set(); - private readonly buffered: ServerWireMessage[] = []; - private readonly disconnect: () => void; - private readonly driver: SessionDriver; - private readonly clientId: string; - private closed = false; - private disconnectListener: ((error?: Error) => void) | undefined; - private listener: ((message: ServerWireMessage) => void) | undefined; - - constructor(driver: SessionDriver, clientId: string) { - this.clientId = clientId; - this.driver = driver; - this.disconnect = driver.connect(clientId, (message) => { - if (this.listener) this.listener(message); - else this.buffered.push(message); - }); - } - - start(listener: (message: ServerWireMessage) => void, onDisconnect: (error?: Error) => void): void { - this.listener = listener; - this.disconnectListener = onDisconnect; - for (const message of this.buffered.splice(0)) listener(message); - if (this.closed) onDisconnect(new Error("Session connection closed")); - } - - request(request: SessionRequest, signal?: AbortSignal): Promise { - if (this.closed) return Promise.reject(new Error("Session connection closed")); - if (signal?.aborted) return Promise.reject(signal.reason); - const controller = new AbortController(); - this.active.add(controller); - return new Promise((resolve, reject) => { - let settled = false; - const finish = (settle: () => void) => { - if (settled) return; - settled = true; - signal?.removeEventListener("abort", abort); - this.active.delete(controller); - settle(); - }; - const abort = () => { - controller.abort(signal?.reason); - finish(() => reject(signal?.reason ?? new Error("RPC aborted"))); - }; - signal?.addEventListener("abort", abort, { once: true }); - void this.driver.request(this.clientId, request, controller.signal).then( - (value) => finish(() => resolve(value)), - (error: unknown) => finish(() => reject(error)), - ); - }); - } - - close(): void { - if (this.closed) return; - this.closed = true; - for (const controller of this.active) controller.abort(); - this.active.clear(); - this.disconnect(); - this.disconnectListener?.(new Error("Session connection closed")); - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts deleted file mode 100644 index 4ee7d91d7..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/plugins.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { type AppPlugin, definePlugin, defineService, type SessionContext } from "../lib/index.ts"; -import { ProviderRegistry } from "./providers.ts"; -import { type ModelSpec, Models, type ModelsState, type ThinkingLevel } from "./services.ts"; -import { registerModelTui } from "./tui/model-selector.ts"; -import type { TuiContext } from "./tui/shared.ts"; - -export type CodingAgentPlugin = AppPlugin; - -const Providers = defineService("providers"); -export const Settings = defineService<{ - get(key: string): unknown; - set(key: string, value: unknown): Promise; -}>("settings"); - -export const providersBuiltin = definePlugin({ - id: "@pi/providers-builtin", - session(context) { - const providers = new ProviderRegistry(); - const values = new Map(); - const settings = { - get: (key: string) => values.get(key), - async set(key: string, value: unknown) { - values.set(key, structuredClone(value)); - }, - }; - const state = context.replicatedState({ - catalog: providers.snapshot(), - configuration: { model: undefined, thinkingLevel: "high" }, - refresh: { status: "idle" }, - }); - const sessionController = new AbortController(); - const stop = providers.subscribe((catalog) => state.set({ ...state.value, catalog })); - const models: Models = { - state, - async cycleThinking() { - const current = state.value.configuration; - const selected = current.model - ? state.value.catalog.availableModels.find( - (model) => model.provider === current.model?.provider && model.modelId === current.model.modelId, - ) - : undefined; - if (selected && !selected.reasoning) throw new Error("Current model does not support thinking"); - const levels: readonly ThinkingLevel[] = ["off", "low", "high"]; - const thinkingLevel = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off"; - state.set({ ...state.value, configuration: { ...current, thinkingLevel } }); - }, - async refresh(options) { - const previousRefresh = state.value.refresh; - state.set({ ...state.value, refresh: { status: "refreshing" } }); - const signal = options?.signal - ? AbortSignal.any([sessionController.signal, options.signal]) - : sessionController.signal; - try { - const errors = await providers.refresh(signal); - signal.throwIfAborted(); - state.set({ - ...state.value, - refresh: - errors.size === 0 ? { status: "done" } : { status: "warning", errors: Object.fromEntries(errors) }, - }); - } catch (error) { - if (signal.aborted) state.set({ ...state.value, refresh: previousRefresh }); - throw error; - } - }, - async select(model) { - const selected = state.value.catalog.availableModels.find( - (candidate) => candidate.provider === model.provider && candidate.modelId === model.modelId, - ); - if (!selected) throw new Error(`Unknown model: ${model.provider}/${model.modelId}`); - const configuration = { - model: { provider: selected.provider, modelId: selected.modelId }, - thinkingLevel: selected.reasoning ? state.value.configuration.thinkingLevel : ("off" as const), - }; - state.set({ ...state.value, configuration }); - await settings.set("defaultModel", configuration.model); - }, - }; - context.provide(Providers, providers); - context.provide(Settings, settings); - context.provide(Models, models); - providers.add("@pi/providers-builtin", (draft) => { - draft.native("acme", [{ provider: "acme", modelId: "base", name: "Base", reasoning: true }]); - draft.native("unconfigured", [ - { provider: "unconfigured", modelId: "hidden", name: "Hidden", reasoning: true }, - ]); - }); - context.onActivate(() => providers.rebuild()); - context.onClose(() => { - stop(); - sessionController.abort(); - }); - }, -}); - -export function providersCatalog( - refreshCatalog: (signal: AbortSignal) => Promise, -): CodingAgentPlugin { - let catalog: readonly ModelSpec[] = [{ provider: "acme", modelId: "cached", name: "Cached", reasoning: true }]; - return definePlugin({ - id: "@pi/providers-catalog", - session(context: SessionContext) { - context.use(Providers).add("@pi/providers-catalog", (draft) => { - draft.models("acme", (models) => [...models, ...catalog]); - draft.refresh("acme", "remote", async (signal) => { - const next = await refreshCatalog(signal); - signal.throwIfAborted(); - catalog = next.map((model) => ({ ...model, provider: "acme" })); - }); - }); - }, - }); -} - -export const providersModelsJson: CodingAgentPlugin = definePlugin({ - id: "@pi/providers-models-json", - session(context) { - context.use(Providers).add("@pi/providers-models-json", (draft) => { - draft.models("acme", (models) => - models.map((model) => (model.modelId === "base" ? { ...model, name: "Base (models.json)" } : model)), - ); - }); - }, -}); - -export const auth: CodingAgentPlugin = definePlugin({ - id: "@pi/auth", - session(context) { - context.use(Providers).add("@pi/auth", (draft) => draft.configured("acme")); - }, -}); - -export const thinkingControl: CodingAgentPlugin = definePlugin({ - id: "@pi/thinking-control", - client(context) { - context.actions.register("app.thinking.cycle", () => context.use(Models).cycleThinking()); - }, -}); - -export const modelSelection: CodingAgentPlugin = definePlugin({ - id: "@pi/model-selection", - client(context) { - registerModelTui(context, context.use(Models)); - }, -}); - -export function createCodingAgentPlugins( - refreshCatalog: (signal: AbortSignal) => Promise, -): readonly CodingAgentPlugin[] { - return [ - providersBuiltin, - providersCatalog(refreshCatalog), - providersModelsJson, - auth, - thinkingControl, - modelSelection, - ]; -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/providers.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/providers.ts deleted file mode 100644 index 1fbb2a964..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/providers.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { ModelSpec, ProviderSnapshot } from "./services.ts"; - -type Contribution = (draft: D, owner: string) => void; - -class ReplayRegistry { - private readonly build: (draft: D) => S; - private readonly contributions: Array<{ owner: string; run: Contribution }> = []; - private readonly createDraft: () => D; - private state: S; - - constructor(createDraft: () => D, build: (draft: D) => S) { - this.build = build; - this.createDraft = createDraft; - this.state = build(createDraft()); - } - - add(owner: string, run: Contribution): void { - this.contributions.push({ owner, run }); - } - - rebuild(): S { - const draft = this.createDraft(); - for (const { owner, run } of this.contributions) run(draft, owner); - this.state = this.build(draft); - return this.state; - } - - current(): S { - return this.state; - } -} - -interface ProviderEntry { - configured: boolean; - models: ModelSpec[]; - refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise }>; -} - -class ProviderDraftState { - readonly entries = new Map(); - - entry(id: string): ProviderEntry { - let entry = this.entries.get(id); - if (!entry) { - entry = { configured: false, models: [], refreshers: [] }; - this.entries.set(id, entry); - } - return entry; - } -} - -export class ProviderDraft { - private readonly owner: string; - private readonly state: ProviderDraftState; - - constructor(owner: string, state: ProviderDraftState) { - this.owner = owner; - this.state = state; - } - - native(id: string, models: readonly ModelSpec[]): void { - this.state.entry(id).models = models.map((model) => ({ ...model, provider: id })); - } - - models(id: string, transform: (models: readonly ModelSpec[]) => readonly ModelSpec[]): void { - const entry = this.state.entry(id); - entry.models = transform(entry.models.map((model) => ({ ...model }))).map((model) => ({ - ...model, - provider: id, - })); - } - - configured(id: string): void { - this.state.entry(id).configured = true; - } - - refresh(id: string, localId: string, refresh: (signal: AbortSignal) => Promise): void { - this.state.entry(id).refreshers.push({ id: `${this.owner}:${localId}`, refresh }); - } -} - -interface ProviderState { - availableModels: readonly ModelSpec[]; - models: readonly ModelSpec[]; - refreshers: ReadonlyArray<{ id: string; refresh(signal: AbortSignal): Promise }>; -} - -export class ProviderRegistry { - private readonly listeners = new Set<(snapshot: ProviderSnapshot) => void>(); - private readonly registry = new ReplayRegistry( - () => new ProviderDraftState(), - (draft): ProviderState => { - const models: ModelSpec[] = []; - const availableModels: ModelSpec[] = []; - const refreshers: Array<{ id: string; refresh(signal: AbortSignal): Promise }> = []; - for (const entry of draft.entries.values()) { - models.push(...entry.models.map((model) => ({ ...model }))); - if (entry.configured) availableModels.push(...entry.models.map((model) => ({ ...model }))); - refreshers.push(...entry.refreshers); - } - const byId = (left: ModelSpec, right: ModelSpec) => - `${left.provider}/${left.modelId}`.localeCompare(`${right.provider}/${right.modelId}`); - return { availableModels: availableModels.sort(byId), models: models.sort(byId), refreshers }; - }, - ); - private revision = 0; - private serialized = ""; - - add(owner: string, contribution: (draft: ProviderDraft) => void): void { - this.registry.add(owner, (draft) => contribution(new ProviderDraft(owner, draft))); - } - - rebuild(): void { - const state = this.registry.rebuild(); - const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models }); - if (serialized === this.serialized) return; - this.serialized = serialized; - this.revision++; - for (const listener of this.listeners) listener(this.snapshot()); - } - - snapshot(): ProviderSnapshot { - const state = this.registry.current(); - return { - revision: this.revision, - availableModels: state.availableModels.map((model) => ({ ...model })), - models: state.models.map((model) => ({ ...model })), - }; - } - - subscribe(listener: (snapshot: ProviderSnapshot) => void): () => void { - this.listeners.add(listener); - listener(this.snapshot()); - return () => this.listeners.delete(listener); - } - - async refresh(signal: AbortSignal): Promise> { - signal.throwIfAborted(); - const errors = new Map(); - await Promise.all( - this.registry.current().refreshers.map(async ({ id, refresh }) => { - try { - await refresh(signal); - } catch (error) { - if (!signal.aborted) errors.set(id, error instanceof Error ? error.message : String(error)); - } - }), - ); - if (!signal.aborted) this.rebuild(); - return errors; - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/run-session.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/run-session.ts deleted file mode 100644 index 10bfdc866..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/run-session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { SessionRuntime, SessionTcpServer } from "../lib/index.ts"; -import { createCodingAgentPlugins } from "./plugins.ts"; - -async function main(): Promise { - const host = process.argv[2] ?? "127.0.0.1"; - const port = Number(process.argv[3] ?? "7777"); - const plugins = createCodingAgentPlugins(async (signal) => { - await new Promise((resolve, reject) => { - const timeout = setTimeout(resolve, 750); - signal.addEventListener( - "abort", - () => { - clearTimeout(timeout); - reject(signal.reason); - }, - { once: true }, - ); - }); - return [ - { provider: "acme", modelId: "fresh", name: "Fresh from catalogue", reasoning: false }, - { provider: "acme", modelId: "reasoning", name: "Reasoning model", reasoning: true }, - ]; - }); - const runtime = new SessionRuntime(plugins); - await runtime.start(); - const server = new SessionTcpServer(runtime.driver, { host, port }); - const address = await server.start(); - process.stdout.write(`Session listening on ${address.host}:${address.port}\n`); - await new Promise((resolve) => { - process.once("SIGINT", resolve); - process.once("SIGTERM", resolve); - }); - await server.close(); - runtime.close(); -} - -main().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/run-tui.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/run-tui.ts deleted file mode 100644 index 44645b946..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/run-tui.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ProcessTerminal } from "@earendil-works/pi-tui"; -import { SessionClient, TcpClientTransport } from "../lib/index.ts"; -import { createCodingAgentPlugins } from "./plugins.ts"; -import { MinimalCodingAgentTui } from "./tui/app.ts"; - -async function main(): Promise { - const host = process.argv[2] ?? "127.0.0.1"; - const port = Number(process.argv[3] ?? "7777"); - const transport = await TcpClientTransport.connect({ host, port, clientId: "tui" }); - const client = new SessionClient(transport); - await client.ready; - const app = new MinimalCodingAgentTui( - new ProcessTerminal(), - client, - createCodingAgentPlugins(async () => []), - ); - const stop = () => app.stop(); - process.once("SIGINT", stop); - process.once("SIGTERM", stop); - app.start(); - await app.done; - process.removeListener("SIGINT", stop); - process.removeListener("SIGTERM", stop); -} - -main().catch((error: unknown) => { - process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); - process.exitCode = 1; -}); diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts deleted file mode 100644 index 01178ab1d..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/services.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { defineService, type ReplicatedState, type RpcOptions } from "../lib/index.ts"; - -export type ThinkingLevel = "off" | "low" | "high"; - -export interface ModelRef { - provider: string; - modelId: string; -} - -export interface ModelSpec extends ModelRef { - name: string; - reasoning: boolean; -} - -export interface LaneConfiguration { - model: ModelRef | undefined; - thinkingLevel: ThinkingLevel; -} - -export interface ProviderSnapshot { - revision: number; - availableModels: readonly ModelSpec[]; - models: readonly ModelSpec[]; -} - -export type RefreshState = - | { status: "idle" } - | { status: "refreshing" } - | { status: "done" } - | { status: "warning"; errors: Readonly> }; - -export interface ModelsState { - catalog: ProviderSnapshot; - configuration: LaneConfiguration; - refresh: RefreshState; -} - -export interface Models { - state: ReplicatedState; - cycleThinking(): Promise; - refresh(options?: RpcOptions): Promise; - select(model: ModelRef): Promise; -} - -export const Models = defineService("pi.models"); diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts deleted file mode 100644 index 4ac18f388..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/app.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { - CombinedAutocompleteProvider, - Container, - ScrollView, - Spacer, - type Terminal, - Text, - TuiAltScreen, - VStack, -} from "@earendil-works/pi-tui"; -import { type AppKeybinding, KeybindingsManager } from "../../../../../src/core/keybindings.ts"; -import { CustomEditor } from "../../../../../src/modes/interactive/components/custom-editor.ts"; -import type { SessionClient } from "../../lib/index.ts"; -import type { CodingAgentPlugin } from "../plugins.ts"; -import { type LaneConfiguration, Models } from "../services.ts"; -import { PluginFooter } from "./footer.ts"; -import { - accent, - bold, - border, - dim, - errorStyle, - type TuiCommand, - type TuiContext, - type ViewRenderer, -} from "./shared.ts"; - -/** Alternate-screen client whose commands and views are supplied by plugins. */ -export class MinimalCodingAgentTui { - readonly done: Promise; - readonly tui: TuiAltScreen; - private readonly actions = new Map void | Promise>(); - private readonly client: SessionClient; - private readonly commands = new Map(); - private readonly editor: CustomEditor; - private readonly editorContainer = new Container(); - private readonly models: Models; - private readonly renderers = new Map(); - private readonly resolveDone: () => void; - private readonly root: VStack; - private readonly statusContainer = new Container(); - private readonly transcript = new Container(); - private activeView: string | undefined; - private lastClearTime = 0; - private lastConfiguration: LaneConfiguration; - private query = ""; - private stopped = false; - private unsubscribe: (() => void) | undefined; - - constructor(terminal: Terminal, client: SessionClient, plugins: readonly CodingAgentPlugin[]) { - this.client = client; - this.models = client.use(Models); - this.lastConfiguration = structuredClone(this.models.state.value.configuration); - const context: TuiContext = { - actions: { register: (id, handler) => this.actions.set(id, handler) }, - commands: { register: (command) => this.commands.set(command.name, command) }, - use: (service) => client.use(service), - views: { - close: () => this.closeView(), - open: (component, query = "") => this.openView(component, query), - register: (component, renderer) => this.renderers.set(component, renderer), - }, - }; - for (const plugin of plugins) plugin.client?.(context); - - this.tui = new TuiAltScreen(terminal); - this.editor = new CustomEditor( - this.tui, - { - borderColor: border, - selectList: { - description: dim, - noMatch: dim, - scrollInfo: dim, - selectedPrefix: accent, - selectedText: accent, - }, - }, - new KeybindingsManager(), - { paddingX: 1 }, - ); - for (const [id, handler] of this.actions) { - this.editor.onAction( - id, - () => void Promise.resolve(handler()).catch((error: unknown) => this.showError(error)), - ); - } - this.editor.onAction("app.clear", () => this.handleClear()); - this.editor.onSubmit = (value) => { - this.editor.addToHistory(value); - this.submit(value); - }; - this.editor.setAutocompleteProvider( - new CombinedAutocompleteProvider( - [ - ...[...this.commands.values()].map(({ name, description, argumentHint }) => ({ - name, - description, - argumentHint, - })), - { name: "quit", description: "Exit the plugin application" }, - ], - process.cwd(), - ), - ); - this.editorContainer.addChild(this.editor); - this.transcript.addChild(new Text(`${bold(accent("pi"))}${dim(" plugin application")}`, 1, 0)); - this.transcript.addChild(new Spacer(1)); - this.transcript.addChild(new Text("Remote session connected. Type /model to select a model.", 1, 0)); - const dock = new VStack([ - { component: this.statusContainer, shrink: 1, minSize: 0 }, - { component: this.editorContainer, shrink: 1, minSize: 3 }, - { component: new PluginFooter(this.models.state, () => client.store.updates), basis: 2, shrink: 0 }, - ]); - this.root = new VStack([ - { - component: new ScrollView(this.transcript, { - follow: "end", - primary: true, - overscroll: "chain", - scrollbar: "auto", - }), - basis: 0, - grow: 1, - shrink: 1, - minSize: 1, - }, - { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, - ]); - let settleDone!: () => void; - this.done = new Promise((resolve) => { - settleDone = resolve; - }); - this.resolveDone = settleDone; - } - - start(): void { - if (this.unsubscribe || this.stopped) return; - this.unsubscribe = this.client.store.subscribe(() => { - const connection = this.client.store.connection.value; - if (connection.status === "disconnected") this.showStatus(`Disconnected: ${connection.reason}`, true); - this.applyConfiguration(); - this.renderView(); - this.updateEditorBorder(); - this.tui.requestRender(); - }); - this.renderView(); - this.tui.setLayoutRoot(this.root); - this.tui.start(); - } - - submit(input: string): void { - const value = input.trim(); - if (!value) return; - if (value === "/quit" || value === "quit") { - this.stop(); - return; - } - if (!value.startsWith("/")) { - this.showError(new Error("Only slash commands are supported by this test app")); - return; - } - const separator = value.indexOf(" "); - const name = value.slice(1, separator < 0 ? undefined : separator); - const args = separator < 0 ? "" : value.slice(separator + 1).trim(); - const command = this.commands.get(name); - if (!command) { - this.showError(new Error(`Unknown command: ${name}`)); - return; - } - if (name !== "model") { - this.transcript.addChild(new Spacer(1)); - this.transcript.addChild(new Text(`${accent("> ")}${value}`, 1, 0)); - } - void Promise.resolve(command.run(args)).catch((error: unknown) => this.showError(error)); - this.tui.requestRender(); - } - - stop(): void { - if (this.stopped) return; - this.stopped = true; - this.unsubscribe?.(); - this.unsubscribe = undefined; - this.client.close(); - this.tui.stop({ preserveScreen: true }); - this.resolveDone(); - } - - private applyConfiguration(): void { - const current = this.models.state.value.configuration; - if ( - this.lastConfiguration.model?.provider !== current.model?.provider || - this.lastConfiguration.model?.modelId !== current.model?.modelId - ) { - this.showStatus(current.model ? `Model: ${current.model.modelId}` : "Model cleared"); - } else if (this.lastConfiguration.thinkingLevel !== current.thinkingLevel) { - this.showStatus(`Thinking level: ${current.thinkingLevel}`); - } - this.lastConfiguration = structuredClone(current); - } - - private openView(component: string, query: string): void { - this.activeView = component; - this.query = query; - this.renderView(); - this.tui.requestRender(); - } - - private closeView(): void { - this.activeView = undefined; - this.query = ""; - this.renderView(); - this.tui.requestRender(); - } - - private renderView(): void { - this.editorContainer.clear(); - if (!this.activeView) { - this.editorContainer.addChild(this.editor); - this.tui.setFocus(this.editor); - return; - } - const renderer = this.renderers.get(this.activeView); - if (!renderer) { - this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${this.activeView}`), 1, 0)); - this.tui.setFocus(null); - return; - } - const rendered = renderer({ - close: () => this.closeView(), - query: this.query, - setQuery: (query) => { - this.query = query; - }, - }); - this.editorContainer.addChild(rendered.component); - this.tui.setFocus(rendered.focus); - } - - private handleClear(): void { - const now = Date.now(); - if (now - this.lastClearTime < 500) { - this.stop(); - return; - } - this.editor.setText(""); - this.showStatus(undefined); - this.lastClearTime = now; - this.tui.requestRender(); - } - - private showError(error: unknown): void { - this.showStatus(error instanceof Error ? error.message : String(error), true); - this.tui.requestRender(); - } - - private showStatus(message: string | undefined, isError = false): void { - this.statusContainer.clear(); - if (message) this.statusContainer.addChild(new Text(isError ? errorStyle(message) : accent(message), 1, 0)); - } - - private updateEditorBorder(): void { - const state = this.models.state.value; - const current = state.configuration.model; - const selected = current - ? state.catalog.availableModels.find( - (model) => model.provider === current.provider && model.modelId === current.modelId, - ) - : undefined; - this.editor.borderColor = selected?.reasoning ? accent : border; - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts deleted file mode 100644 index a1a0f093b..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/footer.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { homedir } from "node:os"; -import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { ReplicatedState } from "../../lib/index.ts"; -import type { ModelsState } from "../services.ts"; -import { dim } from "./shared.ts"; - -export class PluginFooter implements Component { - private readonly models: ReplicatedState; - private readonly updateCount: () => number; - - constructor(models: ReplicatedState, updateCount: () => number) { - this.models = models; - this.updateCount = updateCount; - } - - invalidate(): void {} - - render(width: number): string[] { - const state = this.models.value; - const home = homedir(); - const cwd = process.cwd(); - const displayCwd = cwd === home ? "~" : cwd.startsWith(`${home}/`) ? `~/${cwd.slice(home.length + 1)}` : cwd; - const current = state.configuration.model; - const currentSpec = current - ? state.catalog.availableModels.find( - (model) => model.provider === current.provider && model.modelId === current.modelId, - ) - : undefined; - const model = current - ? `(${current.provider}) ${current.modelId}${ - currentSpec?.reasoning - ? state.configuration.thinkingLevel === "off" - ? " • thinking off" - : ` • ${state.configuration.thinkingLevel}` - : "" - }` - : "no-model"; - const left = `updates ${this.updateCount()} · catalogue r${state.catalog.revision}`; - const right = truncateToWidth(model, Math.max(0, width - visibleWidth(left) - 2), ""); - const padding = " ".repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(right))); - return [truncateToWidth(dim(displayCwd), width, dim("...")), dim(`${left}${padding}${right}`)]; - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts deleted file mode 100644 index 0bfef1a5a..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/model-selector.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui"; -import { rpcOptions } from "../../lib/index.ts"; -import type { ModelSpec, Models, ModelsState } from "../services.ts"; -import { accent, border, dim, success, type TuiContext, warning } from "./shared.ts"; - -class Rule implements Component { - invalidate(): void {} - render(width: number): string[] { - return [border("─".repeat(Math.max(1, width)))]; - } -} - -export function registerModelTui(context: TuiContext, models: Models): void { - let refreshController: AbortController | undefined; - const resolve = (query: string): ModelSpec | undefined => { - const available = models.state.value.catalog.availableModels; - const qualified = available.find((model) => `${model.provider}/${model.modelId}` === query); - if (qualified) return qualified; - const unqualified = available.filter((model) => model.modelId === query); - return unqualified.length === 1 ? unqualified[0] : undefined; - }; - context.commands.register({ - name: "model", - description: "Select model", - argumentHint: "[provider/]model", - async run(args) { - if (args) { - let model = resolve(args); - if (!model) { - await models.refresh(); - model = resolve(args); - } - if (model) return models.select(model); - } - refreshController?.abort(); - const controller = new AbortController(); - refreshController = controller; - context.views.open("model-selector", args); - void models - .refresh(rpcOptions({ signal: controller.signal })) - .catch(() => {}) - .finally(() => { - if (refreshController === controller) refreshController = undefined; - }); - }, - }); - context.views.register("model-selector", (view) => { - const close = () => { - refreshController?.abort(); - refreshController = undefined; - view.close(); - }; - const component = new ModelSelector({ - state: models.state.value, - query: view.query, - setQuery: view.setQuery, - cancel: close, - select: async (model) => { - await models.select(model); - close(); - }, - }); - return { component, focus: component }; - }); -} - -class ModelSelector extends Container implements Focusable { - private readonly input = new Input(); - private readonly list = new Container(); - private readonly cancel: () => void; - private readonly select: (model: ModelSpec) => Promise; - private readonly setQuery: (query: string) => void; - private readonly state: ModelsState; - private filtered: readonly ModelSpec[] = []; - private selected = 0; - private _focused = false; - - constructor(options: { - state: ModelsState; - query: string; - setQuery(query: string): void; - cancel(): void; - select(model: ModelSpec): Promise; - }) { - super(); - this.cancel = options.cancel; - this.select = options.select; - this.setQuery = options.setQuery; - this.state = options.state; - this.input.setValue(options.query); - this.addChild(new Rule()); - this.addChild(new Spacer(1)); - this.addChild( - new Text(warning("Only showing models from configured providers. Use provider plugins to add more."), 0, 0), - ); - this.addChild(new Spacer(1)); - this.addChild(this.input); - this.addChild(new Spacer(1)); - this.addChild(this.list); - this.addChild(new Spacer(1)); - this.addChild(new Rule()); - this.update(); - } - - get focused(): boolean { - return this._focused; - } - set focused(value: boolean) { - this._focused = value; - this.input.focused = value; - } - - handleInput(data: string): void { - const keys = getKeybindings(); - if (keys.matches(data, "tui.select.up")) { - if (this.filtered.length) this.selected = this.selected ? this.selected - 1 : this.filtered.length - 1; - } else if (keys.matches(data, "tui.select.down")) { - if (this.filtered.length) this.selected = this.selected === this.filtered.length - 1 ? 0 : this.selected + 1; - } else if (keys.matches(data, "tui.select.confirm")) { - const model = this.filtered[this.selected]; - if (model) void this.select(model); - return; - } else if (keys.matches(data, "tui.select.cancel")) { - this.cancel(); - return; - } else { - this.input.handleInput(data); - this.setQuery(this.input.getValue()); - this.selected = 0; - } - this.update(); - } - - private update(): void { - const query = this.input.getValue().trim().toLowerCase(); - const models = this.state.catalog.availableModels; - this.filtered = query - ? models.filter((model) => `${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query)) - : models; - this.selected = Math.min(this.selected, Math.max(0, this.filtered.length - 1)); - this.list.clear(); - const start = Math.max(0, Math.min(this.selected - 5, this.filtered.length - 10)); - for (let index = start; index < Math.min(start + 10, this.filtered.length); index++) { - const model = this.filtered[index]; - if (!model) continue; - const selected = index === this.selected; - this.list.addChild( - new Text( - `${selected ? accent("→ ") : " "}${selected ? accent(model.modelId) : model.modelId} ${dim(`[${model.provider}]`)}`, - 0, - 0, - ), - ); - } - if (!this.filtered.length) this.list.addChild(new Text(dim(" No matching models"), 0, 0)); - else { - const model = this.filtered[this.selected]; - if (model) { - this.list.addChild(new Spacer(1)); - this.list.addChild(new Text(dim(` Model Name: ${model.name}`), 0, 0)); - } - } - this.list.addChild(new Spacer(1)); - if (this.state.refresh.status === "refreshing") - this.list.addChild(new Text(dim(" Refreshing model catalogs…"), 0, 0)); - else if (this.state.refresh.status === "warning") - this.list.addChild(new Text(warning(" Could not refresh model catalogs; showing cached models."), 0, 0)); - else if (this.state.refresh.status === "done") - this.list.addChild(new Text(success(" Model catalogs refreshed."), 0, 0)); - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/shared.ts b/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/shared.ts deleted file mode 100644 index a4ce1c2c2..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/model-app/tui/shared.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Component } from "@earendil-works/pi-tui"; -import type { AppKeybinding } from "../../../../../src/core/keybindings.ts"; -import type { Service } from "../../lib/index.ts"; - -const style = - (open: number, close: number) => - (text: string): string => - `\x1b[${open}m${text}\x1b[${close}m`; - -export const bold = style(1, 22); -export const dim = style(2, 22); -export const accent = style(36, 39); -export const success = style(32, 39); -export const warning = style(33, 39); -export const errorStyle = style(31, 39); -export const border = (text: string): string => `\x1b[38;5;240m${text}\x1b[39m`; - -export interface TuiCommand { - name: string; - description: string; - argumentHint?: string; - run(args: string): void | Promise; -} - -export interface RenderedView { - component: Component; - focus: Component; -} - -export interface LocalViewContext { - close(): void; - query: string; - setQuery(query: string): void; -} - -export type ViewRenderer = (context: LocalViewContext) => RenderedView; - -export interface TuiContext { - actions: { register(id: AppKeybinding, handler: () => void | Promise): void }; - commands: { register(command: TuiCommand): void }; - use(service: Service): T; - views: { - close(): void; - open(component: string, query?: string): void; - register(component: string, renderer: ViewRenderer): void; - }; -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/run.sh b/packages/coding-agent/test/fixtures/plugin-app/run.sh deleted file mode 100755 index 595771e00..000000000 --- a/packages/coding-agent/test/fixtures/plugin-app/run.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -app_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -host="${1:-127.0.0.1}" -port="${2:-7777}" -log_file="$(mktemp)" - -node "$app_dir/model-app/run-session.ts" "$host" "$port" > >(tee "$log_file") & -server_pid=$! - -cleanup() { - trap - EXIT INT TERM - kill "$server_pid" 2>/dev/null || true - wait "$server_pid" 2>/dev/null || true - rm -f "$log_file" -} -trap cleanup EXIT INT TERM - -for ((attempt = 0; attempt < 100; attempt++)); do - if grep -q "^Session listening on " "$log_file"; then - node "$app_dir/model-app/run-tui.ts" "$host" "$port" - exit - fi - if ! kill -0 "$server_pid" 2>/dev/null; then - wait "$server_pid" - exit $? - fi - sleep 0.05 -done - -echo "Timed out waiting for session server" >&2 -exit 1 diff --git a/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts b/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts deleted file mode 100644 index 778396cbc..000000000 --- a/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; -import { LoopbackTransport, SessionClient, SessionRuntime } from "./fixtures/plugin-app/lib/index.ts"; -import { createCodingAgentPlugins, Settings } from "./fixtures/plugin-app/model-app/plugins.ts"; -import { type ModelSpec, Models } from "./fixtures/plugin-app/model-app/services.ts"; -import { MinimalCodingAgentTui } from "./fixtures/plugin-app/model-app/tui/app.ts"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; -} - -function createDeferred(): Deferred { - let resolvePromise!: (value: T) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { promise, resolve: resolvePromise }; -} - -describe("app-as-plugins model service", () => { - it("replicates ReplicatedState and invokes typed methods over a serializable boundary", async () => { - const catalogue = createDeferred(); - let refreshCalls = 0; - const plugins = createCodingAgentPlugins(() => { - refreshCalls++; - return catalogue.promise; - }); - const runtime = new SessionRuntime(plugins); - await runtime.start(); - const client = new SessionClient(new LoopbackTransport(runtime.driver, "tui-1")); - await client.ready; - const models = client.use(Models); - - expect(plugins.map((plugin) => plugin.id)).toEqual([ - "@pi/providers-builtin", - "@pi/providers-catalog", - "@pi/providers-models-json", - "@pi/auth", - "@pi/thinking-control", - "@pi/model-selection", - ]); - expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]); - expect(models.state.value.catalog.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]); - - const observed: string[] = []; - const unsubscribe = models.state.subscribe((state) => observed.push(state.refresh.status)); - expect(observed).toEqual(["idle"]); - const refresh = models.refresh(); - await vi.waitFor(() => expect(models.state.value.refresh).toEqual({ status: "refreshing" })); - expect(refreshCalls).toBe(1); - - catalogue.resolve([{ provider: "ignored", modelId: "fresh", name: "Fresh", reasoning: false }]); - await refresh; - expect(models.state.value.refresh).toEqual({ status: "done" }); - expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]); - await models.select({ provider: "acme", modelId: "fresh" }); - expect(models.state.value.configuration).toEqual({ - model: { provider: "acme", modelId: "fresh" }, - thinkingLevel: "off", - }); - expect(runtime.driver.use(Settings).get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" }); - expect(observed).toEqual(["idle", "refreshing", "refreshing", "done", "done"]); - expect(runtime.driver.trace).toEqual(["rpc:pi.models.refresh", "rpc:pi.models.select"]); - unsubscribe(); - client.close(); - runtime.close(); - }); - - it("keeps the client model feature removable without changing the session", async () => { - const complete = createCodingAgentPlugins(async () => []); - const runtime = new SessionRuntime(complete); - await runtime.start(); - const client = new SessionClient(new LoopbackTransport(runtime.driver, "headless")); - await client.ready; - const terminal = new VirtualTerminal(80, 24); - const app = new MinimalCodingAgentTui( - terminal, - client, - complete.filter((plugin) => plugin.id !== "@pi/model-selection"), - ); - app.start(); - app.submit("/model"); - await vi.waitFor(async () => { - await terminal.waitForRender(); - expect(terminal.getViewport().join("\n")).toContain("Unknown command: model"); - }); - expect(client.use(Models).state.value.catalog.availableModels).toHaveLength(2); - app.stop(); - runtime.close(); - }); - - it("keeps the cached catalogue when refresh fails", async () => { - const runtime = new SessionRuntime( - createCodingAgentPlugins(async () => { - throw new Error("catalogue offline"); - }), - ); - await runtime.start(); - const client = new SessionClient(new LoopbackTransport(runtime.driver, "failure")); - await client.ready; - const models = client.use(Models); - await models.refresh(); - expect(models.state.value.refresh).toEqual({ - status: "warning", - errors: { "@pi/providers-catalog:remote": "catalogue offline" }, - }); - expect(models.state.value.catalog.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]); - client.close(); - runtime.close(); - }); -}); diff --git a/packages/coding-agent/test/plugin-app-tui.test.ts b/packages/coding-agent/test/plugin-app-tui.test.ts deleted file mode 100644 index c6edb84e6..000000000 --- a/packages/coding-agent/test/plugin-app-tui.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; -import { - definePlugin, - LoopbackTransport, - SessionClient, - SessionRuntime, - SessionTcpServer, - TcpClientTransport, -} from "./fixtures/plugin-app/lib/index.ts"; -import { type CodingAgentPlugin, createCodingAgentPlugins } from "./fixtures/plugin-app/model-app/plugins.ts"; -import { type ModelSpec, Models } from "./fixtures/plugin-app/model-app/services.ts"; -import { MinimalCodingAgentTui } from "./fixtures/plugin-app/model-app/tui/app.ts"; - -interface Deferred { - promise: Promise; - resolve(value: T): void; -} - -function createDeferred(): Deferred { - let resolvePromise!: (value: T) => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - return { promise, resolve: resolvePromise }; -} - -async function viewportText(terminal: VirtualTerminal): Promise { - await terminal.waitForRender(); - return terminal.getViewport().join("\n"); -} - -describe("remote plugin app TUI", () => { - it("renders and controls typed session services over real TCP", async () => { - const catalogue = createDeferred(); - const plugins = createCodingAgentPlugins(() => catalogue.promise); - const runtime = new SessionRuntime(plugins); - await runtime.start(); - const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 }); - const address = await server.start(); - const transport = await TcpClientTransport.connect({ ...address, clientId: "tui" }); - const client = new SessionClient(transport); - await client.ready; - const models = client.use(Models); - const closeClient = vi.spyOn(client, "close"); - const terminal = new VirtualTerminal(80, 24); - const app = new MinimalCodingAgentTui(terminal, client, plugins); - - try { - app.start(); - expect(app.tui.mode).toBe("fullscreen"); - const initial = await viewportText(terminal); - expect(initial).toContain("pi plugin application"); - expect(initial).toContain("Remote session connected"); - expect(initial).toContain("updates 0 · catalogue r1"); - expect(initial).toContain("no-model"); - - terminal.sendInput("\x1b[Z"); - await vi.waitFor(() => expect(models.state.value.configuration.thinkingLevel).toBe("off")); - expect(await viewportText(terminal)).toContain("Thinking level: off"); - - terminal.sendInput("/"); - await vi.waitFor(async () => { - const autocomplete = await viewportText(terminal); - expect(autocomplete).toContain("model"); - expect(autocomplete).toContain("[provider/]model — Select model"); - expect(autocomplete).toContain("quit"); - }); - terminal.sendInput("\t"); - await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("/model ")); - terminal.sendInput("\r"); - await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing")); - const refreshing = await viewportText(terminal); - expect(refreshing).toContain("Only showing models from configured providers"); - expect(refreshing).toContain("base [acme]"); - expect(refreshing).toContain("Refreshing model catalogs"); - - catalogue.resolve([{ provider: "acme", modelId: "fresh", name: "Fresh", reasoning: false }]); - await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("done")); - const refreshed = await viewportText(terminal); - expect(refreshed).toContain("fresh [acme]"); - expect(refreshed).toContain("Model catalogs refreshed"); - - terminal.sendInput("fresh"); - await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("Fresh")); - terminal.sendInput("\r"); - await vi.waitFor(() => - expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "fresh" }), - ); - const selected = await viewportText(terminal); - expect(selected).toContain("Model: fresh"); - expect(selected).toContain("(acme) fresh"); - - app.submit("/model acme/base"); - await vi.waitFor(() => - expect(models.state.value.configuration.model).toEqual({ provider: "acme", modelId: "base" }), - ); - expect(await viewportText(terminal)).toContain("Model: base"); - - terminal.sendInput("\x03"); - expect(closeClient).not.toHaveBeenCalled(); - terminal.sendInput("\x03"); - await app.done; - expect(closeClient).toHaveBeenCalledOnce(); - } finally { - app.stop(); - await server.close(); - runtime.close(); - } - }); - - it("cancels the session refresh when the model dialog is dismissed", async () => { - let markAborted!: () => void; - const aborted = new Promise((resolve) => { - markAborted = resolve; - }); - const plugins = createCodingAgentPlugins( - (signal) => - new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - () => { - markAborted(); - reject(signal.reason); - }, - { once: true }, - ); - }), - ); - const runtime = new SessionRuntime(plugins); - await runtime.start(); - const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 }); - const address = await server.start(); - const client = new SessionClient(await TcpClientTransport.connect({ ...address, clientId: "cancel" })); - await client.ready; - const models = client.use(Models); - const terminal = new VirtualTerminal(80, 24); - const app = new MinimalCodingAgentTui(terminal, client, plugins); - - try { - app.start(); - app.submit("/model"); - await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("refreshing")); - terminal.sendInput("\x1b"); - await aborted; - await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("idle")); - expect(await viewportText(terminal)).not.toContain("Refreshing model catalogs"); - } finally { - app.stop(); - await server.close(); - runtime.close(); - } - }); - - it("reports session disconnects to both sides", async () => { - let disconnectedClient: string | undefined; - const lifecycle: CodingAgentPlugin = definePlugin({ - id: "lifecycle-test", - session(context) { - context.onClientDisconnect((clientId) => { - disconnectedClient = clientId; - }); - }, - }); - const runtime = new SessionRuntime([...createCodingAgentPlugins(async () => []), lifecycle]); - await runtime.start(); - const server = new SessionTcpServer(runtime.driver, { host: "127.0.0.1", port: 0 }); - const address = await server.start(); - const client = new SessionClient(await TcpClientTransport.connect({ ...address, clientId: "disconnect-test" })); - await client.ready; - const models = client.use(Models); - - await server.close(); - await vi.waitFor(() => expect(client.store.connection.value.status).toBe("disconnected")); - await vi.waitFor(() => expect(disconnectedClient).toBe("disconnect-test")); - await expect(models.cycleThinking()).rejects.toThrow("Session connection closed"); - client.close(); - runtime.close(); - }); - - it("applies state updates buffered after the connection snapshot", async () => { - const runtime = new SessionRuntime(createCodingAgentPlugins(async () => [])); - await runtime.start(); - const transport = new LoopbackTransport(runtime.driver, "buffered"); - await runtime.driver.use(Models).select({ provider: "acme", modelId: "base" }); - const client = new SessionClient(transport); - await client.ready; - const models = client.use(Models); - - expect(models.state.value.configuration).toEqual({ - model: { provider: "acme", modelId: "base" }, - thinkingLevel: "high", - }); - expect(client.store.updates).toBe(1); - const observed: string[] = []; - models.state.subscribe((state) => observed.push(state.configuration.model?.modelId ?? "none")); - expect(observed).toEqual(["base"]); - client.close(); - runtime.close(); - }); -});