diff --git a/packages/agent/README.md b/packages/agent/README.md index 6191bd23c..c7be41669 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -44,7 +44,7 @@ await agent.prompt("Hello!"); ## Experimental plugin services -The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineRemoteService()`, `remoteState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods and state members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `RemoteState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. Wire framing and host routing remain outside this package. +The package exports the transport-neutral plugin-service primitives used by the experimental application hosts: `defineRemoteService()`, `remoteState()`, `RemoteServiceProvider`, and `RemoteServiceNamespace`. Providers classify implementation methods, state members, and scaffolded event members at runtime; consumers receive stable singleton facades through `use()` or lifecycle-scoped keyed instances through `observe()`. `RemoteState` values are borrowed immutable JSON: the runtime does not defensively clone them, so callers must not mutate or retain them. `RemoteEvents` is currently a contract surface only: listener registration and emission throw `ServiceSliceNotImplemented` until its transport slice lands. Wire framing and host routing remain outside this package. ## Core Concepts diff --git a/packages/agent/docs/plugins.md b/packages/agent/docs/plugins.md index 853b6d63a..82712f4af 100644 --- a/packages/agent/docs/plugins.md +++ b/packages/agent/docs/plugins.md @@ -1,6 +1,6 @@ # Coding-Agent Application Hosts and Plugin Facets -> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `RemoteState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. The application host contexts, plugin kernel, events, references, telemetry propagation, and most other example facets remain illustrative. Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package. +> **Status:** Tentative design input, not a normative contract or implementation handoff. The transport-neutral service token, provider, singleton `use()`, keyed `observe()`, and `RemoteState` substrate now have an experimental implementation, including `Models`, `Chat`, `SessionDirectory`, and `SessionManagement` vertical slices. `RemoteEvents` contract/member metadata and the remaining documented built-in service contracts are scaffolded with explicit `ServiceSliceNotImplemented` behavior; event delivery is not implemented. The application host contexts, plugin kernel, references, telemetry propagation, and most other example facets remain illustrative. Reconcile this design with `rpc.md`, `telemetry.md`, and the final harness contract before adding it to `harness.md` or creating a work package. This document assumes you already understand `AgentHarness`, `AgentLane`, `Session`, `SessionTree`, `SessionRepo`, invocation `Context`, and telemetry. Read `rpc.md` for wire frames, remote references, subscriptions, and trace carriers, and `telemetry.md` for context propagation and cancellation semantics. @@ -428,7 +428,7 @@ The two namespaces are the topology made visible: `server.use(SessionDirectory)` A future `CodingAgentWebPluginContext` carries the same two namespaces plus browser registries — routes, views, DOM dialogs. The server context is defined in the [server section](#the-server-directory-management-and-routing). Contexts resemble each other by convention; no kernel-owned base type forces the shape. -Concretely, a chat plugin exposes a narrow `Chat` contract — `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)` — and implements it with direct local lane capabilities: +Concretely, the minimum chat slice exposes `prompt(request, context)` returning `{ accepted, operationId, error }`, plus `requestAbort(operationId, context)`, and implements it with direct local lane capabilities. The experimental `Chat` contract also predeclares the later steer, follow-up, next-run, queue cancellation, resume, compaction, and navigation presentation operations; those members throw `ServiceSliceNotImplemented` until their Harness adapter slices land: ```ts session(pluginContext: CodingAgentSessionPluginContext) { @@ -960,7 +960,7 @@ Disconnect behavior, from the plugin author's perspective: - **A process loses its server connection.** A presentation loses both namespaces. A session worker loses server services and all attached presentations at once; unattended-session policy decides whether it exits. - Reconnect and reattach always hydrate from a fresh authoritative snapshot; prior remote references are invalid unless the exposure has explicitly session-stable identity. **Never blindly replay a mutation after an uncertain disconnect** — a replayed `select()` is harmless, a replayed `prompt()` is not. Reconnect, hydrate, and reconcile, or design the operation around a stable operation ID with explicit lookup semantics. -Errors cross the wire as a JSON envelope `{ code, message, data? }` with stable codes. Expected service errors use stable codes or result values; unexpected exceptions become an internal error with safe metadata — no stacks or sensitive causes by default. Cancellation, disconnect, unknown service/method, invalid arguments/results, stale references, `not_attached`, `not_authorized`, `unknown_session`, and `session_unavailable` need distinct codes. +Errors cross the wire as a JSON envelope `{ code, message, data? }` with stable codes. Expected service errors use stable codes or result values; unexpected exceptions become an internal error with safe metadata — no stacks or sensitive causes by default. Experimental scaffold members use `service_not_implemented` until their implementation slice lands. Cancellation, disconnect, unknown service/method, invalid arguments/results, stale references, `not_attached`, `not_authorized`, `unknown_session`, and `session_unavailable` need distinct codes. Security rules every providing host (session and server alike) must enforce: diff --git a/packages/agent/src/plugins/services/events.ts b/packages/agent/src/plugins/services/events.ts new file mode 100644 index 000000000..7ec583f65 --- /dev/null +++ b/packages/agent/src/plugins/services/events.ts @@ -0,0 +1,52 @@ +import type { Context } from "../../harness/context.ts"; +import { + type MutableRemoteEvents, + type RemoteEventListener, + type RemoteEvents, + type RemoteEventType, + ServiceSliceNotImplemented, +} from "./types.ts"; + +export const REMOTE_EVENTS_INTERNALS = Symbol("pi.remoteEvents.internals"); + +export interface RemoteEventsInternals { + readonly deliveryImplemented: false; +} + +export interface RemoteEventsSource extends MutableRemoteEvents { + readonly [REMOTE_EVENTS_INTERNALS]: RemoteEventsInternals; +} + +class UnimplementedRemoteEvents implements RemoteEventsSource { + readonly [REMOTE_EVENTS_INTERNALS] = { deliveryImplemented: false } as const; + + subscribe(_listener: RemoteEventListener): () => void { + throw new ServiceSliceNotImplemented("RemoteEvents.subscribe"); + } + + on>( + _type: TType, + _listener: RemoteEventListener>, + ): () => void { + throw new ServiceSliceNotImplemented("RemoteEvents.on"); + } + + emit(_event: T, _context: Context): void { + throw new ServiceSliceNotImplemented("RemoteEvents.emit"); + } +} + +/** Declare an event member now while making its unimplemented delivery explicit. */ +export function remoteEvents(): MutableRemoteEvents { + return new UnimplementedRemoteEvents(); +} + +export function getRemoteEventsInternals(value: unknown): RemoteEventsInternals | undefined { + if (typeof value !== "object" || value === null || !(REMOTE_EVENTS_INTERNALS in value)) return undefined; + const candidate = value as { readonly [REMOTE_EVENTS_INTERNALS]?: unknown }; + const internals = candidate[REMOTE_EVENTS_INTERNALS]; + if (typeof internals !== "object" || internals === null) return undefined; + return internals as RemoteEventsInternals; +} + +export type { RemoteEvents }; diff --git a/packages/agent/src/plugins/services/index.ts b/packages/agent/src/plugins/services/index.ts index 7ded67e7c..8186b2be2 100644 --- a/packages/agent/src/plugins/services/index.ts +++ b/packages/agent/src/plugins/services/index.ts @@ -1,3 +1,10 @@ +export { + getRemoteEventsInternals, + REMOTE_EVENTS_INTERNALS, + type RemoteEventsInternals, + type RemoteEventsSource, + remoteEvents, +} from "./events.ts"; export { RemoteServiceNamespace } from "./namespace.ts"; export { createLoopbackServiceConnection, @@ -17,7 +24,12 @@ export { cloneJson, defineRemoteService, isJsonValue, + type MutableRemoteEvents, type MutableRemoteState, + type RemoteEventListener, + type RemoteEvents, + type RemoteEventType, + type RemoteJson, type RemoteService, type RemoteServiceConnection, type RemoteServiceInstance, @@ -34,6 +46,7 @@ export { type ServiceMode, type ServiceProviderSubscription, type ServiceProviderUpdate, + ServiceSliceNotImplemented, type ServiceStateSnapshot, type ServiceSubscriptionSnapshot, } from "./types.ts"; diff --git a/packages/agent/src/plugins/services/namespace.ts b/packages/agent/src/plugins/services/namespace.ts index 6fb784769..a8fe5d93e 100644 --- a/packages/agent/src/plugins/services/namespace.ts +++ b/packages/agent/src/plugins/services/namespace.ts @@ -17,6 +17,7 @@ import { type ServiceMemberDescription, type ServiceMemberKind, type ServiceProviderUpdate, + ServiceSliceNotImplemented, type ServiceStateSnapshot, } from "./types.ts"; @@ -107,9 +108,21 @@ class MemberSlot { return this.#state.value; } if (property === "subscribe") { + if (this.#kind === "events") { + this.#expect("events"); + return () => { + throw new ServiceSliceNotImplemented("RemoteEvents.subscribe"); + }; + } this.#expect("state"); return this.#state.subscribe.bind(this.#state); } + if (property === "on") { + this.#expect("events"); + return () => { + throw new ServiceSliceNotImplemented("RemoteEvents.on"); + }; + } if (property === Symbol.toStringTag) return "RemoteServiceMember"; if (property === "then") return undefined; return undefined; diff --git a/packages/agent/src/plugins/services/provider.ts b/packages/agent/src/plugins/services/provider.ts index f4931421b..1cd555124 100644 --- a/packages/agent/src/plugins/services/provider.ts +++ b/packages/agent/src/plugins/services/provider.ts @@ -1,5 +1,6 @@ import type { Context } from "../../harness/context.ts"; import type { JsonValue } from "../../harness/session/types.ts"; +import { getRemoteEventsInternals } from "./events.ts"; import { freshDeliveryContext, getRemoteStateInternals, type RemoteStateInternals } from "./state.ts"; import { cloneJson, @@ -24,7 +25,8 @@ export type RemoteServiceErrorCode = | "service_member_mismatch" | "service_instance_not_found" | "service_stale_instance" - | "service_invalid_value"; + | "service_invalid_value" + | "service_not_implemented"; export class RemoteServiceError extends Error { readonly code: RemoteServiceErrorCode; @@ -40,7 +42,8 @@ type RemoteMethod = (...args: unknown[]) => unknown; type InstanceMember = | { readonly kind: "method"; readonly method: RemoteMethod } - | { readonly kind: "state"; readonly state: RemoteStateInternals }; + | { readonly kind: "state"; readonly state: RemoteStateInternals } + | { readonly kind: "events" }; interface ProviderInstance { readonly address?: ServiceInstanceAddress; @@ -261,29 +264,34 @@ export class RemoteServiceProvider { continue; } const state = getRemoteStateInternals(descriptor.value); - if (state === undefined) { - throw new TypeError(`Remote service member ${registration.serviceId}.${name} is not remotely exposable`); + if (state !== undefined) { + members.set(name, { kind: "state", state }); + removeStateListeners.push( + state.subscribe((value, sequence, context) => { + if (!instance.active) return; + if (!isJsonValue(value)) { + throw new RemoteServiceError("service_invalid_value", "Remote state update must be strict JSON"); + } + this.#emit( + registration, + { + type: "state", + ...(address === undefined ? {} : { instance: address }), + member: name, + sequence, + value, + }, + context, + ); + }), + ); + continue; } - members.set(name, { kind: "state", state }); - removeStateListeners.push( - state.subscribe((value, sequence, context) => { - if (!instance.active) return; - if (!isJsonValue(value)) { - throw new RemoteServiceError("service_invalid_value", "Remote state update must be strict JSON"); - } - this.#emit( - registration, - { - type: "state", - ...(address === undefined ? {} : { instance: address }), - member: name, - sequence, - value, - }, - context, - ); - }), - ); + if (getRemoteEventsInternals(descriptor.value) !== undefined) { + members.set(name, { kind: "events" }); + continue; + } + throw new TypeError(`Remote service member ${registration.serviceId}.${name} is not remotely exposable`); } if (members.size === 0) throw new TypeError(`Remote service ${registration.serviceId} has no members`); return instance; diff --git a/packages/agent/src/plugins/services/types.ts b/packages/agent/src/plugins/services/types.ts index b8c530151..b66e3066e 100644 --- a/packages/agent/src/plugins/services/types.ts +++ b/packages/agent/src/plugins/services/types.ts @@ -4,7 +4,16 @@ import type { JsonValue } from "../../harness/session/types.ts"; const REMOTE_SERVICE_TYPE = Symbol("pi.remoteService.type"); export type ServiceMode = "singleton" | "keyed"; -export type ServiceMemberKind = "method" | "state"; +export type ServiceMemberKind = "method" | "state" | "events"; + +export class ServiceSliceNotImplemented extends Error { + readonly code = "service_not_implemented" as const; + + constructor(operation: string) { + super(`${operation} is not implemented until its later plugin-service slice`); + this.name = "ServiceSliceNotImplemented"; + } +} export interface RemoteState { /** Borrowed immutable value, or undefined until hydration. Do not mutate or retain it. */ @@ -19,30 +28,56 @@ export interface MutableRemoteState extends RemoteState { set(value: T, context: Context): void; } +export type RemoteEventType = T extends { readonly type: infer TType extends string } ? TType : never; +export type RemoteEventListener = (event: T, context: Context) => void | Promise; + +/** Contract surface for non-durable semantic events. Remote delivery is a later service slice. */ +export interface RemoteEvents { + subscribe(listener: RemoteEventListener): () => void; + on>( + type: TType, + listener: RemoteEventListener>, + ): () => void; +} + +export interface MutableRemoteEvents extends RemoteEvents { + emit(event: T, context: Context): void; +} + +/** Marks an existing schema-validated wire DTO as JSON-safe for static service-contract checking. */ +declare const REMOTE_JSON_TYPE: unique symbol; +export type RemoteJson = T & { readonly [REMOTE_JSON_TYPE]?: true }; + type JsonPrimitive = null | boolean | number | string; -type InvalidJsonPart = T extends JsonPrimitive +type InvalidJsonPart = typeof REMOTE_JSON_TYPE extends keyof T ? never - : T extends readonly (infer TItem)[] - ? InvalidJsonPart - : T extends (...args: never[]) => unknown - ? T - : T extends object - ? { [TKey in keyof T]-?: InvalidJsonPart }[keyof T] - : T; + : T extends JsonPrimitive + ? never + : T extends readonly (infer TItem)[] + ? InvalidJsonPart + : T extends (...args: never[]) => unknown + ? T + : T extends object + ? { [TKey in keyof T]-?: InvalidJsonPart }[keyof T] + : T; type InvalidRemoteMember = T extends RemoteState ? InvalidJsonPart extends never ? never : "state value is not JSON" - : T extends (...args: [...infer TArgs, Context]) => Promise - ? InvalidJsonPart extends never - ? TResult extends void - ? never - : InvalidJsonPart extends never + : T extends RemoteEvents + ? InvalidJsonPart extends never + ? never + : "event value is not JSON" + : T extends (...args: [...infer TArgs, Context]) => Promise + ? InvalidJsonPart extends never + ? TResult extends void ? never - : "method result is not JSON or void" - : "method argument is not JSON" - : "member is not a remote method or RemoteState"; + : InvalidJsonPart extends never + ? never + : "method result is not JSON or void" + : "method argument is not JSON" + : "member is not a remote method, RemoteState, or RemoteEvents"; type InvalidRemoteMemberNames = { [TKey in keyof T]-?: InvalidRemoteMember extends never ? never : TKey; diff --git a/packages/agent/test/plugins/services.test.ts b/packages/agent/test/plugins/services.test.ts index 9351fb38d..471c509fa 100644 --- a/packages/agent/test/plugins/services.test.ts +++ b/packages/agent/test/plugins/services.test.ts @@ -4,11 +4,14 @@ import { BACKGROUND_CONTEXT, createLoopbackServiceConnection, defineRemoteService, + type RemoteEvents, type RemoteServiceConnection, RemoteServiceNamespace, RemoteServiceProvider, type RemoteState, + remoteEvents, remoteState, + ServiceSliceNotImplemented, } from "../../src/index.ts"; type ModelRef = { provider: string; modelId: string }; @@ -31,6 +34,12 @@ interface QuestionDialogService { const QuestionDialogs = defineRemoteService("question-dialog"); +interface ActivityService { + readonly events: RemoteEvents<{ type: "changed"; revision: number }>; +} + +const Activity = defineRemoteService("activity"); + describe("plugin remote services", () => { test("does not defensively clone borrowed state values", () => { const initial: ModelsState = { selected: null, revision: 0 }; @@ -194,6 +203,31 @@ describe("plugin remote services", () => { provider.dispose(); }); + test("declares RemoteEvents members while delivery remains an explicit later slice", async () => { + const provider = new RemoteServiceProvider([Activity]); + const events = remoteEvents<{ type: "changed"; revision: number }>(); + provider.provide(Activity, { events }); + const subscription = provider.subscribe("activity", "singleton", () => {}); + expect(subscription.snapshot.instances[0]?.members).toEqual([{ name: "events", kind: "events" }]); + expect(() => events.subscribe(() => {})).toThrow(ServiceSliceNotImplemented); + expect(() => events.on("changed", () => {})).toThrow(ServiceSliceNotImplemented); + expect(() => events.emit({ type: "changed", revision: 1 }, BACKGROUND_CONTEXT)).toThrow( + ServiceSliceNotImplemented, + ); + subscription.close(); + + const namespace = new RemoteServiceNamespace({ + services: [Activity], + connection: createLoopbackServiceConnection(provider), + }); + const activity = namespace.use(Activity); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(() => activity.events.subscribe(() => {})).toThrow(ServiceSliceNotImplemented); + expect(() => activity.events.on("changed", () => {})).toThrow(ServiceSliceNotImplemented); + await namespace.dispose(BACKGROUND_CONTEXT); + provider.dispose(); + }); + test("rejects mode mixing, unsupported members, and non-JSON values", async () => { const provider = new RemoteServiceProvider([Models, QuestionDialogs]); provider.provide(Models, { diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index d40bf9070..cdf2b2449 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -1,12 +1,12 @@ import { basename } from "node:path"; -import { BACKGROUND_CONTEXT, type RemoteServiceNamespace, type RemoteState } from "@earendil-works/pi-agent-core"; +import { BACKGROUND_CONTEXT, type RemoteServiceNamespaceApi, type RemoteState } from "@earendil-works/pi-agent-core"; import { Client } from "@earendil-works/pi-client"; import { createUnixTransportFactory, discoverUnixServers, type UnixServerRoute } from "@earendil-works/pi-client/unix"; import { isServerId, type LaneEvent, type PromptMessage, type SessionAddress } from "@earendil-works/pi-protocol"; import type { ClientCommand } from "../cli/experimental/commands/client.ts"; import { activateServer, ENV_SERVER_ID, resolveServerDirectory, resolveSessionDirectory } from "./server.ts"; import { Chat, type ChatPromptResponse } from "./services/chat.ts"; -import { createServerServiceNamespace, createSessionServiceNamespace } from "./services/connection.ts"; +import { createBuiltinServerServiceNamespace, createBuiltinSessionServiceNamespace } from "./services/connection.ts"; import { SessionDirectory, SessionManagement, type SessionManagementService } from "./services/sessions.ts"; export type ClientResult = @@ -57,7 +57,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption } } const openedClients = new Set(); - const serviceNamespaces = new Set(); + const serviceNamespaces = new Set(); if (activatedClient) openedClients.add(activatedClient); const discovered: { route: UnixServerRoute; @@ -76,9 +76,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption })); activatedClient = undefined; openedClients.add(client); - const services = createServerServiceNamespace(client, { - services: [SessionDirectory, SessionManagement], - }); + const services = createBuiltinServerServiceNamespace(client); serviceNamespaces.add(services); const directory = services.use(SessionDirectory); const management = services.use(SessionManagement); @@ -130,7 +128,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption return { kind: "attached", serverId: match.route.serverId, sessionId }; } - const sessionServices = createSessionServiceNamespace(match.client, { services: [Chat] }); + const sessionServices = createBuiltinSessionServiceNamespace(match.client); serviceNamespaces.add(sessionServices); const chat = sessionServices.use(Chat); const completedText = new Map(); diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md new file mode 100644 index 000000000..b46e10138 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/README.md @@ -0,0 +1,16 @@ +# Experimental client/server service slices + +`server-builtins.ts` and `session-builtins.ts` are the separate provider allowlists; `builtins.ts` combines them only for presentation hosts and tests, so the server router does not load Session contracts. A token in that inventory is either implemented end to end or registered with an explicit `ServiceSliceNotImplemented` provider. Framed callers receive the stable `service_not_implemented` code. Keyed services hydrate as an empty directory until their owning feature spawns an instance. + +| Scope | Service | Current slice | Continuation point | +|---|---|---|---| +| server | `SessionDirectory` | replicated state implemented; `events` declared | implement `RemoteEvents` delivery, then publish semantic directory changes | +| server | `SessionManagement` | create, remove, attach, detach implemented | add authenticated workspace authorization | +| session | `Models` | state, selection, thinking, refresh implemented | move provider/auth composition behind plugin facets | +| session | `Chat` | prompt and durable `requestAbort` implemented; queue, resume, compaction, and navigation contracts declared | replace each explicit throwing provider member with its Harness adapter slice | +| session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade | +| session | `Transcript` | revisioned snapshot/events contract declared; both operations throw | implement snapshot/event gap handling and remove the compatibility lane watch | + +`RemoteEvents` currently has contract types and provider member classification only. `remoteEvents()`, local listeners, remote listeners, and emission deliberately throw `ServiceSliceNotImplemented`; there are no event delivery frames yet. `ServerServices.connection` and `SessionServices.attachment` are implemented local control states. + +The question dialog, diff review, Git, indexing-job, and canvas examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, flow control, the plugin kernel, and host facet contexts remain protocol/host infrastructure slices rather than presentation service tokens. diff --git a/packages/coding-agent/src/experimental/services/accounts.ts b/packages/coding-agent/src/experimental/services/accounts.ts new file mode 100644 index 000000000..2ac30f605 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/accounts.ts @@ -0,0 +1,17 @@ +import { type Context, defineRemoteService, type RemoteState } from "@earendil-works/pi-agent-core"; + +export interface AccountSummary { + provider: string; + configured: boolean; +} + +export interface AccountsState { + providers: AccountSummary[]; +} + +export interface AccountsService { + readonly state: RemoteState; + remove(provider: string, context: Context): Promise; +} + +export const Accounts = defineRemoteService("accounts"); diff --git a/packages/coding-agent/src/experimental/services/builtins.ts b/packages/coding-agent/src/experimental/services/builtins.ts new file mode 100644 index 000000000..892ff5a2f --- /dev/null +++ b/packages/coding-agent/src/experimental/services/builtins.ts @@ -0,0 +1,2 @@ +export { BUILTIN_SERVER_SERVICES } from "./server-builtins.ts"; +export { BUILTIN_SESSION_SERVICES } from "./session-builtins.ts"; diff --git a/packages/coding-agent/src/experimental/services/chat-provider.ts b/packages/coding-agent/src/experimental/services/chat-provider.ts index 9cc886d9d..289bf8147 100644 --- a/packages/coding-agent/src/experimental/services/chat-provider.ts +++ b/packages/coding-agent/src/experimental/services/chat-provider.ts @@ -1,4 +1,9 @@ -import type { AgentHarness, RunResult as HarnessRunResult, RemoteServiceProvider } from "@earendil-works/pi-agent-core"; +import { + type AgentHarness, + type RunResult as HarnessRunResult, + type RemoteServiceProvider, + ServiceSliceNotImplemented, +} from "@earendil-works/pi-agent-core"; import type { PromptArguments } from "@earendil-works/pi-protocol"; import { toHarnessPromptArguments } from "../harness-wire-adapter.ts"; import { Chat, type ChatPromptRequest, type ChatPromptResponse } from "./chat.ts"; @@ -17,6 +22,27 @@ export function provideChatService(provider: RemoteServiceProvider, harness: Age const result = await harness.requestAbort(operationId, context); if (!result.ok) throw new Error(result.error.message); }, + async steer() { + throw new ServiceSliceNotImplemented("Chat.steer"); + }, + async followUp() { + throw new ServiceSliceNotImplemented("Chat.followUp"); + }, + async nextRun() { + throw new ServiceSliceNotImplemented("Chat.nextRun"); + }, + async cancelQueued() { + throw new ServiceSliceNotImplemented("Chat.cancelQueued"); + }, + async resume() { + throw new ServiceSliceNotImplemented("Chat.resume"); + }, + async compact() { + throw new ServiceSliceNotImplemented("Chat.compact"); + }, + async navigate() { + throw new ServiceSliceNotImplemented("Chat.navigate"); + }, }); } diff --git a/packages/coding-agent/src/experimental/services/chat.ts b/packages/coding-agent/src/experimental/services/chat.ts index 23254cf54..ef167e0ff 100644 --- a/packages/coding-agent/src/experimental/services/chat.ts +++ b/packages/coding-agent/src/experimental/services/chat.ts @@ -15,9 +15,34 @@ export type ChatPromptResponse = | { accepted: true; operationId: string; error: ChatPromptError | null } | { accepted: false; operationId: string | null; error: ChatPromptError }; +export type ChatQueueResponse = + | { accepted: true; entryId: string; error: null } + | { accepted: false; entryId: null; error: ChatPromptError }; + +export interface ChatCompactionRequest { + customInstructions: string | null; +} + +export interface ChatNavigationRequest { + targetId: string | null; + summarize: boolean; + label: string | null; + customInstructions: string | null; +} + export interface ChatService { prompt(request: ChatPromptRequest, context: Context): Promise; requestAbort(operationId: string, context: Context): Promise; + steer(request: ChatPromptRequest, context: Context): Promise; + followUp(request: ChatPromptRequest, context: Context): Promise; + nextRun(request: ChatPromptRequest, context: Context): Promise; + cancelQueued( + entryId: string, + context: Context, + ): Promise<{ outcome: "cancelled" | "already_consumed" | "not_found" }>; + resume(context: Context): Promise; + compact(request: ChatCompactionRequest, context: Context): Promise; + navigate(request: ChatNavigationRequest, context: Context): Promise; } export const Chat = defineRemoteService("chat"); diff --git a/packages/coding-agent/src/experimental/services/connection.ts b/packages/coding-agent/src/experimental/services/connection.ts index b7e55dfd3..7df9c362c 100644 --- a/packages/coding-agent/src/experimental/services/connection.ts +++ b/packages/coding-agent/src/experimental/services/connection.ts @@ -6,19 +6,42 @@ import { isJsonValue, type RemoteServiceConnection, RemoteServiceNamespace, + type RemoteServiceNamespaceApi, + type RemoteState, + remoteState, } from "@earendil-works/pi-agent-core"; import type { Client } from "@earendil-works/pi-client"; import type { ProtocolRpcCall, RpcTarget, ServiceProviderUpdate } from "@earendil-works/pi-protocol"; +import { BUILTIN_SERVER_SERVICES, BUILTIN_SESSION_SERVICES } from "./builtins.ts"; + +export type ServerConnectionState = + | { status: "connecting"; attempt: number } + | { status: "connected"; since: string } + | { status: "disconnected"; since: string; reason: string; retryAt: string | null }; + +export type SessionAttachmentState = + | { status: "detached" } + | { status: "attaching" | "attached" | "degraded"; sessionId: string }; + +export interface ServerServices extends RemoteServiceNamespaceApi { + readonly connection: RemoteState; +} + +export interface SessionServices extends RemoteServiceNamespaceApi { + readonly attachment: RemoteState; +} export interface ServiceNamespaceOptions { readonly services: readonly { readonly id: string }[]; readonly onError?: (error: Error) => void; } -class ServerServiceNamespace extends RemoteServiceNamespace { +class ServerServiceNamespace extends RemoteServiceNamespace implements ServerServices { + readonly connection: RemoteState; readonly #removeConnectionListener: () => void; readonly #onError: ((error: Error) => void) | undefined; #transition = Promise.resolve(); + #connectionAttempt: number; constructor(client: Client, connection: RemoteServiceConnection, options: ServiceNamespaceOptions) { super({ @@ -28,10 +51,17 @@ class ServerServiceNamespace extends RemoteServiceNamespace { ...(options.onError === undefined ? {} : { onError: options.onError }), }); this.#onError = options.onError; - this.#removeConnectionListener = client.onConnectionStateChange(({ state }) => { + this.#connectionAttempt = client.connectionState === "connecting" ? 1 : 0; + const connectionState = remoteState( + toServerConnectionState(client, this.#connectionAttempt), + ); + this.connection = connectionState; + this.#removeConnectionListener = client.onConnectionStateChange(({ state, error }) => { + if (state === "connecting") this.#connectionAttempt += 1; + connectionState.set(toServerConnectionState(client, this.#connectionAttempt, error), BACKGROUND_CONTEXT); this.#transition = this.#transition .then(() => this.rebind(state === "connected", BACKGROUND_CONTEXT)) - .catch((error: unknown) => this.#onError?.(toError(error))); + .catch((transitionError: unknown) => this.#onError?.(toError(transitionError))); }); } @@ -42,7 +72,8 @@ class ServerServiceNamespace extends RemoteServiceNamespace { } } -class SessionServiceNamespace extends RemoteServiceNamespace { +class SessionServiceNamespace extends RemoteServiceNamespace implements SessionServices { + readonly attachment: RemoteState; readonly #removeAttachmentListener: () => void; readonly #onError: ((error: Error) => void) | undefined; #transition = Promise.resolve(); @@ -55,7 +86,13 @@ class SessionServiceNamespace extends RemoteServiceNamespace { ...(options.onError === undefined ? {} : { onError: options.onError }), }); this.#onError = options.onError; + const attachmentState = remoteState(toSessionAttachmentState(client)); + this.attachment = attachmentState; this.#removeAttachmentListener = client.onAttachmentChange((attachment) => { + attachmentState.set( + attachment === undefined ? { status: "detached" } : { status: "attached", sessionId: attachment.sessionId }, + BACKGROUND_CONTEXT, + ); this.#transition = this.#transition .then(() => this.rebind(attachment !== undefined, BACKGROUND_CONTEXT)) .catch((error: unknown) => this.#onError?.(toError(error))); @@ -70,20 +107,33 @@ class SessionServiceNamespace extends RemoteServiceNamespace { } /** Bind plugin service facades to the connected server route. */ -export function createServerServiceNamespace(client: Client, options: ServiceNamespaceOptions): RemoteServiceNamespace { +export function createServerServiceNamespace(client: Client, options: ServiceNamespaceOptions): ServerServices { const connection = createServiceConnection(client, () => ({ serverId: client.serverId })); return new ServerServiceNamespace(client, connection, options); } -/** Bind plugin service facades to the client's selected Session route. */ -export function createSessionServiceNamespace( +/** Bind every built-in server service expected by the presentation host. */ +export function createBuiltinServerServiceNamespace( client: Client, - options: ServiceNamespaceOptions, -): RemoteServiceNamespace { + options: Omit = {}, +): ServerServices { + return createServerServiceNamespace(client, { ...options, services: BUILTIN_SERVER_SERVICES }); +} + +/** Bind plugin service facades to the client's selected Session route. */ +export function createSessionServiceNamespace(client: Client, options: ServiceNamespaceOptions): SessionServices { const connection = createServiceConnection(client, () => client.attachment); return new SessionServiceNamespace(client, connection, options); } +/** Bind every built-in Session service expected by the presentation host. */ +export function createBuiltinSessionServiceNamespace( + client: Client, + options: Omit = {}, +): SessionServices { + return createSessionServiceNamespace(client, { ...options, services: BUILTIN_SESSION_SERVICES }); +} + function createServiceConnection(client: Client, getTarget: () => RpcTarget | undefined): RemoteServiceConnection { return { invoke: async (call, context) => { @@ -119,6 +169,28 @@ function createServiceConnection(client: Client, getTarget: () => RpcTarget | un }; } +function toServerConnectionState(client: Client, attempt: number, error?: Error): ServerConnectionState { + const since = new Date().toISOString(); + switch (client.connectionState) { + case "connecting": + return { status: "connecting", attempt }; + case "connected": + return { status: "connected", since }; + case "disconnected": + return { + status: "disconnected", + since, + reason: error?.message ?? "Client is disconnected", + retryAt: null, + }; + } +} + +function toSessionAttachmentState(client: Client): SessionAttachmentState { + const attachment = client.attachment; + return attachment === undefined ? { status: "detached" } : { status: "attached", sessionId: attachment.sessionId }; +} + function toError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } diff --git a/packages/coding-agent/src/experimental/services/server-builtins.ts b/packages/coding-agent/src/experimental/services/server-builtins.ts new file mode 100644 index 000000000..5850c1409 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/server-builtins.ts @@ -0,0 +1,4 @@ +import { SessionDirectory, SessionManagement } from "./sessions.ts"; + +/** Complete documented server-service allowlist for the experimental presentation host. */ +export const BUILTIN_SERVER_SERVICES = [SessionDirectory, SessionManagement] as const; diff --git a/packages/coding-agent/src/experimental/services/server.ts b/packages/coding-agent/src/experimental/services/server.ts index c3904adf3..0ac4e78ff 100644 --- a/packages/coding-agent/src/experimental/services/server.ts +++ b/packages/coding-agent/src/experimental/services/server.ts @@ -3,6 +3,7 @@ import { type Context, type ServiceProviderUpdate as CoreServiceProviderUpdate, RemoteServiceProvider, + remoteEvents, remoteState, type ServiceProviderSubscription, } from "@earendil-works/pi-agent-core"; @@ -17,7 +18,13 @@ import { } from "@earendil-works/pi-protocol"; import type { RoutedServerServiceAttachment, RoutedServerServiceHost } from "@earendil-works/pi-server"; import { Check } from "typebox/value"; -import { SessionDirectory, type SessionDirectoryState, SessionManagement } from "./sessions.ts"; +import { BUILTIN_SERVER_SERVICES } from "./server-builtins.ts"; +import { + SessionDirectory, + type SessionDirectoryEvent, + type SessionDirectoryState, + SessionManagement, +} from "./sessions.ts"; export interface ExperimentalServerServices { readonly host: RoutedServerServiceHost; @@ -35,6 +42,7 @@ export async function createExperimentalServerServices(options: { revision, sessions: await options.list(BACKGROUND_CONTEXT), }); + const directoryEvents = remoteEvents(); const attachments = new Set(); let mutationTail = Promise.resolve(); @@ -54,8 +62,8 @@ export async function createExperimentalServerServices(options: { return { host: { attachClient(presentation) { - const provider = new RemoteServiceProvider([SessionDirectory, SessionManagement]); - provider.provide(SessionDirectory, { state: directory }); + const provider = new RemoteServiceProvider(BUILTIN_SERVER_SERVICES); + provider.provide(SessionDirectory, { state: directory, events: directoryEvents }); provider.provide(SessionManagement, { create: (createOptions, context) => serialize(async () => { diff --git a/packages/coding-agent/src/experimental/services/session-builtins.ts b/packages/coding-agent/src/experimental/services/session-builtins.ts new file mode 100644 index 000000000..4728d2d9a --- /dev/null +++ b/packages/coding-agent/src/experimental/services/session-builtins.ts @@ -0,0 +1,10 @@ +import { Accounts } from "./accounts.ts"; +import { Chat } from "./chat.ts"; +import { Models } from "./models.ts"; +import { Transcript } from "./transcript.ts"; + +/** + * Complete built-in session-service allowlist for the experimental presentation host. + * Some providers intentionally throw ServiceSliceNotImplemented until their implementation slice lands. + */ +export const BUILTIN_SESSION_SERVICES = [Accounts, Chat, Models, Transcript] as const; diff --git a/packages/coding-agent/src/experimental/services/sessions.ts b/packages/coding-agent/src/experimental/services/sessions.ts index 13191b188..76419e858 100644 --- a/packages/coding-agent/src/experimental/services/sessions.ts +++ b/packages/coding-agent/src/experimental/services/sessions.ts @@ -1,4 +1,4 @@ -import { type Context, defineRemoteService, type RemoteState } from "@earendil-works/pi-agent-core"; +import { type Context, defineRemoteService, type RemoteEvents, type RemoteState } from "@earendil-works/pi-agent-core"; import type { SessionCreateOptions, SessionSummary } from "@earendil-works/pi-protocol"; export interface SessionDirectoryState { @@ -6,8 +6,13 @@ export interface SessionDirectoryState { sessions: SessionSummary[]; } +export type SessionDirectoryEvent = + | { type: "created" | "changed"; session: SessionSummary } + | { type: "deleted"; sessionId: string }; + export interface SessionDirectoryService { readonly state: RemoteState; + readonly events: RemoteEvents; } export const SessionDirectory = defineRemoteService("session-directory"); diff --git a/packages/coding-agent/src/experimental/services/stubs-provider.ts b/packages/coding-agent/src/experimental/services/stubs-provider.ts new file mode 100644 index 000000000..60c811c44 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/stubs-provider.ts @@ -0,0 +1,24 @@ +import { + type RemoteServiceProvider, + remoteEvents, + remoteState, + ServiceSliceNotImplemented, +} from "@earendil-works/pi-agent-core"; +import { Accounts, type AccountsState } from "./accounts.ts"; +import { Transcript, type TranscriptEvent } from "./transcript.ts"; + +/** Install documented built-in surfaces whose implementation belongs to later client/server slices. */ +export function provideBuiltinServiceStubs(provider: RemoteServiceProvider): void { + provider.provide(Accounts, { + state: remoteState({ providers: [] }), + async remove() { + throw new ServiceSliceNotImplemented("Accounts.remove"); + }, + }); + provider.provide(Transcript, { + events: remoteEvents(), + async snapshot() { + throw new ServiceSliceNotImplemented("Transcript.snapshot"); + }, + }); +} diff --git a/packages/coding-agent/src/experimental/services/transcript.ts b/packages/coding-agent/src/experimental/services/transcript.ts new file mode 100644 index 000000000..ad2f3e6e2 --- /dev/null +++ b/packages/coding-agent/src/experimental/services/transcript.ts @@ -0,0 +1,22 @@ +import { type Context, defineRemoteService, type RemoteEvents, type RemoteJson } from "@earendil-works/pi-agent-core"; +import type { LaneEvent, LaneSnapshot } from "@earendil-works/pi-protocol"; + +export interface TranscriptSnapshot { + revision: number; + lane: RemoteJson; +} + +export interface TranscriptEvent { + type: "lane_event"; + revision: number; + event: RemoteJson; +} + +export interface TranscriptService { + /** Authoritative snapshot at one transcript revision. */ + snapshot(context: Context): Promise; + /** Ordered semantic deltas; final message events replace streamed projections authoritatively. */ + readonly events: RemoteEvents; +} + +export const Transcript = defineRemoteService("transcript"); diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts index a503e9f94..6f034d4bf 100644 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ b/packages/coding-agent/src/experimental/services/worker.ts @@ -16,10 +16,10 @@ import { import Type, { type Static } from "typebox"; import { Check } from "typebox/value"; import type { ModelRuntime } from "../../core/model-runtime.ts"; -import { Chat } from "./chat.ts"; import { provideChatService } from "./chat-provider.ts"; -import { Models } from "./models.ts"; import { provideModelsService } from "./models-provider.ts"; +import { BUILTIN_SESSION_SERVICES } from "./session-builtins.ts"; +import { provideBuiltinServiceStubs } from "./stubs-provider.ts"; export const ServiceOperationResultSchema = Type.Object( { result: Type.Optional(JsonValueSchema) }, @@ -57,10 +57,11 @@ export async function createSessionWorkerServices(options: { readonly configureServices: ((provider: RemoteServiceProvider) => void | Promise) | undefined; publish(scope: WorkerServiceScope, subscriptionId: string, update: ProtocolServiceProviderUpdate): Promise; }): Promise { - const provider = new RemoteServiceProvider([Chat, Models, ...options.serviceTokens]); + const provider = new RemoteServiceProvider([...BUILTIN_SESSION_SERVICES, ...options.serviceTokens]); try { provideChatService(provider, options.harness); await provideModelsService(provider, options.harness, options.modelRuntime); + provideBuiltinServiceStubs(provider); await options.configureServices?.(provider); } catch (error) { provider.dispose(); diff --git a/packages/coding-agent/src/experimental/session-worker.ts b/packages/coding-agent/src/experimental/session-worker.ts index 624be1797..38f55754d 100644 --- a/packages/coding-agent/src/experimental/session-worker.ts +++ b/packages/coding-agent/src/experimental/session-worker.ts @@ -13,6 +13,7 @@ import { type JsonlSessionMetadata, JsonlSessionRepo, RemoteServiceError, + ServiceSliceNotImplemented, type Session, TODO_CONTEXT, withCancel, @@ -724,7 +725,8 @@ async function run(options: SessionWorkerOptions, createHarness: CreateSessionWo response: { type: "operation_result", requestId: request.requestId, scope: request.scope, result }, }); } catch (error) { - const code: ServiceErrorCode | undefined = error instanceof RemoteServiceError ? error.code : undefined; + const code: ServiceErrorCode | undefined = + error instanceof RemoteServiceError || error instanceof ServiceSliceNotImplemented ? error.code : undefined; await control.send({ type: "operation_response", token, diff --git a/packages/coding-agent/test/experimental-chat-service.test.ts b/packages/coding-agent/test/experimental-chat-service.test.ts index 673a283fe..5762a27a2 100644 --- a/packages/coding-agent/test/experimental-chat-service.test.ts +++ b/packages/coding-agent/test/experimental-chat-service.test.ts @@ -6,6 +6,7 @@ import { InvalidMessage, LaneBusy, RemoteServiceProvider, + ServiceSliceNotImplemented, UnknownSkill, UnknownTemplate, } from "@earendil-works/pi-agent-core"; @@ -64,6 +65,12 @@ describe("Chat service", () => { ).resolves.toBeUndefined(); expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT); expect(requestAbort).toHaveBeenCalledWith("operation-1", BACKGROUND_CONTEXT); + await expect( + provider.invoke( + { serviceId: "chat", member: "steer", args: [{ message: "later", images: null }] }, + BACKGROUND_CONTEXT, + ), + ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); } finally { provider.dispose(); } diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index 136690a2d..803427448 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -13,6 +13,7 @@ import { } from "../src/experimental/services/connection.ts"; import { Models } from "../src/experimental/services/models.ts"; import { SessionDirectory, SessionManagement } from "../src/experimental/services/sessions.ts"; +import { Transcript } from "../src/experimental/services/transcript.ts"; import { configureExperimentalWorkerModel, createExperimentalSessions, @@ -302,6 +303,8 @@ describe("experimental durable server composition", () => { services: [SessionDirectory, SessionManagement], onError: (error) => errors.push(error), }); + expect(firstServices.connection.value).toMatchObject({ status: "connected" }); + expect(secondServices.connection.value).toMatchObject({ status: "connected" }); const firstDirectory = firstServices.use(SessionDirectory); const secondDirectory = secondServices.use(SessionDirectory); const firstManagement = firstServices.use(SessionManagement); @@ -368,6 +371,8 @@ describe("experimental durable server composition", () => { services: [Models], onError: (error) => errors.push(error), }); + expect(firstServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" }); + expect(secondServices.attachment.value).toEqual({ status: "attached", sessionId: "demo-1" }); const firstModels = firstServices.use(Models); const secondModels = secondServices.use(Models); @@ -389,6 +394,19 @@ describe("experimental durable server composition", () => { await Promise.all([firstServices.dispose(BACKGROUND_CONTEXT), secondServices.dispose(BACKGROUND_CONTEXT)]); }); + test("routes explicit later-slice errors across the framed service boundary", async () => { + const { runtime } = await makeServer(); + const client = await attachClient(runtime, "demo-1"); + const services = createSessionServiceNamespace(client, { services: [Transcript] }); + const transcript = services.use(Transcript); + + await expect(transcript.snapshot(BACKGROUND_CONTEXT)).rejects.toMatchObject({ + code: "service_not_implemented", + message: "Transcript.snapshot is not implemented until its later plugin-service slice", + }); + await services.dispose(BACKGROUND_CONTEXT); + }); + test("observes keyed service instances and fences replacement generations over framed transport", async ({ onTestFinished, }) => { @@ -433,6 +451,7 @@ describe("experimental durable server composition", () => { const replaced = observed[1]!.service; await expect(client.attachSession("demo-2")).resolves.toMatchObject({ sessionId: "demo-2" }); await vi.waitFor(() => expect(observed).toHaveLength(3)); + expect(services.attachment.value).toEqual({ status: "attached", sessionId: "demo-2" }); expect(observed[1]!.context.abortSignal?.aborted).toBe(true); expect(observed[2]!.value).toBe("first"); await expect(replaced.replace("late", BACKGROUND_CONTEXT)).rejects.toMatchObject({ diff --git a/packages/coding-agent/test/experimental-service-stubs.test.ts b/packages/coding-agent/test/experimental-service-stubs.test.ts new file mode 100644 index 000000000..b1c738158 --- /dev/null +++ b/packages/coding-agent/test/experimental-service-stubs.test.ts @@ -0,0 +1,34 @@ +import { BACKGROUND_CONTEXT, RemoteServiceProvider, ServiceSliceNotImplemented } from "@earendil-works/pi-agent-core"; +import { describe, expect, test } from "vitest"; +import { BUILTIN_SERVER_SERVICES, BUILTIN_SESSION_SERVICES } from "../src/experimental/services/builtins.ts"; +import { provideBuiltinServiceStubs } from "../src/experimental/services/stubs-provider.ts"; + +describe("experimental built-in service surface", () => { + test("inventories every built-in server and Session service", () => { + expect(BUILTIN_SERVER_SERVICES.map(({ id }) => id)).toEqual(["session-directory", "session-management"]); + expect(BUILTIN_SESSION_SERVICES.map(({ id }) => id)).toEqual(["accounts", "chat", "models", "transcript"]); + }); + + test("exposes later singleton slices as explicit unimplemented providers", async () => { + const provider = new RemoteServiceProvider(BUILTIN_SESSION_SERVICES); + provideBuiltinServiceStubs(provider); + try { + const accounts = provider.subscribe("accounts", "singleton", () => {}).snapshot; + expect(accounts.instances[0]?.states.state?.value).toEqual({ providers: [] }); + await expect( + provider.invoke({ serviceId: "accounts", member: "remove", args: ["anthropic"] }, BACKGROUND_CONTEXT), + ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); + + const transcript = provider.subscribe("transcript", "singleton", () => {}).snapshot; + expect(transcript.instances[0]?.members).toEqual([ + { name: "events", kind: "events" }, + { name: "snapshot", kind: "method" }, + ]); + await expect( + provider.invoke({ serviceId: "transcript", member: "snapshot", args: [] }, BACKGROUND_CONTEXT), + ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); + } finally { + provider.dispose(); + } + }); +}); diff --git a/packages/protocol/README.md b/packages/protocol/README.md index d03146b14..cb8d5de70 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -10,11 +10,11 @@ Protocol version `3` carries the current server- and Session-scoped plugin-servi - singleton and keyed-service subscription snapshots plus ordered state, spawn, and close updates; - correlated responses, request cancellation, out-of-band attachment updates, attachment-scoped events, and bounded protocol errors. -The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain a plugin key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. `prompt()` targets that exact route. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles. +The transport carries `{ serviceId, instance?, member, args }` calls. A server target contains `{ serverId }`; a Session target contains `{ serverId, sessionId, attachmentId }`. Keyed addresses contain a plugin key and provider-owned generation so delayed calls cannot reach a replacement instance. Service subscriptions return a complete member/state snapshot before update delivery starts; member metadata may declare scaffolded `events` members, but event delivery frames are not implemented yet. Session-directory state and management results contain no working directory, owner, storage, or parent metadata. The server derives private creation fields from authenticated workspace context. Management `attach()` and `detach()` return no routing identifiers; the server publishes the selected live route in an out-of-band `attachment` message. `prompt()` targets that exact route. The real `Session` and `AgentHarness` remain process-local. Disconnecting releases only that presentation's attachment after admitted work settles. `PromptArguments` contains one serializable `AgentLane.prompt()` overload. `PromptMessage` is the protocol's closed set of built-in message DTOs; application-defined `AgentMessage` extensions are not accepted implicitly. `RunResult` is the wire-safe structural equivalent of the Harness result and contains no JavaScript `Error` instances. -The temporary `ServiceRpc` manifest still backs low-level client compatibility methods and transcript operations while those features migrate to plugin services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` services plus the worker-owned `Chat` service; Session plugin calls route opaquely to the attached worker, where the provider validates and invokes them. +The temporary `ServiceRpc` manifest still backs low-level client compatibility methods and transcript operations while those features migrate to plugin services. Experimental presentations consume server-owned `SessionDirectory` and `SessionManagement` services plus the worker-owned built-in service inventory. `Chat` and `Models` have implemented providers; `Accounts` and revisioned `Transcript` are explicit later-slice scaffolds. Session plugin calls route opaquely to the attached worker, where the provider validates and invokes them. A lane watch uses three RPCs so snapshot ordering does not depend on response/event scheduling: `watch()` creates a buffering watch and returns its authoritative snapshot, `startWatch()` flushes buffered events and begins live delivery, and `stopWatch()` releases it. Events carry their watch ID. Streaming message updates carry compact assistant-message frames rather than cumulative partial messages. Reconnection creates a new watch and snapshot rather than replaying old events. diff --git a/packages/protocol/src/protocol.ts b/packages/protocol/src/protocol.ts index 97bc52bc0..8499f5b77 100644 --- a/packages/protocol/src/protocol.ts +++ b/packages/protocol/src/protocol.ts @@ -105,7 +105,7 @@ export type ProtocolRpcResult = JsonValue | undefined; export const ServiceMemberDescriptionSchema = StrictObject({ name: IdSchema, - kind: Type.Union([Type.Literal("method"), Type.Literal("state")]), + kind: Type.Union([Type.Literal("method"), Type.Literal("state"), Type.Literal("events")]), }); export type ServiceMemberDescription = Static; @@ -237,6 +237,7 @@ export const ServiceErrorCodeSchema = Type.Union([ Type.Literal("service_instance_not_found"), Type.Literal("service_stale_instance"), Type.Literal("service_invalid_value"), + Type.Literal("service_not_implemented"), ]); export type ServiceErrorCode = Static; diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index e5917bb93..16fe36dd7 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -27,6 +27,7 @@ import { ProtocolValidationError, parseClientMessage, parseServerMessage, + parseServiceSubscriptionSnapshot, type RunResult, RunResultSchema, type ServerHello, @@ -219,6 +220,17 @@ describe("protocol validation", () => { type: "unsubscribe", subscriptionId: "subscription-1", }); + expect( + parseServiceSubscriptionSnapshot({ + serviceId: "transcript", + mode: "singleton", + instances: [{ members: [{ name: "events", kind: "events" }], states: {} }], + }), + ).toEqual({ + serviceId: "transcript", + mode: "singleton", + instances: [{ members: [{ name: "events", kind: "events" }], states: {} }], + }); const keyed: ClientMessage = { type: "request", id: "request-1", diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 700b9371e..d593121ca 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,6 +1,7 @@ import { BACKGROUND_CONTEXT, RemoteServiceError, + ServiceSliceNotImplemented, type SessionMetadata, TODO_CONTEXT, withAbortSignal, @@ -466,7 +467,11 @@ export class Server { } private toProtocolError(error: unknown): ProtocolError { - if (error instanceof ServerError || error instanceof RemoteServiceError) { + if ( + error instanceof ServerError || + error instanceof RemoteServiceError || + error instanceof ServiceSliceNotImplemented + ) { return { code: error.code, message: error.message }; } if (error instanceof ProtocolValidationError) {