diff --git a/packages/coding-agent/test/fixtures/plugin-app/client.ts b/packages/coding-agent/test/fixtures/plugin-app/client.ts index 54bc649d0..c83885cb3 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/client.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/client.ts @@ -1,69 +1,79 @@ -import type { AppKeybinding } from "../../../src/core/keybindings.ts"; -import type { - ClientSnapshot, - CodingAgentEvent, - CodingAgentSnapshot, - ServerWireMessage, - SessionRequest, - WireView, -} from "./protocol.ts"; +import { isRpcOptions, type RemoteState, type Service } from "./kernel.ts"; +import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; export interface ClientTransport { close(): void; - request(request: SessionRequest): Promise; + request(request: SessionRequest, signal?: AbortSignal): Promise; start(listener: (message: ServerWireMessage) => void): void; } -/** Replicated client state: one app snapshot plus active targeted views. */ -export class CodingAgentClientStore { - readonly events: CodingAgentEvent[] = []; +class ClientRemoteState implements RemoteState { + 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 { + updates = 0; private readonly listeners = new Set<() => void>(); - private readonly viewMap = new Map(); - private appState: CodingAgentSnapshot | undefined; - - get app(): CodingAgentSnapshot { - if (!this.appState) throw new Error("Session snapshot has not arrived"); - return this.appState; - } - - get views(): readonly WireView[] { - return [...this.viewMap.values()]; - } + private readonly states = new Map>(); apply(message: ServerWireMessage): void { if (message.type === "response") return; - if (message.type === "snapshot") { - this.replace(message.snapshot); - } else if (message.type === "event") { - this.events.push(structuredClone(message.event)); - if (message.event.type === "config_update") { - this.appState = { ...this.app, configuration: structuredClone(message.event.value) }; - } else { - this.appState = { ...this.app, providers: structuredClone(message.event.providers) }; - } - } else if (message.type === "view_updated") { - this.viewMap.set(message.view.id, structuredClone(message.view)); - } else { - this.viewMap.delete(message.viewId); - } + if (message.type === "snapshot") this.replace(message.states); + else this.update(message.service, message.property, message.value); for (const listener of this.listeners) listener(); } + get(service: string, property: string): RemoteState { + const state = this.states.get(`${service}.${property}`); + if (!state) throw new Error(`Remote state not provided: ${service}.${property}`); + return state as RemoteState; + } + subscribe(listener: () => void): () => void { this.listeners.add(listener); + listener(); return () => this.listeners.delete(listener); } - private replace(snapshot: ClientSnapshot): void { - this.appState = structuredClone(snapshot.app); - this.viewMap.clear(); - for (const view of snapshot.views) this.viewMap.set(view.id, structuredClone(view)); + 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 ClientRemoteState(value)); + if (count) this.updates++; } } export class SessionClient { readonly ready: Promise; - readonly store = new CodingAgentClientStore(); + readonly store = new ClientStateStore(); + private readonly proxies = new Map(); private readonly resolveReady: () => void; private readonly transport: ClientTransport; private receivedSnapshot = false; @@ -88,15 +98,39 @@ export class SessionClient { this.transport.close(); } - invokeAction(id: AppKeybinding): Promise { - return this.transport.request({ type: "invoke_action", id }); - } - - sendView(viewId: string, message: unknown): Promise { - return this.transport.request({ type: "view_message", viewId, message }); - } - - submit(input: string): Promise { - return this.transport.request({ type: "submit", input }); + 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/kernel.ts b/packages/coding-agent/test/fixtures/plugin-app/kernel.ts index 4bbe51a28..e8017d6c9 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/kernel.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/kernel.ts @@ -1,48 +1,50 @@ -/** Minimal session-side plugin lifecycle. The app owns every service and registry. */ +/** + * Shared tokens and two-sided plugin shape. + * Remote 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 AppPlugin { +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 { + readonly signal?: AbortSignal; + readonly [rpcOptionsMarker]: true; +} + +export function rpcOptions(options: { 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 RemoteState { + readonly value: T; + subscribe(listener: (value: T) => void): () => void; +} + +export interface MutableRemoteState extends RemoteState { + set(value: T): void; +} + +export interface AppPlugin { id: string; - setup(api: Api): void | Promise; + session?(context: SessionContext): void | Promise; + client?(context: ClientContext): void | Promise; } -export interface AppInstance { - readonly driver: Driver; - activate(): void | Promise; - api(owner: string): Api; - close(): void; -} - -export interface AppDefinition { - id: string; - create(): AppInstance; - plugins: readonly AppPlugin[]; -} - -export function defineApp(definition: AppDefinition): AppDefinition { - return definition; -} - -export function definePlugin(plugin: AppPlugin): AppPlugin { +export function definePlugin( + plugin: AppPlugin, +): AppPlugin { return plugin; } - -export class TestAppRuntime { - readonly definition: AppDefinition; - readonly driver: Driver; - private readonly instance: AppInstance; - - constructor(definition: AppDefinition) { - this.definition = definition; - this.instance = definition.create(); - this.driver = this.instance.driver; - } - - async start(): Promise { - for (const plugin of this.definition.plugins) await plugin.setup(this.instance.api(plugin.id)); - await this.instance.activate(); - } - - close(): void { - this.instance.close(); - } -} diff --git a/packages/coding-agent/test/fixtures/plugin-app/plugins.ts b/packages/coding-agent/test/fixtures/plugin-app/plugins.ts index 1331eaf19..3c309b7d9 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/plugins.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/plugins.ts @@ -1,16 +1,96 @@ -import { defineApp, definePlugin } from "./kernel.ts"; -import type { ModelSelectorState, ModelSpec, ThinkingLevel } from "./protocol.ts"; -import { type CodingAgentApi, type CodingAgentApp, type CodingAgentPlugin, createSessionInstance } from "./session.ts"; +import { type AppPlugin, definePlugin, defineService } from "./kernel.ts"; +import { type ModelSpec, Models, type ModelsService, type ModelsState, type ThinkingLevel } from "./protocol.ts"; +import { ProviderRegistry } from "./providers.ts"; +import type { SessionContext } from "./session.ts"; +import { registerModelTui } from "./tui/model-selector.ts"; +import type { TuiContext } from "./tui/shared.ts"; -export const providersBuiltin = definePlugin({ +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", - setup(pi) { - pi.providers.add((draft) => { + 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.state({ + 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: ModelsService = { + 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(); + }); }, }); @@ -20,8 +100,8 @@ export function providersCatalog( let catalog: readonly ModelSpec[] = [{ provider: "acme", modelId: "cached", name: "Cached", reasoning: true }]; return definePlugin({ id: "@pi/providers-catalog", - setup(pi: CodingAgentApi) { - pi.providers.add((draft) => { + 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); @@ -33,10 +113,10 @@ export function providersCatalog( }); } -export const providersModelsJson = definePlugin({ +export const providersModelsJson: CodingAgentPlugin = definePlugin({ id: "@pi/providers-models-json", - setup(pi) { - pi.providers.add((draft) => { + 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)), ); @@ -44,155 +124,36 @@ export const providersModelsJson = definePlugin({ }, }); -export const auth = definePlugin({ +export const auth: CodingAgentPlugin = definePlugin({ id: "@pi/auth", - setup(pi) { - pi.providers.add((draft) => draft.configured("acme")); + session(context) { + context.use(Providers).add("@pi/auth", (draft) => draft.configured("acme")); }, }); -export const thinkingControl = definePlugin({ +export const thinkingControl: CodingAgentPlugin = definePlugin({ id: "@pi/thinking-control", - setup(pi) { - pi.actions.add((draft) => { - draft.set("app.thinking.cycle", { - description: "Cycle thinking level", - async handler() { - const current = pi.session.getConfiguration(); - const selected = current.model - ? pi.providers - .snapshot() - .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 next = levels[(levels.indexOf(current.thinkingLevel) + 1) % levels.length] ?? "off"; - await pi.session.configure({ ...current, thinkingLevel: next }); - }, - }); - }); + client(context) { + context.actions.register("app.thinking.cycle", () => context.use(Models).cycleThinking()); }, }); -type ModelSelectorMessage = { type: "cancel" } | { type: "select"; provider: string; modelId: string }; - -function isModelSelectorMessage(value: unknown): value is ModelSelectorMessage { - if (typeof value !== "object" || value === null || !("type" in value)) return false; - const candidate = value as { type?: unknown; provider?: unknown; modelId?: unknown }; - return ( - candidate.type === "cancel" || - (candidate.type === "select" && typeof candidate.provider === "string" && typeof candidate.modelId === "string") - ); -} - -export const modelSelection = definePlugin({ +export const modelSelection: CodingAgentPlugin = definePlugin({ id: "@pi/model-selection", - setup(pi) { - pi.commands.add((draft) => { - draft.set("model", { - argumentHint: "[provider/]model", - description: "Select model", - async handler(args, context) { - const resolveExact = (query: string): ModelSpec | undefined => { - const models = pi.providers.snapshot().availableModels; - const canonical = models.find((model) => `${model.provider}/${model.modelId}` === query); - if (canonical) return canonical; - const bare = models.filter((model) => model.modelId === query); - return bare.length === 1 ? bare[0] : undefined; - }; - const select = async (model: ModelSpec): Promise => { - const current = pi.session.getConfiguration(); - await pi.session.configure({ - model: { provider: model.provider, modelId: model.modelId }, - thinkingLevel: model.reasoning ? current.thinkingLevel : "off", - }); - await pi.settings.set("defaultModel", { provider: model.provider, modelId: model.modelId }); - }; - - if (args) { - let exact = resolveExact(args); - if (!exact) { - await pi.providers.refresh(context.signal); - exact = resolveExact(args); - } - if (exact) { - await select(exact); - return; - } - } - - let refresh: ModelSelectorState["refresh"] = { status: "refreshing" }; - const createState = (): ModelSelectorState => ({ - schema: 1, - query: args, - catalogRevision: pi.providers.snapshot().revision, - refresh, - }); - const view = pi.view("model-selector", createState(), { to: [context.originatorClientId] }); - const stopRebuild = pi.providers.onRebuild(() => { - view.state = createState(); - }); - view.on(async (message) => { - if (!isModelSelectorMessage(message)) return; - if (message.type === "cancel") { - view.close(); - return; - } - const selected = pi.providers - .snapshot() - .availableModels.find( - (model) => model.provider === message.provider && model.modelId === message.modelId, - ); - if (!selected) return; - await select(selected); - view.close(); - }); - - const refreshController = new AbortController(); - const refreshSignal = AbortSignal.any([context.signal, refreshController.signal]); - void pi.providers - .refresh(refreshSignal) - .then((result) => { - refresh = - result.errors.size === 0 - ? { status: "done" } - : { status: "warning", errors: Object.fromEntries(result.errors) }; - view.state = createState(); - }) - .catch((error: unknown) => { - if (refreshSignal.aborted) return; - refresh = { - status: "warning", - errors: { refresh: error instanceof Error ? error.message : String(error) }, - }; - view.state = createState(); - }); - - await view.done; - refreshController.abort(); - stopRebuild(); - }, - }); - }); + client(context) { + registerModelTui(context, context.use(Models)); }, }); -export function createCodingAgentApp( +export function createCodingAgentPlugins( refreshCatalog: (signal: AbortSignal) => Promise, -): CodingAgentApp { - return defineApp({ - id: "coding-agent", - create: createSessionInstance, - plugins: [ - providersBuiltin, - providersCatalog(refreshCatalog), - providersModelsJson, - auth, - thinkingControl, - modelSelection, - ], - }); +): readonly CodingAgentPlugin[] { + return [ + providersBuiltin, + providersCatalog(refreshCatalog), + providersModelsJson, + auth, + thinkingControl, + modelSelection, + ]; } diff --git a/packages/coding-agent/test/fixtures/plugin-app/protocol.ts b/packages/coding-agent/test/fixtures/plugin-app/protocol.ts index 332817f57..31c28b7ba 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/protocol.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/protocol.ts @@ -1,4 +1,4 @@ -import type { AppKeybinding } from "../../../src/core/keybindings.ts"; +import { defineService, type RemoteState, type RpcOptions } from "./kernel.ts"; export type ThinkingLevel = "off" | "low" | "high"; @@ -23,50 +23,43 @@ export interface ProviderSnapshot { models: readonly ModelSpec[]; } -export interface CodingAgentSnapshot { - actions: ReadonlyArray<{ id: AppKeybinding; description: string }>; - commands: ReadonlyArray<{ name: string; description: string; argumentHint?: string }>; +export type RefreshState = + | { status: "idle" } + | { status: "refreshing" } + | { status: "done" } + | { status: "warning"; errors: Readonly> }; + +export interface ModelsState { + catalog: ProviderSnapshot; configuration: LaneConfiguration; - providers: ProviderSnapshot; + refresh: RefreshState; } -export type CodingAgentEvent = - | { type: "config_update"; previous: LaneConfiguration; value: LaneConfiguration } - | { type: "providers_changed"; providers: ProviderSnapshot }; - -export interface ModelSelectorState { - schema: 1; - query: string; - catalogRevision: number; - refresh: - | { status: "refreshing" } - | { status: "done" } - | { status: "warning"; errors: Readonly> }; +export interface ModelsService { + state: RemoteState; + cycleThinking(): Promise; + refresh(options?: RpcOptions): Promise; + select(model: ModelRef): Promise; } -export interface WireView { - id: string; - component: string; - state: unknown; -} +export const Models = defineService("models"); -export interface ClientSnapshot { - app: CodingAgentSnapshot; - views: readonly WireView[]; -} +export type StateSnapshot = Record>; -export type SessionRequest = - | { type: "submit"; input: string } - | { type: "invoke_action"; id: AppKeybinding } - | { type: "view_message"; viewId: string; message: unknown }; +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: "request"; id: number; request: SessionRequest } + | { type: "cancel"; id: number }; export type ServerWireMessage = - | { type: "snapshot"; snapshot: ClientSnapshot } - | { type: "event"; event: CodingAgentEvent } - | { type: "view_updated"; view: WireView } - | { type: "view_closed"; viewId: string } - | { type: "response"; id: number; error?: string }; + | { 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/providers.ts b/packages/coding-agent/test/fixtures/plugin-app/providers.ts new file mode 100644 index 000000000..616decb1d --- /dev/null +++ b/packages/coding-agent/test/fixtures/plugin-app/providers.ts @@ -0,0 +1,152 @@ +import type { ModelSpec, ProviderSnapshot } from "./protocol.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/run-session.ts b/packages/coding-agent/test/fixtures/plugin-app/run-session.ts index 43ec571d9..d3b27a5dc 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/run-session.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/run-session.ts @@ -1,38 +1,35 @@ -import { TestAppRuntime } from "./kernel.ts"; -import { createCodingAgentApp } from "./plugins.ts"; +import { createCodingAgentPlugins } from "./plugins.ts"; +import { SessionRuntime } from "./session.ts"; import { SessionTcpServer } from "./transport.ts"; async function main(): Promise { const host = process.argv[2] ?? "127.0.0.1"; const port = Number(process.argv[3] ?? "7777"); - const runtime = new TestAppRuntime( - createCodingAgentApp(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 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) => { - const stop = () => resolve(); - process.once("SIGINT", stop); - process.once("SIGTERM", stop); + process.once("SIGINT", resolve); + process.once("SIGTERM", resolve); }); await server.close(); runtime.close(); diff --git a/packages/coding-agent/test/fixtures/plugin-app/run-tui.ts b/packages/coding-agent/test/fixtures/plugin-app/run-tui.ts index 0c78a743c..409ac4e02 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/run-tui.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/run-tui.ts @@ -1,8 +1,8 @@ import { ProcessTerminal } from "@earendil-works/pi-tui"; import { SessionClient } from "./client.ts"; +import { createCodingAgentPlugins } from "./plugins.ts"; import { TcpClientTransport } from "./transport.ts"; import { MinimalCodingAgentTui } from "./tui/app.ts"; -import { modelSelectorTui } from "./tui/model-selector.ts"; async function main(): Promise { const host = process.argv[2] ?? "127.0.0.1"; @@ -10,7 +10,11 @@ async function main(): Promise { const transport = await TcpClientTransport.connect({ host, port, clientId: "tui" }); const client = new SessionClient(transport); await client.ready; - const app = new MinimalCodingAgentTui(new ProcessTerminal(), client, [modelSelectorTui]); + const app = new MinimalCodingAgentTui( + new ProcessTerminal(), + client, + createCodingAgentPlugins(async () => []), + ); const stop = () => app.stop(); process.once("SIGINT", stop); process.once("SIGTERM", stop); diff --git a/packages/coding-agent/test/fixtures/plugin-app/session.ts b/packages/coding-agent/test/fixtures/plugin-app/session.ts index cc64398b1..6a7aa6299 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/session.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/session.ts @@ -1,76 +1,34 @@ -import { type AppKeybinding, KEYBINDINGS } from "../../../src/core/keybindings.ts"; -import type { AppDefinition, AppInstance, AppPlugin } from "./kernel.ts"; -import type { - ClientSnapshot, - CodingAgentEvent, - CodingAgentSnapshot, - LaneConfiguration, - ModelSpec, - ProviderSnapshot, - ServerWireMessage, - SessionRequest, - WireView, -} from "./protocol.ts"; +import { type AppPlugin, type MutableRemoteState, rpcOptions, type Service } from "./kernel.ts"; +import type { ServerWireMessage, SessionRequest, StateSnapshot } from "./protocol.ts"; -type Contribution = (draft: D, owner: string) => void; +class SessionRemoteState implements MutableRemoteState { + private readonly listeners = new Set<(value: T) => void>(); + private current: T; -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()); + constructor(initial: T) { + this.current = structuredClone(initial); } - add(owner: string, run: Contribution): void { - this.contributions.push({ owner, run }); + get value(): T { + return this.current; } - rebuild(): S { - const draft = this.createDraft(); - for (const { owner, run } of this.contributions) run(draft, owner); - this.state = this.build(draft); - return this.state; + set(value: T): void { + this.current = structuredClone(value); + for (const listener of this.listeners) listener(this.current); } - current(): S { - return this.state; + subscribe(listener: (value: T) => void): () => void { + this.listeners.add(listener); + listener(this.current); + return () => this.listeners.delete(listener); } } -export interface InvocationContext { - originatorClientId: string; - signal: AbortSignal; -} - -interface ActionDefinition { - description: string; - handler(context: InvocationContext): Promise; -} - -interface CommandDefinition { - argumentHint?: string; - description: string; - handler(args: string, context: InvocationContext): Promise; -} - -type ViewMessageHandler = (message: unknown, fromClientId: string) => void | Promise; - -interface ViewRecord extends WireView { - closed: boolean; - handler: ViewMessageHandler | undefined; - resolveDone(): void; - to: readonly string[]; -} - class ClientHub { private readonly connections = new Map void>(); - connect(clientId: string, snapshot: () => ClientSnapshot, send: (message: ServerWireMessage) => void): () => void { + connect(clientId: string, snapshot: () => StateSnapshot, send: (message: ServerWireMessage) => void): () => void { const pending: ServerWireMessage[] = []; let ready = false; const connection = (message: ServerWireMessage) => { @@ -78,7 +36,7 @@ class ClientHub { else pending.push(message); }; this.connections.set(clientId, connection); - send({ type: "snapshot", snapshot: structuredClone(snapshot()) }); + send({ type: "snapshot", states: structuredClone(snapshot()) }); ready = true; for (const message of pending) send(message); return () => { @@ -89,388 +47,96 @@ class ClientHub { broadcast(message: ServerWireMessage): void { for (const send of this.connections.values()) send(structuredClone(message)); } - - send(clientIds: readonly string[], message: ServerWireMessage): void { - for (const clientId of clientIds) this.connections.get(clientId)?.(structuredClone(message)); - } } -interface ViewController { - close(id: string): void; - setHandler(id: string, handler: ViewMessageHandler): void; - update(id: string, state: unknown): void; -} - -export class ViewHandle { - readonly done: Promise; - readonly id: string; - private readonly controller: ViewController; - - constructor(id: string, done: Promise, controller: ViewController) { - this.controller = controller; - this.done = done; - this.id = id; - } - - set state(state: S) { - this.controller.update(this.id, state); - } - - on(handler: ViewMessageHandler): void { - this.controller.setHandler(this.id, handler); - } - - close(): void { - this.controller.close(this.id); - } -} - -class ViewService implements ViewController { - private readonly hub: ClientHub; - private readonly records = new Map(); - private nextId = 1; - openedCount = 0; - - constructor(hub: ClientHub) { - this.hub = hub; - } - - open(component: string, state: S, to: readonly string[]): ViewHandle { - const id = `view-${this.nextId++}`; - let resolveDone!: () => void; - const done = new Promise((resolve) => { - resolveDone = resolve; - }); - const record: ViewRecord = { - closed: false, - component, - handler: undefined, - id, - resolveDone, - state: structuredClone(state), - to: [...to], - }; - this.records.set(id, record); - this.openedCount++; - this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) }); - return new ViewHandle(id, done, this); - } - - forClient(clientId: string): WireView[] { - return [...this.records.values()] - .filter((record) => !record.closed && record.to.includes(clientId)) - .map((record) => this.toWire(record)); - } - - async send(id: string, clientId: string, message: unknown): Promise { - const record = this.records.get(id); - if (!record || record.closed || !record.to.includes(clientId)) return; - await record.handler?.(structuredClone(message), clientId); - } - - update(id: string, state: unknown): void { - const record = this.records.get(id); - if (!record || record.closed) return; - record.state = structuredClone(state); - this.hub.send(record.to, { type: "view_updated", view: this.toWire(record) }); - } - - setHandler(id: string, handler: ViewMessageHandler): void { - const record = this.records.get(id); - if (record && !record.closed) record.handler = handler; - } - - close(id: string): void { - const record = this.records.get(id); - if (!record || record.closed) return; - record.closed = true; - this.hub.send(record.to, { type: "view_closed", viewId: id }); - record.resolveDone(); - } - - closeAll(): void { - for (const id of this.records.keys()) this.close(id); - } - - private toWire(record: ViewRecord): WireView { - return { id: record.id, component: record.component, state: structuredClone(record.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(): ProviderSnapshot { - const state = this.registry.rebuild(); - const serialized = JSON.stringify({ availableModels: state.availableModels, models: state.models }); - if (serialized !== this.serialized) { - this.serialized = serialized; - this.revision++; - for (const listener of this.listeners) listener(this.snapshot()); - } - return 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 })), - }; - } - - onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - async refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap }> { - 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 { aborted: signal.aborted, errors }; - } -} - -export interface CodingAgentApi { - actions: { add(contribution: (draft: Map) => void): void }; - commands: { add(contribution: (draft: Map) => void): void }; - providers: { - add(contribution: (draft: ProviderDraft) => void): void; - onRebuild(listener: (snapshot: ProviderSnapshot) => void): () => void; - refresh(signal: AbortSignal): Promise<{ aborted: boolean; errors: ReadonlyMap }>; - snapshot(): ProviderSnapshot; - }; - session: { - configure(configuration: LaneConfiguration): Promise; - getConfiguration(): LaneConfiguration; - }; - settings: { set(key: string, value: unknown): Promise }; - view(component: string, state: S, options: { to: readonly string[] }): ViewHandle; +export interface SessionContext { + onActivate(callback: () => void | Promise): void; + onClose(callback: () => void): void; + provide(service: Service, implementation: T): void; + state(initial: T): MutableRemoteState; + use(service: Service): T; } export interface SessionDriver { - readonly openedViewCount: number; readonly trace: readonly string[]; connect(clientId: string, send: (message: ServerWireMessage) => void): () => void; - getConfiguration(): LaneConfiguration; - invokeAction(clientId: string, id: AppKeybinding): Promise; - providers: ProviderRegistry; - request(clientId: string, request: SessionRequest): Promise; - settings: ReadonlyMap; - submit(clientId: string, input: string): Promise; + request(clientId: string, request: SessionRequest, signal: AbortSignal): Promise; + use(service: Service): T; } -export type CodingAgentPlugin = AppPlugin; -export type CodingAgentApp = AppDefinition; +export class SessionRuntime { + readonly driver: SessionDriver; + private readonly activateCallbacks: Array<() => void | Promise> = []; + private readonly closeCallbacks: Array<() => void> = []; + private readonly hub = new ClientHub(); + private readonly plugins: readonly AppPlugin[]; + private readonly services = new Map(); + private readonly trace: string[] = []; -export function createSessionInstance(): AppInstance { - const actions = new ReplayRegistry, ReadonlyMap>( - () => new Map(), - (draft) => new Map(draft), - ); - const commands = new ReplayRegistry, ReadonlyMap>( - () => new Map(), - (draft) => new Map(draft), - ); - const providers = new ProviderRegistry(); - const settings = new Map(); - const trace: string[] = []; - const activeInvocations = new Set(); - const hub = new ClientHub(); - const views = new ViewService(hub); - let configuration: LaneConfiguration = { model: undefined, thinkingLevel: "high" }; + constructor(plugins: readonly AppPlugin[]) { + this.plugins = plugins; + this.driver = { + trace: this.trace, + connect: (clientId, send) => this.hub.connect(clientId, () => this.snapshot(), send), + 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({ signal })] : request.args; + return Reflect.apply(method, service, args); + }, + use: (service) => this.use(service), + }; + } - const appSnapshot = (): CodingAgentSnapshot => ({ - actions: [...actions.current()] - .filter((entry): entry is [AppKeybinding, ActionDefinition] => Object.hasOwn(KEYBINDINGS, entry[0])) - .map(([id, action]) => ({ id, description: action.description })), - commands: [...commands.current()].map(([name, command]) => ({ - name, - description: command.description, - ...(command.argumentHint ? { argumentHint: command.argumentHint } : {}), - })), - configuration: structuredClone(configuration), - providers: providers.snapshot(), - }); - providers.onRebuild((snapshot) => - hub.broadcast({ type: "event", event: { type: "providers_changed", providers: snapshot } }), - ); + async start(): Promise { + const context: SessionContext = { + onActivate: (callback) => this.activateCallbacks.push(callback), + onClose: (callback) => this.closeCallbacks.push(callback), + provide: (service, implementation) => this.provide(service, implementation), + state: (initial) => new SessionRemoteState(initial), + use: (service) => this.use(service), + }; + for (const plugin of this.plugins) await plugin.session?.(context); + for (const activate of this.activateCallbacks) await activate(); + } - const invoke = async (clientId: string, run: (context: InvocationContext) => Promise): Promise => { - const controller = new AbortController(); - activeInvocations.add(controller); - try { - await run({ originatorClientId: clientId, signal: controller.signal }); - } finally { - activeInvocations.delete(controller); + 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 SessionRemoteState)) continue; + value.subscribe((next) => { + this.hub.broadcast({ + type: "state_update", + service: service.id, + property, + value: structuredClone(next), + }); + }); } - }; + } - const submit = async (clientId: string, input: string): Promise => { - if (!input.startsWith("/")) throw new Error("Only slash commands are supported by this test app"); - const separator = input.indexOf(" "); - const name = input.slice(1, separator < 0 ? undefined : separator); - const args = separator < 0 ? "" : input.slice(separator + 1).trim(); - const command = commands.current().get(name); - if (!command) throw new Error(`Unknown command: ${name}`); - trace.push(`command:${name}`); - await invoke(clientId, (context) => command.handler(args, context)); - }; + 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; + } - const driver: SessionDriver = { - get openedViewCount() { - return views.openedCount; - }, - trace, - connect: (clientId, send) => - hub.connect(clientId, () => ({ app: appSnapshot(), views: views.forClient(clientId) }), send), - getConfiguration: () => structuredClone(configuration), - invokeAction: async (clientId, id) => { - const action = actions.current().get(id); - if (!action) throw new Error(`Unknown action: ${id}`); - trace.push(`action:${id}`); - await invoke(clientId, action.handler); - }, - providers, - request: async (clientId, request) => { - if (request.type === "submit") await submit(clientId, request.input); - else if (request.type === "invoke_action") await driver.invokeAction(clientId, request.id); - else await views.send(request.viewId, clientId, request.message); - }, - settings, - submit, - }; - - return { - driver, - activate() { - actions.rebuild(); - commands.rebuild(); - providers.rebuild(); - }, - api(owner) { - return { - actions: { add: (contribution) => actions.add(owner, (draft) => contribution(draft)) }, - commands: { add: (contribution) => commands.add(owner, (draft) => contribution(draft)) }, - providers: { - add: (contribution) => providers.add(owner, contribution), - onRebuild: (listener) => providers.onRebuild(listener), - refresh: (signal) => providers.refresh(signal), - snapshot: () => providers.snapshot(), - }, - session: { - configure: async (next) => { - const previous = structuredClone(configuration); - configuration = structuredClone(next); - trace.push(`config:${next.model?.provider}/${next.model?.modelId}`); - const event: CodingAgentEvent = { type: "config_update", previous, value: structuredClone(next) }; - hub.broadcast({ type: "event", event }); - }, - getConfiguration: () => structuredClone(configuration), - }, - settings: { - set: async (key, value) => { - settings.set(key, structuredClone(value)); - trace.push(`setting:${key}`); - }, - }, - view: (component, state, options) => { - trace.push(`view:open:${component}`); - return views.open(component, state, options.to); - }, - }; - }, - close() { - for (const controller of activeInvocations) controller.abort(); - views.closeAll(); - }, - }; + 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 SessionRemoteState) 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/transport.ts b/packages/coding-agent/test/fixtures/plugin-app/transport.ts index f6c7dd8c7..062efda68 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/transport.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/transport.ts @@ -76,6 +76,7 @@ export class SessionTcpServer { 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) => { @@ -89,17 +90,28 @@ export class SessionTcpServer { peer.send({ type: "response", id: message.id, error: "Client has not sent hello" }); return; } - void this.driver.request(clientId, message.request).then( - () => peer.send({ type: "response", id: message.id }), - (error: unknown) => - peer.send({ - type: "response", - id: message.id, - error: error instanceof Error ? error.message : String(error), - }), - ); + 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); }); @@ -109,7 +121,10 @@ export class SessionTcpServer { export class TcpClientTransport implements ClientTransport { private readonly buffered: ServerWireMessage[] = []; private readonly peer: JsonLineSocket; - private readonly pending = new Map(); + private readonly pending = new Map< + number, + { resolve(value: unknown): void; reject(error: unknown): void; stopAbort(): void } + >(); private readonly socket: Socket; private listener: ((message: ServerWireMessage) => void) | undefined; private nextRequestId = 1; @@ -122,8 +137,9 @@ export class TcpClientTransport implements ClientTransport { 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(); + else pending.resolve(message.result); } else if (this.listener) { this.listener(message); } else { @@ -131,7 +147,10 @@ export class TcpClientTransport implements ClientTransport { } }); socket.once("close", () => { - for (const pending of this.pending.values()) pending.reject(new Error("Session connection closed")); + for (const pending of this.pending.values()) { + pending.stopAbort(); + pending.reject(new Error("Session connection closed")); + } this.pending.clear(); }); this.peer.send({ type: "hello", clientId }); @@ -151,10 +170,18 @@ export class TcpClientTransport implements ClientTransport { for (const message of this.buffered.splice(0)) listener(message); } - request(request: SessionRequest): Promise { + request(request: SessionRequest, signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason); const id = this.nextRequestId++; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); + 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 }); }); } @@ -165,6 +192,7 @@ export class TcpClientTransport implements ClientTransport { } export class LoopbackTransport implements ClientTransport { + private readonly active = new Set(); private readonly buffered: ServerWireMessage[] = []; private readonly disconnect: () => void; private readonly driver: SessionDriver; @@ -185,11 +213,34 @@ export class LoopbackTransport implements ClientTransport { for (const message of this.buffered.splice(0)) listener(message); } - request(request: SessionRequest): Promise { - return this.driver.request(this.clientId, request); + request(request: SessionRequest, signal?: AbortSignal): Promise { + 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 { + for (const controller of this.active) controller.abort(); + this.active.clear(); this.disconnect(); } } diff --git a/packages/coding-agent/test/fixtures/plugin-app/tui/app.ts b/packages/coding-agent/test/fixtures/plugin-app/tui/app.ts index 95c000b5c..3e52b4159 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/tui/app.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/tui/app.ts @@ -8,34 +8,61 @@ import { TuiAltScreen, VStack, } from "@earendil-works/pi-tui"; -import { KeybindingsManager } from "../../../../src/core/keybindings.ts"; +import { type AppKeybinding, KeybindingsManager } from "../../../../src/core/keybindings.ts"; import { CustomEditor } from "../../../../src/modes/interactive/components/custom-editor.ts"; import type { SessionClient } from "../client.ts"; -import type { CodingAgentEvent } from "../protocol.ts"; +import type { CodingAgentPlugin } from "../plugins.ts"; +import { type LaneConfiguration, Models, type ModelsService } from "../protocol.ts"; import { PluginFooter } from "./footer.ts"; -import { accent, bold, border, dim, errorStyle, type TuiPlugin, type ViewRenderer } from "./shared.ts"; +import { + accent, + bold, + border, + dim, + errorStyle, + type TuiCommand, + type TuiContext, + type ViewRenderer, +} from "./shared.ts"; -/** Alternate-screen projection of the remote client store. */ +/** 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: ModelsService; private readonly renderers = new Map(); private readonly resolveDone: () => void; private readonly root: VStack; private readonly statusContainer = new Container(); private readonly transcript = new Container(); - private eventIndex = 0; + 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 TuiPlugin[]) { + constructor(terminal: Terminal, client: SessionClient, plugins: readonly CodingAgentPlugin[]) { this.client = client; - for (const plugin of plugins) plugin.setup(this.renderers); + 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, @@ -52,20 +79,38 @@ export class MinimalCodingAgentTui { 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(client.store), basis: 2, shrink: 0 }, + { component: new PluginFooter(this.models.state, () => client.store.updates), basis: 2, shrink: 0 }, ]); this.root = new VStack([ { @@ -82,7 +127,6 @@ export class MinimalCodingAgentTui { }, { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, ]); - let settleDone!: () => void; this.done = new Promise((resolve) => { settleDone = resolve; @@ -92,19 +136,8 @@ export class MinimalCodingAgentTui { start(): void { if (this.unsubscribe || this.stopped) return; - for (const action of this.client.store.app.actions) { - this.editor.onAction(action.id, () => { - void this.client.invokeAction(action.id).catch((error: unknown) => this.showError(error)); - }); - } - this.editor.setAutocompleteProvider( - new CombinedAutocompleteProvider( - [...this.client.store.app.commands, { name: "quit", description: "Exit the plugin application" }], - process.cwd(), - ), - ); this.unsubscribe = this.client.store.subscribe(() => { - this.reduceEvents(); + this.applyConfiguration(); this.renderView(); this.updateEditorBorder(); this.tui.requestRender(); @@ -115,18 +148,29 @@ export class MinimalCodingAgentTui { } submit(input: string): void { - const command = input.trim(); - if (!command) return; - if (command === "/quit" || command === "quit") { + const value = input.trim(); + if (!value) return; + if (value === "/quit" || value === "quit") { this.stop(); return; } - if (command !== "/model") { - this.transcript.addChild(new Spacer(1)); - this.transcript.addChild(new Text(`${accent(">")} ${command}`, 1, 0)); - this.showStatus(`Running ${command}`); + if (!value.startsWith("/")) { + this.showError(new Error("Only slash commands are supported by this test app")); + return; } - void this.client.submit(command).catch((error: unknown) => this.showError(error)); + 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(); } @@ -140,47 +184,52 @@ export class MinimalCodingAgentTui { this.resolveDone(); } - private reduceEvents(): void { - while (this.eventIndex < this.client.store.events.length) { - this.applyEvent(this.client.store.events[this.eventIndex++]!); + 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 applyEvent(event: CodingAgentEvent): void { - if (event.type !== "config_update") return; - if ( - event.previous.model?.provider !== event.value.model?.provider || - event.previous.model?.modelId !== event.value.model?.modelId - ) { - this.showStatus(event.value.model ? `Model: ${event.value.model.modelId}` : "Model cleared"); - } else if (event.previous.thinkingLevel !== event.value.thinkingLevel) { - this.showStatus(`Thinking level: ${event.value.thinkingLevel}`); - } + 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(); - const view = this.client.store.views[0]; - if (!view) { - this.query = ""; + if (!this.activeView) { this.editorContainer.addChild(this.editor); this.tui.setFocus(this.editor); return; } - const renderer = this.renderers.get(view.component); + const renderer = this.renderers.get(this.activeView); if (!renderer) { - this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${view.component}`), 1, 0)); + this.editorContainer.addChild(new Text(dim(`No TUI renderer for ${this.activeView}`), 1, 0)); this.tui.setFocus(null); return; } const rendered = renderer({ - app: this.client.store.app, + close: () => this.closeView(), query: this.query, - onQueryChange: (query) => { + setQuery: (query) => { this.query = query; }, - send: (message) => this.client.sendView(view.id, message), - view, }); this.editorContainer.addChild(rendered.component); this.tui.setFocus(rendered.focus); @@ -209,13 +258,13 @@ export class MinimalCodingAgentTui { } private updateEditorBorder(): void { - const snapshot = this.client.store.app; - const current = snapshot.configuration.model; - const currentSpec = current - ? snapshot.providers.availableModels.find( + 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 = currentSpec?.reasoning ? accent : border; + this.editor.borderColor = selected?.reasoning ? accent : border; } } diff --git a/packages/coding-agent/test/fixtures/plugin-app/tui/footer.ts b/packages/coding-agent/test/fixtures/plugin-app/tui/footer.ts index 2b93d27e1..8f09993f7 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/tui/footer.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/tui/footer.ts @@ -1,38 +1,41 @@ import { homedir } from "node:os"; import { type Component, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import type { CodingAgentClientStore } from "../client.ts"; +import type { RemoteState } from "../kernel.ts"; +import type { ModelsState } from "../protocol.ts"; import { dim } from "./shared.ts"; export class PluginFooter implements Component { - private readonly store: CodingAgentClientStore; + private readonly models: RemoteState; + private readonly updateCount: () => number; - constructor(store: CodingAgentClientStore) { - this.store = store; + constructor(models: RemoteState, updateCount: () => number) { + this.models = models; + this.updateCount = updateCount; } invalidate(): void {} render(width: number): string[] { - const snapshot = this.store.app; + 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 = snapshot.configuration.model; + const current = state.configuration.model; const currentSpec = current - ? snapshot.providers.availableModels.find( + ? state.catalog.availableModels.find( (model) => model.provider === current.provider && model.modelId === current.modelId, ) : undefined; const model = current ? `(${current.provider}) ${current.modelId}${ currentSpec?.reasoning - ? snapshot.configuration.thinkingLevel === "off" + ? state.configuration.thinkingLevel === "off" ? " • thinking off" - : ` • ${snapshot.configuration.thinkingLevel}` + : ` • ${state.configuration.thinkingLevel}` : "" }` : "no-model"; - const left = `events ${this.store.events.length} · catalogue r${snapshot.providers.revision}`; + 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/tui/model-selector.ts b/packages/coding-agent/test/fixtures/plugin-app/tui/model-selector.ts index 456524bb9..5f18044b3 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/tui/model-selector.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/tui/model-selector.ts @@ -1,60 +1,91 @@ import { type Component, Container, type Focusable, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui"; -import type { ModelSelectorState, ModelSpec } from "../protocol.ts"; -import { accent, border, dim, success, type TuiPlugin, warning } from "./shared.ts"; +import { rpcOptions } from "../kernel.ts"; +import type { ModelSpec, ModelsService, ModelsState } from "../protocol.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 isModelSelectorState(value: unknown): value is ModelSelectorState { - if (typeof value !== "object" || value === null) return false; - const candidate = value as Partial; - return candidate.schema === 1 && typeof candidate.catalogRevision === "number"; +export function registerModelTui(context: TuiContext, models: ModelsService): 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 }; + }); } -export const modelSelectorTui: TuiPlugin = { - id: "@pi/model-selection-tui", - setup(renderers) { - renderers.set("model-selector", (context) => { - if (!isModelSelectorState(context.view.state)) throw new Error("Invalid model-selector state"); - const component = new ModelSelectorComponent({ - state: context.view.state, - models: context.app.providers.availableModels, - query: context.query || context.view.state.query, - onQueryChange: context.onQueryChange, - send: context.send, - }); - return { component, focus: component }; - }); - }, -}; - -export class ModelSelectorComponent extends Container implements Focusable { +class ModelSelector extends Container implements Focusable { private readonly input = new Input(); private readonly list = new Container(); - private readonly models: readonly ModelSpec[]; - private readonly onQueryChange: (query: string) => void; - private readonly send: (message: unknown) => Promise; - private readonly state: ModelSelectorState; - private filteredModels: readonly ModelSpec[] = []; - private selectedIndex = 0; + 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: ModelSelectorState; - models: readonly ModelSpec[]; + state: ModelsState; query: string; - onQueryChange(query: string): void; - send(message: unknown): Promise; + setQuery(query: string): void; + cancel(): void; + select(model: ModelSpec): Promise; }) { super(); - this.models = options.models; - this.onQueryChange = options.onQueryChange; - this.send = options.send; + 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()); @@ -68,61 +99,51 @@ export class ModelSelectorComponent extends Container implements Focusable { this.addChild(this.list); this.addChild(new Spacer(1)); this.addChild(new Rule()); - this.updateList(); + this.update(); } get focused(): boolean { return this._focused; } - set focused(value: boolean) { this._focused = value; this.input.focused = value; } handleInput(data: string): void { - const keybindings = getKeybindings(); - if (keybindings.matches(data, "tui.select.up")) { - if (this.filteredModels.length > 0) { - this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; - this.updateList(); - } - } else if (keybindings.matches(data, "tui.select.down")) { - if (this.filteredModels.length > 0) { - this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; - this.updateList(); - } - } else if (keybindings.matches(data, "tui.select.confirm")) { - const model = this.filteredModels[this.selectedIndex]; - if (model) void this.send({ type: "select", provider: model.provider, modelId: model.modelId }); - } else if (keybindings.matches(data, "tui.select.cancel")) { - void this.send({ type: "cancel" }); + 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.onQueryChange(this.input.getValue()); - this.selectedIndex = 0; - this.updateList(); + this.setQuery(this.input.getValue()); + this.selected = 0; } + this.update(); } - private updateList(): void { + private update(): void { const query = this.input.getValue().trim().toLowerCase(); - this.filteredModels = query - ? this.models.filter((model) => - `${model.provider} ${model.modelId} ${model.name}`.toLowerCase().includes(query), - ) - : this.models; - this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1)); + 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 maxVisible = 10; - const start = Math.max( - 0, - Math.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible), - ); - for (let index = start; index < Math.min(start + maxVisible, this.filteredModels.length); index++) { - const model = this.filteredModels[index]; + 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.selectedIndex; + const selected = index === this.selected; this.list.addChild( new Text( `${selected ? accent("→ ") : " "}${selected ? accent(model.modelId) : model.modelId} ${dim(`[${model.provider}]`)}`, @@ -131,22 +152,20 @@ export class ModelSelectorComponent extends Container implements Focusable { ), ); } - if (this.filteredModels.length === 0) { - this.list.addChild(new Text(dim(" No matching models"), 0, 0)); - } else { - const selected = this.filteredModels[this.selectedIndex]; - if (selected) { + 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: ${selected.name}`), 0, 0)); + this.list.addChild(new Text(dim(` Model Name: ${model.name}`), 0, 0)); } } this.list.addChild(new Spacer(1)); - if (this.state.refresh.status === "refreshing") { + if (this.state.refresh.status === "refreshing") this.list.addChild(new Text(dim(" Refreshing model catalogs…"), 0, 0)); - } else if (this.state.refresh.status === "warning") { + else if (this.state.refresh.status === "warning") this.list.addChild(new Text(warning(" Could not refresh model catalogs; showing cached models."), 0, 0)); - } else { + 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/tui/shared.ts b/packages/coding-agent/test/fixtures/plugin-app/tui/shared.ts index ee7214840..df170314f 100644 --- a/packages/coding-agent/test/fixtures/plugin-app/tui/shared.ts +++ b/packages/coding-agent/test/fixtures/plugin-app/tui/shared.ts @@ -1,5 +1,6 @@ import type { Component } from "@earendil-works/pi-tui"; -import type { CodingAgentSnapshot, WireView } from "../protocol.ts"; +import type { AppKeybinding } from "../../../../src/core/keybindings.ts"; +import type { Service } from "../kernel.ts"; const style = (open: number, close: number) => @@ -14,22 +15,33 @@ 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 ViewRendererContext { - app: CodingAgentSnapshot; +export interface LocalViewContext { + close(): void; query: string; - onQueryChange(query: string): void; - send(message: unknown): Promise; - view: WireView; + setQuery(query: string): void; } -export type ViewRenderer = (context: ViewRendererContext) => RenderedView; +export type ViewRenderer = (context: LocalViewContext) => RenderedView; -export interface TuiPlugin { - id: string; - setup(renderers: Map): void; +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/plugin-app-model-vertical-slice.test.ts b/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts index 5a96853db..1b1319916 100644 --- a/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts +++ b/packages/coding-agent/test/plugin-app-model-vertical-slice.test.ts @@ -1,50 +1,40 @@ import { describe, expect, it, vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import { SessionClient } from "./fixtures/plugin-app/client.ts"; -import { defineApp, TestAppRuntime } from "./fixtures/plugin-app/kernel.ts"; -import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts"; -import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts"; +import { createCodingAgentPlugins, Settings } from "./fixtures/plugin-app/plugins.ts"; +import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts"; +import { SessionRuntime } from "./fixtures/plugin-app/session.ts"; import { LoopbackTransport } from "./fixtures/plugin-app/transport.ts"; -import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts"; -import type { ViewRenderer } from "./fixtures/plugin-app/tui/shared.ts"; +import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts"; interface Deferred { promise: Promise; - reject(error: unknown): void; resolve(value: T): void; } function createDeferred(): Deferred { - let rejectPromise!: (error: unknown) => void; let resolvePromise!: (value: T) => void; - const promise = new Promise((resolve, reject) => { - rejectPromise = reject; + const promise = new Promise((resolve) => { resolvePromise = resolve; }); - return { promise, reject: rejectPromise, resolve: resolvePromise }; + return { promise, resolve: resolvePromise }; } -function modelView(client: SessionClient): WireView & { state: ModelSelectorState } { - const view = client.store.views.find((candidate) => candidate.component === "model-selector"); - if (!view || typeof view.state !== "object" || view.state === null || !("schema" in view.state)) { - throw new Error("Model selector view is not open"); - } - return view as WireView & { state: ModelSelectorState }; -} - -describe("app-as-plugins /model vertical slice", () => { - it("builds the session from plugins and runs /model over a serializable client boundary", async () => { +describe("app-as-plugins model service", () => { + it("replicates RemoteState and invokes typed methods over a serializable boundary", async () => { const catalogue = createDeferred(); let refreshCalls = 0; - const definition = createCodingAgentApp((_signal) => { + const plugins = createCodingAgentPlugins(() => { refreshCalls++; return catalogue.promise; }); - const runtime = new TestAppRuntime(definition); + 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(definition.plugins.map((plugin) => plugin.id)).toEqual([ + expect(plugins.map((plugin) => plugin.id)).toEqual([ "@pi/providers-builtin", "@pi/providers-catalog", "@pi/providers-models-json", @@ -52,97 +42,72 @@ describe("app-as-plugins /model vertical slice", () => { "@pi/thinking-control", "@pi/model-selection", ]); - expect(client.store.app.actions).toEqual([{ id: "app.thinking.cycle", description: "Cycle thinking level" }]); - expect(client.store.app.commands).toEqual([ - { name: "model", argumentHint: "[provider/]model", description: "Select model" }, - ]); - expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]); - expect(client.store.app.providers.models.map((model) => model.modelId)).toEqual(["base", "cached", "hidden"]); + 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 invocation = client.submit("/model"); - expect(modelView(client).state.refresh).toEqual({ status: "refreshing" }); + 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 vi.waitFor(() => expect(modelView(client).state.refresh).toEqual({ status: "done" })); - expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "fresh"]); - - await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" }); - await invocation; - expect(client.store.views).toEqual([]); - expect(runtime.driver.getConfiguration()).toEqual({ + 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.settings.get("defaultModel")).toEqual({ provider: "acme", modelId: "fresh" }); - expect(client.store.events.filter((event) => event.type === "config_update")).toHaveLength(1); - expect(runtime.driver.trace).toEqual([ - "command:model", - "view:open:model-selector", - "config:acme/fresh", - "setting:defaultModel", - ]); - - const openedViews = runtime.driver.openedViewCount; - await client.submit("/model acme/base"); - expect(runtime.driver.openedViewCount).toBe(openedViews); - expect(refreshCalls).toBe(1); - expect(runtime.driver.getConfiguration().model).toEqual({ provider: "acme", modelId: "base" }); + 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:models.refresh", "rpc:models.select"]); + unsubscribe(); client.close(); runtime.close(); }); - it("keeps command and TUI renderer plugins independently removable", async () => { - const complete = createCodingAgentApp(async () => []); - const commandless = new TestAppRuntime( - defineApp({ - ...complete, - plugins: complete.plugins.filter((plugin) => plugin.id !== "@pi/model-selection"), - }), + 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"), ); - await commandless.start(); - const commandlessClient = new SessionClient(new LoopbackTransport(commandless.driver, "commandless")); - await commandlessClient.ready; - expect(commandlessClient.store.app.commands).toEqual([]); - await expect(commandlessClient.submit("/model")).rejects.toThrow("Unknown command: model"); - commandlessClient.close(); - commandless.close(); - - const headless = new TestAppRuntime(complete); - await headless.start(); - const headlessClient = new SessionClient(new LoopbackTransport(headless.driver, "headless")); - await headlessClient.ready; - const invocation = headlessClient.submit("/model"); - expect(modelView(headlessClient).component).toBe("model-selector"); - const renderers = new Map(); - expect(renderers.has("model-selector")).toBe(false); - modelSelectorTui.setup(renderers); - expect(renderers.has("model-selector")).toBe(true); - headless.close(); - await invocation; - headlessClient.close(); + 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 TestAppRuntime( - createCodingAgentApp(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 invocation = client.submit("/model"); - await vi.waitFor(() => - expect(modelView(client).state.refresh).toEqual({ - status: "warning", - errors: { "@pi/providers-catalog:remote": "catalogue offline" }, - }), - ); - expect(client.store.app.providers.availableModels.map((model) => model.modelId)).toEqual(["base", "cached"]); - await client.sendView(modelView(client).id, { type: "cancel" }); - await invocation; + 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 index 47838947b..73950f13f 100644 --- a/packages/coding-agent/test/plugin-app-tui.test.ts +++ b/packages/coding-agent/test/plugin-app-tui.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import { SessionClient } from "./fixtures/plugin-app/client.ts"; -import { TestAppRuntime } from "./fixtures/plugin-app/kernel.ts"; -import { createCodingAgentApp } from "./fixtures/plugin-app/plugins.ts"; -import type { ModelSelectorState, ModelSpec, WireView } from "./fixtures/plugin-app/protocol.ts"; +import { createCodingAgentPlugins } from "./fixtures/plugin-app/plugins.ts"; +import { type ModelSpec, Models } from "./fixtures/plugin-app/protocol.ts"; +import { SessionRuntime } from "./fixtures/plugin-app/session.ts"; import { LoopbackTransport, SessionTcpServer, TcpClientTransport } from "./fixtures/plugin-app/transport.ts"; import { MinimalCodingAgentTui } from "./fixtures/plugin-app/tui/app.ts"; -import { modelSelectorTui } from "./fixtures/plugin-app/tui/model-selector.ts"; interface Deferred { promise: Promise; @@ -21,30 +20,26 @@ function createDeferred(): Deferred { return { promise, resolve: resolvePromise }; } -function modelView(client: SessionClient): WireView & { state: ModelSelectorState } { - const view = client.store.views.find((candidate) => candidate.component === "model-selector"); - if (!view) throw new Error("Model selector view is not open"); - return view as WireView & { state: ModelSelectorState }; -} - async function viewportText(terminal: VirtualTerminal): Promise { await terminal.waitForRender(); return terminal.getViewport().join("\n"); } describe("remote plugin app TUI", () => { - it("renders and controls a session over a real TCP transport", async () => { + it("renders and controls typed session services over real TCP", async () => { const catalogue = createDeferred(); - const runtime = new TestAppRuntime(createCodingAgentApp(() => catalogue.promise)); + 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, [modelSelectorTui]); + const app = new MinimalCodingAgentTui(terminal, client, plugins); try { app.start(); @@ -52,11 +47,11 @@ describe("remote plugin app TUI", () => { const initial = await viewportText(terminal); expect(initial).toContain("pi plugin application"); expect(initial).toContain("Remote session connected"); - expect(initial).toContain("events 0 · catalogue r1"); + expect(initial).toContain("updates 0 · catalogue r1"); expect(initial).toContain("no-model"); terminal.sendInput("\x1b[Z"); - await vi.waitFor(() => expect(client.store.app.configuration.thinkingLevel).toBe("off")); + await vi.waitFor(() => expect(models.state.value.configuration.thinkingLevel).toBe("off")); expect(await viewportText(terminal)).toContain("Thinking level: off"); terminal.sendInput("/"); @@ -69,26 +64,34 @@ describe("remote plugin app TUI", () => { terminal.sendInput("\t"); await vi.waitFor(async () => expect(await viewportText(terminal)).toContain("/model ")); terminal.sendInput("\r"); - await vi.waitFor(() => expect(modelView(client).state.refresh.status).toBe("refreshing")); + 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(modelView(client).state.refresh.status).toBe("done")); + await vi.waitFor(() => expect(models.state.value.refresh.status).toBe("done")); const refreshed = await viewportText(terminal); - expect(refreshed).toContain("events 2 · catalogue r2"); expect(refreshed).toContain("fresh [acme]"); expect(refreshed).toContain("Model catalogs refreshed"); - await client.sendView(modelView(client).id, { type: "select", provider: "acme", modelId: "fresh" }); - await vi.waitFor(() => expect(client.store.views).toEqual([])); + 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("events 3 · catalogue r2"); 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"); @@ -101,28 +104,66 @@ describe("remote plugin app TUI", () => { } }); - it("applies events buffered after the connection snapshot", async () => { - const runtime = new TestAppRuntime(createCodingAgentApp(async () => [])); + 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("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.submit("buffered", "/model acme/base"); + await runtime.driver.use(Models).select({ provider: "acme", modelId: "base" }); const client = new SessionClient(transport); await client.ready; + const models = client.use(Models); - expect(client.store.app.configuration).toEqual({ + expect(models.state.value.configuration).toEqual({ model: { provider: "acme", modelId: "base" }, thinkingLevel: "high", }); - expect(client.store.events).toEqual([ - { - type: "config_update", - previous: { model: undefined, thinkingLevel: "high" }, - value: { - 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(); });