From 1bf0de21441a19fcbf476bfcfb9a867d1faecf73 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 29 Aug 2026 20:31:33 +0200 Subject: [PATCH] feat: simplify Chord service APIs --- packages/agent/docs/plugins.md | 66 +++++++++---------- packages/chord/src/api.ts | 31 +-------- packages/chord/src/facets/host.ts | 38 +++-------- packages/chord/src/index.ts | 2 - packages/chord/src/services/consumer.ts | 10 +-- packages/chord/src/services/instances.ts | 12 ---- packages/chord/src/types.ts | 6 +- packages/chord/test/facet-loader.test.ts | 2 +- packages/chord/test/facets.test.ts | 20 +++--- packages/chord/test/helpers.ts | 19 ++++++ packages/chord/test/services.test.ts | 9 +-- .../src/experimental/client-runtime.ts | 2 +- .../src/experimental/services/connection.ts | 28 ++++---- .../test/experimental-client-tui.test.ts | 24 +++++-- 14 files changed, 108 insertions(+), 161 deletions(-) create mode 100644 packages/chord/test/helpers.ts diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 367422531..ca0004a40 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -278,7 +278,7 @@ A service has **one owner and many consumers**. In singleton mode, `providersBui - **Local:** `use()` returns a stable lazy proxy backed by a direct process-local implementation slot. During synchronous setup it is disconnected; after assembly it binds to the local implementation without requiring provider-before-consumer setup order. Reload unbinds and rebinds that same slot. - **Remote:** across a connection, `use()` returns the same kind of stable lazy proxy. Calls made while disconnected fail when invoked; state has no value until hydrated. Concurrent consumers of one token in one process share one proxy, one state replica, and one remote subscription. -Multi-instance services use `provideMany()` and `observe()`. The service is empty until its setup-owned `ServiceSpawner` calls `spawn()`; observing it never creates an instance. `spawner.spawn(key, implementation)` returns an idempotent close function, and the key must be unique among that service's live instances. Local observers use a direct process-local instance registry; non-local provisions additionally publish the same instance through RPC. `observe(service, handler)` reconciles current instances and then ordered additions, replacements, and removals. The handler receives the same `T` proxy shape as `use()`; `getServiceInstanceKey(proxy)` exposes its keyed application identity when needed. After an instance's initial state members hydrate, the host starts one handler task with a fresh `Context`. Closing the instance aborts that context, rejects new calls, and lets already-admitted calls return. Cancellation from the instance context is normal task cleanup; other handler failures follow host failure policy. Reusing a closed key creates a new host-owned generation, so stale proxies cannot address the replacement. +Multi-instance services use `provideMany()` and `observe()`. The service is empty until its setup-owned `ServiceSpawner` calls `spawn()`; observing it never creates an instance. `spawner.spawn(key, implementation)` returns an idempotent close function, and the key must be unique among that service's live instances. Local observers use a direct process-local instance registry; non-local provisions additionally publish the same instance through RPC. `observe(service, handler)` reconciles current instances and then ordered additions, replacements, and removals. The handler receives the same `T` proxy shape as `use()`; instance keys remain provider-side addressing details. After an instance's initial state members hydrate, the host starts one handler task with a fresh `Context`. The facet lifecycle owns that observation. Closing the instance aborts its task context, rejects new calls, and lets already-admitted calls return. Cancellation from the instance context is normal task cleanup; other handler failures follow host failure policy. Reusing a closed key creates a new host-owned generation, so stale proxies cannot address the replacement. An added instance member has structural identity `(service, key, generation, member)`. Its `ReplicatedState` members therefore need no independent IDs. The instance directory is control-plane metadata, not a facet-visible `ReplicatedState` containing proxies. Switching sessions aborts all observed instance tasks before hydrating the selected session's current instances. @@ -319,7 +319,7 @@ interface FacetEnvironment extends FacetLifecycle { observe( service: Service, handler: (service: T, context: Context) => void | Promise, - ): () => void; + ): void; provide(service: Service, implementation: T): void; provideMany(service: Service): ServiceSpawner; replicatedState(initial: T): MutableReplicatedState; @@ -816,41 +816,39 @@ export const questionTuiFacet = defineFacet({ id: "@pi/question", setup(env) { const tui = env.use(Tui); - env.own( - env.observe(QuestionDialogs, async (dialog, context) => { - const request = dialog.service.request.value; - if (request === undefined) throw new Error("Question dialog was observed before hydration"); + env.observe(QuestionDialogs, async (dialog, context) => { + const request = dialog.request.value; + if (request === undefined) throw new Error("Question dialog was observed before hydration"); - const modal = await tui.acquireModal(context.abortSignal); - try { - const choice = await modal.select( - request.question, - [ - ...request.options.map((option, index) => ({ - label: option.label, - ...(option.description === null ? {} : { description: option.description }), - value: { outcome: "selected" as const, index }, - })), - { label: "Write a custom answer", value: { outcome: "custom" as const } }, - ], - ); + const modal = await tui.acquireModal(context.abortSignal); + try { + const choice = await modal.select( + request.question, + [ + ...request.options.map((option, index) => ({ + label: option.label, + ...(option.description === null ? {} : { description: option.description }), + value: { outcome: "selected" as const, index }, + })), + { label: "Write a custom answer", value: { outcome: "custom" as const } }, + ], + ); - let response: QuestionResponse; - if (choice === undefined) { - response = { outcome: "cancelled" }; - } else if (choice.outcome === "selected") { - response = choice; - } else { - const answer = await modal.input(request.question); - response = answer === undefined ? { outcome: "cancelled" } : { outcome: "custom", answer }; - } - - await dialog.service.submitAnswer(response, context); - } finally { - modal.close(); + let response: QuestionResponse; + if (choice === undefined) { + response = { outcome: "cancelled" }; + } else if (choice.outcome === "selected") { + response = choice; + } else { + const answer = await modal.input(request.question); + response = answer === undefined ? { outcome: "cancelled" } : { outcome: "custom", answer }; } - }), - ); + + await dialog.submitAnswer(response, context); + } finally { + modal.close(); + } + }); tui.toolRenderers.add("question", questionRenderer); }, diff --git a/packages/chord/src/api.ts b/packages/chord/src/api.ts index ba87f1eee..23958d713 100644 --- a/packages/chord/src/api.ts +++ b/packages/chord/src/api.ts @@ -1,9 +1,7 @@ import { FacetKernel } from "./facets/host.ts"; import { disposeLoadedFacets } from "./facets/loader.ts"; import { RemoteServiceBindingImpl } from "./services/consumer.ts"; -import { lookupServiceInstanceKey } from "./services/instances.ts"; -import type { RemoteServiceProvider } from "./services/provider.ts"; -import { MutableReplicatedStateImpl, serviceDeliveryContext } from "./services/state.ts"; +import { MutableReplicatedStateImpl } from "./services/state.ts"; import type { Facet, FacetHost, @@ -13,7 +11,6 @@ import type { MutableReplicatedState, RemoteServiceBinding, RemoteServiceBindingOptions, - RemoteServiceConnection, RemoteServiceContract, Service, } from "./types.ts"; @@ -89,32 +86,6 @@ export function createRemoteServiceBinding(options: RemoteServiceBindingOptions) return new RemoteServiceBindingImpl(options); } -/** - * Return the application key for a keyed service proxy, or undefined for a singleton. - * - * @publicApiReview Consider passing the key directly to keyed observers instead of exposing proxy metadata. - */ -export function getServiceInstanceKey(service: object): string | undefined { - return lookupServiceInstanceKey(service); -} - -/** @publicApiReview Consider keeping this loopback transport adapter in testing support. */ -export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection { - return { - invoke: (call, context) => provider.invoke(call, context), - subscribe: async (serviceId, mode, listener) => { - const subscription = provider.subscribe(serviceId, mode, (update) => - listener(update, serviceDeliveryContext()), - ); - return { - snapshot: subscription.snapshot, - activate: () => subscription.activate(), - close: () => subscription.close(), - }; - }, - }; -} - export function replicatedState(initial: T): MutableReplicatedState { return new MutableReplicatedStateImpl(initial); } diff --git a/packages/chord/src/facets/host.ts b/packages/chord/src/facets/host.ts index 0955e5e2b..5be57edf9 100644 --- a/packages/chord/src/facets/host.ts +++ b/packages/chord/src/facets/host.ts @@ -119,11 +119,6 @@ class FacetLifecycle { } } -interface StagedObservation { - start(): () => void; - stop(): void; -} - interface KeyedServiceSource { observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; } @@ -226,23 +221,11 @@ class FacetServiceHandles { return handle.proxy as T; } - observe(service: Service, handler: (service: T, context: Context) => void | Promise): StagedObservation { - let close: (() => void) | undefined; - let closed = false; - const stop = (): void => { - if (closed) return; - closed = true; - close?.(); - }; - return { - start: () => { - if (closed) return () => {}; - const source = this.#keyedSources.get(service.id); - if (source === undefined) throw new Error(`Service ${service.id} is disconnected`); - close = source.observe(service, handler); - return stop; - }, - stop, + observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => Disposal { + return () => { + const source = this.#keyedSources.get(service.id); + if (source === undefined) throw new Error(`Service ${service.id} is disconnected`); + return source.observe(service, handler); }; } @@ -390,7 +373,7 @@ export class FacetKernel { this.#phase = "connecting"; await Promise.all( - [...this.#connectionBindings.values()].map((services) => services.activate(BACKGROUND_CONTEXT)), + [...this.#connectionBindings.values()].map((services) => services.ready(BACKGROUND_CONTEXT)), ); this.#phase = "activating"; @@ -575,15 +558,10 @@ export class FacetKernel { recordServiceReference(runtime.requires, service, "singleton"); return this.#handles.use(service); }, - observe: ( - service: Service, - handler: (service: T, context: Context) => void | Promise, - ): (() => void) => { + observe: (service: Service, handler: (service: T, context: Context) => void | Promise): void => { lifecycle.assertSettingUp("observe services"); recordServiceReference(runtime.requires, service, "keyed"); - const observation = this.#handles.observe(service, handler); - lifecycle.observe(() => observation.start()); - return observation.stop; + lifecycle.observe(this.#handles.observe(service, handler)); }, replicatedState: (initial: T) => { lifecycle.assertRunning("create replicated state"); diff --git a/packages/chord/src/index.ts b/packages/chord/src/index.ts index d7e922cef..100298860 100644 --- a/packages/chord/src/index.ts +++ b/packages/chord/src/index.ts @@ -4,12 +4,10 @@ export { combineFacetLoaders, createFacetHost, - createLoopbackServiceConnection, createRemoteServiceBinding, createStaticFacetLoader, defineFacet, defineService, - getServiceInstanceKey, replicatedState, } from "./api.ts"; export { RemoteServiceError } from "./services/errors.ts"; diff --git a/packages/chord/src/services/consumer.ts b/packages/chord/src/services/consumer.ts index 4be99fdd0..9000619ee 100644 --- a/packages/chord/src/services/consumer.ts +++ b/packages/chord/src/services/consumer.ts @@ -410,7 +410,7 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding { readonly #allowlist = new Set(); readonly #reportError: ErrorReporter; readonly #modes = new Map(); - #assertAccess: () => void; + readonly #assertAccess: () => void; readonly #singletons = new Map(); readonly #keyed = new Map>(); #bound: boolean; @@ -481,14 +481,6 @@ export class RemoteServiceBindingImpl implements RemoteServiceBinding { return binding.observe(handler); } - setAccessGuard(assertAccess: () => void): void { - this.#assertAccess = assertAccess; - } - - async activate(context: Context): Promise { - await this.ready(context); - } - async ready(context: Context): Promise { if (this.#disposed) throw new Error("Remote service binding is disposed"); while (true) { diff --git a/packages/chord/src/services/instances.ts b/packages/chord/src/services/instances.ts index 0d7076ecd..b668758fc 100644 --- a/packages/chord/src/services/instances.ts +++ b/packages/chord/src/services/instances.ts @@ -1,12 +1,6 @@ import { BACKGROUND_CONTEXT, withCancel } from "../context/index.ts"; import type { Context } from "../types.ts"; -const instanceKeys = new WeakMap(); - -export function lookupServiceInstanceKey(service: object): string | undefined { - return instanceKeys.get(service); -} - export interface InstanceDirectoryEntry { readonly key: string; readonly generation: number; @@ -47,7 +41,6 @@ export class InstanceDirectory { throw new Error(`Keyed service already has a live instance with key ${entry.key}`); } this.#entries.set(entry.key, entry); - registerServiceInstance(entry.service, entry.key); if (this.#ready) this.#startAll(entry); } @@ -61,7 +54,6 @@ export class InstanceDirectory { this.#remove(previous); } this.#entries.set(entry.key, entry); - registerServiceInstance(entry.service, entry.key); if (this.#ready) this.#startAll(entry); } @@ -150,10 +142,6 @@ export class InstanceDirectory { } } -function registerServiceInstance(service: object, key: string): void { - instanceKeys.set(service, key); -} - function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/packages/chord/src/types.ts b/packages/chord/src/types.ts index 77fc588c5..f81ac45c6 100644 --- a/packages/chord/src/types.ts +++ b/packages/chord/src/types.ts @@ -93,12 +93,8 @@ export interface ServiceSpawner { export interface RemoteServices { use(service: Service): T; observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; - /** Activate this service set and wait for every acquired service to become ready. */ - activate(context: Context): Promise; /** Wait until every currently acquired service has installed its initial snapshot. */ ready(context: Context): Promise; - /** Install a host lifecycle guard for proxy member access. */ - setAccessGuard(assertAccess: () => void): void; dispose(context: Context): Promise; } @@ -207,7 +203,7 @@ export interface FacetEnvironment { /** Declare a hard dependency on one singleton service and return its stable handle. */ use(service: Service): T; /** Declare a hard dependency on a keyed service and observe each live instance. */ - observe(service: Service, handler: (service: T, context: Context) => void | Promise): () => void; + observe(service: Service, handler: (service: T, context: Context) => void | Promise): void; /** Declare and install this facet's singleton implementation of a service. */ provide(service: Service, implementation: NoInfer): void; /** Declare ownership of a multi-instance service and return its deferred spawning capability. */ diff --git a/packages/chord/test/facet-loader.test.ts b/packages/chord/test/facet-loader.test.ts index 75d2b2f82..ae71fb3d8 100644 --- a/packages/chord/test/facet-loader.test.ts +++ b/packages/chord/test/facet-loader.test.ts @@ -4,13 +4,13 @@ import { type Context, combineFacetLoaders, createFacetHost, - createLoopbackServiceConnection, createRemoteServiceBinding, createStaticFacetLoader, defineFacet, defineService, type FacetLoader, } from "../src/index.ts"; +import { createLoopbackServiceConnection } from "./helpers.ts"; interface GenerationValue { read(context: Context): Promise; diff --git a/packages/chord/test/facets.test.ts b/packages/chord/test/facets.test.ts index 952e88814..0c2805e24 100644 --- a/packages/chord/test/facets.test.ts +++ b/packages/chord/test/facets.test.ts @@ -3,7 +3,6 @@ import { BACKGROUND_CONTEXT } from "../src/context/index.ts"; import { type Context, createFacetHost, - createLoopbackServiceConnection, createRemoteServiceBinding, defineFacet, defineService, @@ -12,6 +11,7 @@ import { RemoteServiceProvider, type ReplicatedState, } from "../src/index.ts"; +import { createLoopbackServiceConnection } from "./helpers.ts"; interface Source { read(context: Context): Promise; @@ -283,8 +283,9 @@ describe("facet host", () => { const connections = [ { namespace: leftNamespace, provider: leftProvider }, { namespace: rightNamespace, provider: rightProvider }, - ].map(({ namespace, provider }) => - Object.assign(namespace, { + ].map(({ namespace, provider }) => { + const ready = namespace.ready.bind(namespace); + return Object.assign(namespace, { acceptsUnavailableServices: false, async catalogue() { return provider.catalogue; @@ -292,12 +293,12 @@ describe("facet host", () => { open() { return namespace; }, - async activate(context: Context) { + async ready(context: Context) { await namespace.rebind(true, context); - await namespace.ready(context); + await ready(context); }, - }), - ); + }); + }); const facet = defineFacet({ id: "combined", setup(env) { @@ -371,11 +372,12 @@ describe("facet host", () => { assertAccess: options.assertAccess, onError: options.onError, }); + const ready = namespace.ready.bind(namespace); const dispose = namespace.dispose.bind(namespace); return Object.assign(namespace, { - async activate(context: Context) { + async ready(context: Context) { await namespace.rebind(true, context); - await namespace.ready(context); + await ready(context); }, async dispose(context: Context) { disposed += 1; diff --git a/packages/chord/test/helpers.ts b/packages/chord/test/helpers.ts new file mode 100644 index 000000000..838e884e6 --- /dev/null +++ b/packages/chord/test/helpers.ts @@ -0,0 +1,19 @@ +import type { RemoteServiceConnection } from "../src/index.ts"; +import type { RemoteServiceProvider } from "../src/services/provider.ts"; +import { serviceDeliveryContext } from "../src/services/state.ts"; + +export function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection { + return { + invoke: (call, context) => provider.invoke(call, context), + subscribe: async (serviceId, mode, listener) => { + const subscription = provider.subscribe(serviceId, mode, (update) => + listener(update, serviceDeliveryContext()), + ); + return { + snapshot: subscription.snapshot, + activate: () => subscription.activate(), + close: () => subscription.close(), + }; + }, + }; +} diff --git a/packages/chord/test/services.test.ts b/packages/chord/test/services.test.ts index 72198e503..ffb587698 100644 --- a/packages/chord/test/services.test.ts +++ b/packages/chord/test/services.test.ts @@ -2,16 +2,15 @@ import { describe, expect, test, vi } from "vitest"; import { BACKGROUND_CONTEXT } from "../src/context/index.ts"; import { type Context, - createLoopbackServiceConnection, createRemoteServiceBinding, defineService, - getServiceInstanceKey, type JsonValue, type RemoteServiceConnection, RemoteServiceProvider, type ReplicatedState, replicatedState, } from "../src/index.ts"; +import { createLoopbackServiceConnection } from "./helpers.ts"; type ModelRef = { provider: string; modelId: string }; type ModelsState = { @@ -344,14 +343,12 @@ describe("remote services", () => { onError: (error) => errors.push(error), }); const observed: { - key: string; question: Question | undefined; service: QuestionDialogs; context: Context; }[] = []; const stop = namespace.observe(QuestionDialogs, (service, context) => { observed.push({ - key: getServiceInstanceKey(service)!, question: service.request.value, service, context, @@ -366,7 +363,7 @@ describe("remote services", () => { submit: firstSubmit, }); await vi.waitFor(() => expect(observed).toHaveLength(1)); - expect(observed[0]).toMatchObject({ key: "invocation-1", question: { question: "First?" } }); + expect(observed[0]).toMatchObject({ question: { question: "First?" } }); const firstService = observed[0]!.service; firstRequest.set({ question: "Updated?" }, BACKGROUND_CONTEXT); @@ -388,7 +385,7 @@ describe("remote services", () => { }, }); await vi.waitFor(() => expect(observed).toHaveLength(2)); - expect(observed[1]).toMatchObject({ key: "invocation-1", question: { question: "Again?" } }); + expect(observed[1]).toMatchObject({ question: { question: "Again?" } }); expect(observed[1]!.service).not.toBe(firstService); expect(errors).toEqual([]); diff --git a/packages/coding-agent/src/experimental/client-runtime.ts b/packages/coding-agent/src/experimental/client-runtime.ts index eb879ea88..57f26a791 100644 --- a/packages/coding-agent/src/experimental/client-runtime.ts +++ b/packages/coding-agent/src/experimental/client-runtime.ts @@ -173,7 +173,7 @@ export async function activateBuiltinClientServices( }; const models = sessionServices.use(Models); const agent = sessionServices.use(AgentController); - await Promise.all([serverServices.activate(BACKGROUND_CONTEXT), sessionServices.activate(BACKGROUND_CONTEXT)]); + await Promise.all([serverServices.ready(BACKGROUND_CONTEXT), sessionServices.ready(BACKGROUND_CONTEXT)]); return { ...server, directory, management, models, agent }; } diff --git a/packages/coding-agent/src/experimental/services/connection.ts b/packages/coding-agent/src/experimental/services/connection.ts index 725ad65af..9a3b8c2be 100644 --- a/packages/coding-agent/src/experimental/services/connection.ts +++ b/packages/coding-agent/src/experimental/services/connection.ts @@ -58,6 +58,7 @@ class RoutedServiceBinding implements RemoteServices { readonly #onActivate: (context: Context) => Promise; readonly #remove: () => void; #activated = false; + #activationComplete = false; constructor(options: { readonly services: readonly { readonly id: string }[]; @@ -88,20 +89,19 @@ class RoutedServiceBinding implements RemoteServices { return this.#services.observe(service, handler); } - setAccessGuard(assertAccess: () => void): void { - this.#services.setAccessGuard(assertAccess); - } - - async activate(context: Context): Promise { + async ready(context: Context): Promise { if (!this.#activated) { this.#activated = true; await this.#services.rebind(this.#getBound(), context); } - await this.#services.ready(context); - await this.#onActivate(context); + await this.serviceReady(context); + if (!this.#activationComplete) { + await this.#onActivate(context); + this.#activationComplete = true; + } } - ready(context: Context): Promise { + serviceReady(context: Context): Promise { return this.#services.ready(context); } @@ -323,7 +323,7 @@ class SessionServiceConnectionImpl implements SessionServiceConnection { async #whenAttached(attachment: SessionTarget, revision: number, context: Context): Promise { try { - await Promise.all([...this.#bindings].map((binding) => binding.ready(context))); + await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context))); } catch (error) { if (this.#attachmentRevision === revision && sameAttachment(this.#client.attachment, attachment)) { this.#attachmentState.set({ status: "degraded", sessionId: attachment.sessionId }, context); @@ -340,7 +340,7 @@ class SessionServiceConnectionImpl implements SessionServiceConnection { } async #whenDetached(revision: number, context: Context): Promise { - await Promise.all([...this.#bindings].map((binding) => binding.ready(context))); + await Promise.all([...this.#bindings].map((binding) => binding.serviceReady(context))); if (this.#attachmentRevision !== revision || this.#client.attachment !== undefined) { throw new Error("The Session attachment changed while detaching"); } @@ -361,7 +361,7 @@ export function createServerServiceBinding( assertAccess() {}, onError: options.onError ?? (() => {}), }); - const activation = services.activate(BACKGROUND_CONTEXT); + const activation = services.ready(BACKGROUND_CONTEXT); void activation.catch(options.onError ?? (() => {})); const ready = async (context: Context): Promise => { await activation; @@ -374,9 +374,7 @@ export function createServerServiceBinding( open: (openOptions) => connection.open(openOptions), use: (service) => services.use(service), observe: (service, handler) => services.observe(service, handler), - activate: ready, ready, - setAccessGuard: (assertAccess) => services.setAccessGuard(assertAccess), async dispose(context) { const results = await Promise.allSettled([services.dispose(context), connection.dispose(context)]); throwFailures(results, "Failed to dispose server service binding"); @@ -395,7 +393,7 @@ export function createSessionServiceBinding( assertAccess() {}, onError: options.onError ?? (() => {}), }); - const activation = services.activate(BACKGROUND_CONTEXT); + const activation = services.ready(BACKGROUND_CONTEXT); void activation.catch(options.onError ?? (() => {})); const ready = async (context: Context): Promise => { await activation; @@ -412,9 +410,7 @@ export function createSessionServiceBinding( open: (openOptions) => connection.open(openOptions), use: (service) => services.use(service), observe: (service, handler) => services.observe(service, handler), - activate: ready, ready, - setAccessGuard: (assertAccess) => services.setAccessGuard(assertAccess), whenAttached: (sessionId, context) => connection.whenAttached(sessionId, context), whenDetached: (context) => connection.whenDetached(context), async dispose(context) { diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 3ae39d97b..ba5bccea1 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -1,8 +1,8 @@ import { - createLoopbackServiceConnection, createRemoteServiceBinding, defineFacet, type FacetLoader, + type RemoteServiceConnection, RemoteServiceProvider, replicatedState, } from "@earendil-works/chord"; @@ -34,6 +34,20 @@ function session(sessionId: string, createdAt: number): SessionSummary { return { serverId, sessionId, createdAt }; } +function createLoopbackServiceConnection(provider: RemoteServiceProvider): RemoteServiceConnection { + return { + invoke: (call, context) => provider.invoke(call, context), + subscribe: async (serviceId, mode, listener) => { + const subscription = provider.subscribe(serviceId, mode, (update) => listener(update, BACKGROUND_CONTEXT)); + return { + snapshot: subscription.snapshot, + activate: () => subscription.activate(), + close: () => subscription.close(), + }; + }, + }; +} + function laneSnapshot(): LaneSnapshot { return { lane: "main", @@ -196,6 +210,7 @@ describe("experimental client TUI", () => { connection: createLoopbackServiceConnection(serverProvider), bound: false, }); + const serverNamespaceReady = serverNamespace.ready.bind(serverNamespace); const serverServices: ServerServiceConnection = Object.assign(serverNamespace, { acceptsUnavailableServices: false, connection: connectionState, @@ -205,9 +220,9 @@ describe("experimental client TUI", () => { open() { return serverNamespace; }, - async activate() { + async ready() { await serverNamespace.rebind(true, BACKGROUND_CONTEXT); - await serverNamespace.ready(BACKGROUND_CONTEXT); + await serverNamespaceReady(BACKGROUND_CONTEXT); }, }); const sessionNamespace = createRemoteServiceBinding({ @@ -224,9 +239,6 @@ describe("experimental client TUI", () => { open() { return sessionNamespace; }, - async activate() { - await sessionNamespace.ready(BACKGROUND_CONTEXT); - }, async whenAttached(sessionId: string) { await sessionNamespace.rebind(true, BACKGROUND_CONTEXT); await sessionNamespace.ready(BACKGROUND_CONTEXT);