From d3697e65cabe160975889b7f89748d73f049e900 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 30 Aug 2026 22:51:00 +0200 Subject: [PATCH] feat(coding-agent): simplify experimental services and CLI --- .../plugins/pi-example-plugin/README.md | 4 +- .../plugins/pi-example-plugin/src/tui.ts | 2 +- .../coding-agent/src/cli/experimental/cli.ts | 15 +- .../src/cli/experimental/command-options.ts | 47 ++-- .../src/cli/experimental/command.ts | 35 ++- .../src/cli/experimental/commands/client.ts | 34 +-- .../src/cli/experimental/commands/pi.ts | 47 ---- .../src/cli/experimental/commands/server.ts | 13 +- .../src/experimental/client-tui.ts | 9 +- .../coding-agent/src/experimental/client.ts | 2 +- .../src/experimental/plugins/hello.ts | 28 -- .../src/experimental/services/README.md | 8 +- .../src/experimental/services/accounts.ts | 17 -- .../experimental/services/stubs-provider.ts | 36 --- .../src/experimental/services/transcript.ts | 14 - .../src/experimental/services/worker.ts | 22 +- packages/coding-agent/src/main.ts | 6 +- .../test/experimental-cli-command.test.ts | 251 ++++-------------- .../test/experimental-cli-resolution.test.ts | 87 +----- .../test/experimental-client-tui.test.ts | 12 +- .../test/experimental-remote-runtime.test.ts | 14 - .../test/experimental-service-stubs.test.ts | 35 --- .../test/experimental-slash-commands.test.ts | 4 +- 23 files changed, 148 insertions(+), 594 deletions(-) delete mode 100644 packages/coding-agent/src/cli/experimental/commands/pi.ts delete mode 100644 packages/coding-agent/src/experimental/plugins/hello.ts delete mode 100644 packages/coding-agent/src/experimental/services/accounts.ts delete mode 100644 packages/coding-agent/src/experimental/services/stubs-provider.ts delete mode 100644 packages/coding-agent/src/experimental/services/transcript.ts delete mode 100644 packages/coding-agent/test/experimental-service-stubs.test.ts diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md index ecff7f533..0fb12f942 100644 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/README.md +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/README.md @@ -1,6 +1,6 @@ # `@earendil-works/pi-example-plugin` -This package provides conventional `session` and `tui` Chord facets. The Session-worker facet provides a remote greeting service. The TUI facet contributes `/facet-hello` and calls that service. +This package provides conventional `session` and `tui` Chord facets. The Session-worker facet provides a remote greeting service. The TUI facet contributes `/hello` and calls that service. The package needs no build script. Pi asks Chord to discover `src/session.ts` and `src/tui.ts`, builds both entries into its server-owned plugin cache, and sends the TUI artifact to clients. @@ -22,7 +22,7 @@ Repeat `-e` to select multiple plugin packages. Client paths are resolved locall `server -e` establishes the server profile's default Session and TUI facets. Starting an explicit foreground server without `-e` clears that default. Client selection never changes the server's root facet generation. -Run `/facet-hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and serves the new artifact to future clients. +Run `/hello Armin` in the TUI. After editing a facet, run `/reload`. The server atomically rebuilds the package, reloads the attached Session-worker generation, updates the current TUI generation, and serves the new artifact to future clients. Package metadata can override or disable conventional entries: diff --git a/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts b/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts index e1edb87ec..2158827ab 100644 --- a/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts +++ b/packages/coding-agent/examples/plugins/pi-example-plugin/src/tui.ts @@ -12,7 +12,7 @@ export default defineFacet({ env.onActivate(() => { env.own( commands.register({ - name: "facet-hello", + name: "hello", description: "Call the bundled Session worker facet", argumentHint: "", async run(args, context) { diff --git a/packages/coding-agent/src/cli/experimental/cli.ts b/packages/coding-agent/src/cli/experimental/cli.ts index 2bdc963e1..4843e86be 100644 --- a/packages/coding-agent/src/cli/experimental/cli.ts +++ b/packages/coding-agent/src/cli/experimental/cli.ts @@ -1,7 +1,16 @@ +import { Command } from "./command.ts"; import { type ClientCommandContext, clientCommand } from "./commands/client.ts"; -import { type PiCommandContext, piCommand } from "./commands/pi.ts"; import { type ServerCommandContext, serverCommand } from "./commands/server.ts"; -export type CliContext = PiCommandContext & ServerCommandContext & ClientCommandContext; +interface ExperimentalCommandGroup { + readonly command: "experimental"; +} -export const cli = piCommand.command(serverCommand).command(clientCommand); +export type CliContext = ServerCommandContext & ClientCommandContext; + +const experimentalCommand = new Command("experimental").build(() => ({ + ok: false, + errors: ["Expected experimental command: server or client"], +})); + +export const cli = experimentalCommand.command(serverCommand).command(clientCommand); diff --git a/packages/coding-agent/src/cli/experimental/command-options.ts b/packages/coding-agent/src/cli/experimental/command-options.ts index d72d9221d..d0fe30257 100644 --- a/packages/coding-agent/src/cli/experimental/command-options.ts +++ b/packages/coding-agent/src/cli/experimental/command-options.ts @@ -1,7 +1,6 @@ import { posix } from "node:path"; import { isServerId, type ServerId } from "@earendil-works/pi-protocol"; -import { type Args, parseArgs } from "../args.ts"; -import { type CommandOption, type ParsedCommandInput, stringOption, valueOption } from "./command.ts"; +import { type ParsedCommandInput, stringOption, valueOption } from "./command.ts"; export type AuthInput = | { readonly type: "token"; readonly token: string } @@ -38,18 +37,14 @@ function parseAuthInput(options: { readonly authToken?: string; readonly authTok return { errors: [] }; } -function parseTransportAddress( - value: string, - option: "--listen" | "--connect", -): { address?: TransportAddress; error?: string } { +function parseTransportAddress(value: string): { address?: TransportAddress; error?: string } { let url: URL; try { url = new URL(value); } catch { - return { error: `Invalid ${option} address "${value}"` }; + return { error: `Invalid --connect address "${value}"` }; } if (url.protocol === "radius:") { - if (option !== "--connect") return { error: "Radius transport is only valid for --connect" }; if ( url.username || url.password || @@ -59,7 +54,7 @@ function parseTransportAddress( url.hash || value !== `radius://${url.hostname}${url.pathname}` ) { - return { error: `Invalid ${option} address "${value}"` }; + return { error: `Invalid --connect address "${value}"` }; } const serverId = url.hostname; if (!isServerId(serverId)) { @@ -67,7 +62,7 @@ function parseTransportAddress( } return { address: { transport: "radius", serverId } }; } - if (url.protocol !== "unix:") return { error: `Unsupported ${option} transport "${url.protocol}"` }; + if (url.protocol !== "unix:") return { error: `Unsupported --connect transport "${url.protocol}"` }; if (url.hostname || url.port || url.username || url.password) { return { error: "Unix transport address must not include an authority" }; } @@ -78,27 +73,25 @@ function parseTransportAddress( value.includes("#") || url.href !== value ) { - return { error: `Invalid ${option} address "${value}"` }; + return { error: `Invalid --connect address "${value}"` }; } let path: string; try { path = decodeURIComponent(url.pathname); } catch { - return { error: `Invalid ${option} address "${value}"` }; + return { error: `Invalid --connect address "${value}"` }; } - if (path.includes("\0")) return { error: `Invalid ${option} address "${value}"` }; + if (path.includes("\0")) return { error: `Invalid --connect address "${value}"` }; if (!posix.isAbsolute(path)) return { error: "Unix transport address requires an absolute path" }; return { address: { transport: "unix", path } }; } -export function transportOption(name: "--listen" | "--connect"): CommandOption { - return valueOption(name, (value) => { - const result = parseTransportAddress(value, name); - return result.address - ? { ok: true, value: result.address } - : { ok: false, error: result.error ?? `Invalid ${name} address "${value}"` }; - }); -} +export const connectOption = valueOption("--connect", (value) => { + const result = parseTransportAddress(value); + return result.address + ? { ok: true, value: result.address } + : { ok: false, error: result.error ?? `Invalid --connect address "${value}"` }; +}); export function parseAuth(input: ParsedCommandInput): { auth?: AuthInput; errors: string[] } { return parseAuthInput({ @@ -107,17 +100,7 @@ export function parseAuth(input: ParsedCommandInput): { auth?: AuthInput; errors }); } -export function parseLegacyOptions(input: ParsedCommandInput): { options: Args; errors: string[] } { - const options = parseArgs([...input.remainingArgs]); - return { - options, - errors: options.diagnostics - .filter((diagnostic) => diagnostic.type === "error") - .map((diagnostic) => diagnostic.message), - }; -} - -export function unsupportedLegacyOptions(command: string, input: ParsedCommandInput): string[] { +export function unsupportedOptions(command: string, input: ParsedCommandInput): string[] { if (input.remainingArgs.length === 0) return []; return [`The experimental ${command} command does not support existing CLI options yet`]; } diff --git a/packages/coding-agent/src/cli/experimental/command.ts b/packages/coding-agent/src/cli/experimental/command.ts index b9c9c7687..990794295 100644 --- a/packages/coding-agent/src/cli/experimental/command.ts +++ b/packages/coding-agent/src/cli/experimental/command.ts @@ -16,6 +16,7 @@ export type CommandOptionParseResult = export interface CommandOption { readonly name: `-${string}`; + readonly flag?: boolean; readonly repeatable?: boolean; parse(value: string): CommandOptionParseResult; } @@ -35,6 +36,10 @@ export function stringOption( return valueOption(name, (value) => ({ ok: true, value }), options); } +export function flagOption(name: `-${string}`): CommandOption { + return { name, flag: true, parse: () => ({ ok: true, value: true }) }; +} + export interface ParsedCommandInput { readonly remainingArgs: readonly string[]; value(option: CommandOption): TValue | undefined; @@ -179,17 +184,27 @@ export class Command< break; } - let value = equals === -1 ? undefined : argument.slice(equals + 1); - if (value === undefined) { - const next = argv[index + 1]; - if (next !== undefined && !next.startsWith("-")) { - value = next; - index++; + let value: string; + if (option.flag === true) { + if (equals !== -1) { + parsed.errors.push(`${name} does not take a value`); + continue; } - } - if (value === undefined || value === "") { - parsed.errors.push(`${name} requires a value`); - continue; + value = ""; + } else { + let candidate = equals === -1 ? undefined : argument.slice(equals + 1); + if (candidate === undefined) { + const next = argv[index + 1]; + if (next !== undefined && !next.startsWith("-")) { + candidate = next; + index++; + } + } + if (candidate === undefined || candidate === "") { + parsed.errors.push(`${name} requires a value`); + continue; + } + value = candidate; } const values = parsed.values.get(name) ?? []; diff --git a/packages/coding-agent/src/cli/experimental/commands/client.ts b/packages/coding-agent/src/cli/experimental/commands/client.ts index 7aefbd84f..e44fbfe9d 100644 --- a/packages/coding-agent/src/cli/experimental/commands/client.ts +++ b/packages/coding-agent/src/cli/experimental/commands/client.ts @@ -1,13 +1,12 @@ -import { Command, stringOption } from "../command.ts"; +import { Command, flagOption, stringOption } from "../command.ts"; import { type AuthInput, authTokenFileOption, authTokenOption, + connectOption, parseAuth, - parseLegacyOptions, type TransportAddress, - transportOption, - unsupportedLegacyOptions, + unsupportedOptions, } from "../command-options.ts"; export interface ClientCommand { @@ -27,8 +26,11 @@ export interface ClientCommandContext { runClient(command: ClientCommand): void | Promise; } -const connectOption = transportOption("--connect"); const sessionIdOption = stringOption("--session-id"); +const continueOption = flagOption("--continue"); +const continueShortOption = flagOption("-c"); +const resumeOption = flagOption("--resume"); +const resumeShortOption = flagOption("-r"); const providerOption = stringOption("--provider"); const modelOption = stringOption("--model"); const pluginPackageOption = stringOption("-e", { repeatable: true }); @@ -36,6 +38,10 @@ const pluginPackageOption = stringOption("-e", { repeatable: true }); export const clientCommand = new Command("client") .option(connectOption) .option(sessionIdOption) + .option(continueOption) + .option(continueShortOption) + .option(resumeOption) + .option(resumeShortOption) .option(providerOption) .option(modelOption) .option(pluginPackageOption) @@ -45,6 +51,8 @@ export const clientCommand = new Command("c const { auth, errors: authErrors } = parseAuth(input); const connect = input.value(connectOption); const sessionId = input.value(sessionIdOption); + const shouldContinue = input.value(continueOption) === true || input.value(continueShortOption) === true; + const shouldResume = input.value(resumeOption) === true || input.value(resumeShortOption) === true; const provider = input.value(providerOption); const model = input.value(modelOption); const pluginPackages = input.values(pluginPackageOption); @@ -55,20 +63,14 @@ export const clientCommand = new Command("c promptArgs[0]!.length > 0 ? promptArgs[0] : undefined; - const { options, errors: optionErrors } = parseLegacyOptions(input); const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : []; const sessionSelectionErrors = - [sessionId !== undefined, options.continue === true, options.resume === true].filter(Boolean).length > 1 + [sessionId !== undefined, shouldContinue, shouldResume].filter(Boolean).length > 1 ? ["--session-id, --continue, and --resume are mutually exclusive"] : []; - const onlySessionSelectionOptions = - input.remainingArgs.length > 0 && - input.remainingArgs.every((arg) => arg === "--continue" || arg === "-c" || arg === "--resume" || arg === "-r"); const unsupportedErrors = - input.remainingArgs.length === 0 || prompt !== undefined || onlySessionSelectionOptions - ? [] - : unsupportedLegacyOptions("client", input); - const errors = [...authErrors, ...optionErrors, ...modelErrors, ...sessionSelectionErrors, ...unsupportedErrors]; + input.remainingArgs.length === 0 || prompt !== undefined ? [] : unsupportedOptions("client", input); + const errors = [...authErrors, ...modelErrors, ...sessionSelectionErrors, ...unsupportedErrors]; if (errors.length > 0) return { ok: false, errors }; return { ok: true, @@ -77,8 +79,8 @@ export const clientCommand = new Command("c ...(auth === undefined ? {} : { auth }), ...(connect === undefined ? {} : { connect }), ...(sessionId === undefined ? {} : { sessionId }), - ...(options.continue === true ? { continue: true } : {}), - ...(options.resume === true ? { resume: true } : {}), + ...(shouldContinue ? { continue: true } : {}), + ...(shouldResume ? { resume: true } : {}), ...(provider === undefined ? {} : { provider }), ...(model === undefined ? {} : { model }), ...(pluginPackages.length === 0 ? {} : { pluginPackages }), diff --git a/packages/coding-agent/src/cli/experimental/commands/pi.ts b/packages/coding-agent/src/cli/experimental/commands/pi.ts deleted file mode 100644 index 639388eb3..000000000 --- a/packages/coding-agent/src/cli/experimental/commands/pi.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Args } from "../../args.ts"; -import { Command } from "../command.ts"; -import { - type AuthInput, - authTokenFileOption, - authTokenOption, - parseAuth, - parseLegacyOptions, - type TransportAddress, - transportOption, -} from "../command-options.ts"; - -export interface PiCommand { - readonly command: "pi"; - readonly auth?: AuthInput; - readonly options: Args; - readonly listen?: readonly TransportAddress[]; -} - -export interface PiCommandContext { - runPi(command: PiCommand): void | Promise; -} - -const listenOption = transportOption("--listen"); - -export const piCommand = new Command("pi") - .option(listenOption) - .option(authTokenOption) - .option(authTokenFileOption) - .build((input) => { - const { auth, errors: authErrors } = parseAuth(input); - const listen = input.values(listenOption); - const { options, errors: optionErrors } = parseLegacyOptions(input); - const errors = [...authErrors, ...optionErrors]; - if (options.unknownFlags.has("connect")) errors.push("--connect is only valid for client mode"); - if (errors.length > 0) return { ok: false, errors }; - return { - ok: true, - command: { - command: "pi", - options, - ...(auth === undefined ? {} : { auth }), - ...(listen.length === 0 ? {} : { listen }), - }, - }; - }) - .action((command, context) => context.runPi(command)); diff --git a/packages/coding-agent/src/cli/experimental/commands/server.ts b/packages/coding-agent/src/cli/experimental/commands/server.ts index 07d1291dd..469b4268b 100644 --- a/packages/coding-agent/src/cli/experimental/commands/server.ts +++ b/packages/coding-agent/src/cli/experimental/commands/server.ts @@ -5,16 +5,12 @@ import { authTokenFileOption, authTokenOption, parseAuth, - parseLegacyOptions, - type TransportAddress, - transportOption, - unsupportedLegacyOptions, + unsupportedOptions, } from "../command-options.ts"; export interface ServerCommand { readonly command: "server"; readonly auth?: AuthInput; - readonly listen?: readonly TransportAddress[]; readonly provider?: string; readonly model?: string; readonly pluginPackages?: readonly string[]; @@ -26,7 +22,6 @@ export interface ServerCommandContext { runServer(command: ServerCommand): void | Promise; } -const listenOption = transportOption("--listen"); const serverIdOption = valueOption("--server-id", (value) => isServerId(value) ? { ok: true, value } @@ -38,7 +33,6 @@ const modelOption = stringOption("--model"); const pluginPackageOption = stringOption("-e", { repeatable: true }); export const serverCommand = new Command("server") - .option(listenOption) .option(serverIdOption) .option(sessionDirOption) .option(providerOption) @@ -48,22 +42,19 @@ export const serverCommand = new Command("s .option(authTokenFileOption) .build((input) => { const { auth, errors: authErrors } = parseAuth(input); - const listen = input.values(listenOption); const serverId = input.value(serverIdOption); const sessionDir = input.value(sessionDirOption); const provider = input.value(providerOption); const model = input.value(modelOption); const pluginPackages = input.values(pluginPackageOption); - const { errors: optionErrors } = parseLegacyOptions(input); const modelErrors = provider !== undefined && model === undefined ? ["--provider requires --model"] : []; - const errors = [...authErrors, ...optionErrors, ...modelErrors, ...unsupportedLegacyOptions("server", input)]; + const errors = [...authErrors, ...modelErrors, ...unsupportedOptions("server", input)]; if (errors.length > 0) return { ok: false, errors }; return { ok: true, command: { command: "server", ...(auth === undefined ? {} : { auth }), - ...(listen.length === 0 ? {} : { listen }), ...(provider === undefined ? {} : { provider }), ...(model === undefined ? {} : { model }), ...(pluginPackages.length === 0 ? {} : { pluginPackages }), diff --git a/packages/coding-agent/src/experimental/client-tui.ts b/packages/coding-agent/src/experimental/client-tui.ts index c09b4553d..5bacc1ee1 100644 --- a/packages/coding-agent/src/experimental/client-tui.ts +++ b/packages/coding-agent/src/experimental/client-tui.ts @@ -2,7 +2,6 @@ import { resolve } from "node:path"; import { combineFacetLoaders, createFacetHost, - createStaticFacetLoader, defineFacet, type FacetHost, type FacetLoader, @@ -34,7 +33,6 @@ import { type OpenClientRuntimeOptions, openClientRuntime } from "./client-runti import { ExperimentalChatView } from "./client-tui-chat.ts"; import { type LaneReplica, type LaneWatchSource, openLaneReplica } from "./lane-replica.ts"; import { createPresentationFacetLoaders } from "./plugins/bundled.ts"; -import helloPluginFacet from "./plugins/hello.ts"; import { AgentController, type AgentOperationResponse, type AgentQueueResponse } from "./services/agent-controller.ts"; import type { ServerConnectionState, @@ -164,10 +162,9 @@ export class ExperimentalClientTui implements Component { finish(): void; }): Promise { const prepared = await prepareClientSession(options.command, options.servers); - const loadedFacets = await combineFacetLoaders([ - createStaticFacetLoader([helloPluginFacet]), - ...(options.facetLoader === undefined ? [] : [options.facetLoader]), - ]).load(); + const loadedFacets = await combineFacetLoaders( + options.facetLoader === undefined ? [] : [options.facetLoader], + ).load(); const component = new ExperimentalClientTui(options.ui, options.requestRender, options.finish, loadedFacets); try { await component.#start(prepared); diff --git a/packages/coding-agent/src/experimental/client.ts b/packages/coding-agent/src/experimental/client.ts index 166ad6058..2d7eef906 100644 --- a/packages/coding-agent/src/experimental/client.ts +++ b/packages/coding-agent/src/experimental/client.ts @@ -80,7 +80,7 @@ export async function runClient(command: ClientCommand, options: RunClientOption const agent = match.agent; const completedText = new Map(); - // AgentController returns no transcript content. Keep the lane watch until Transcript owns live deltas. + // AgentController returns no transcript content. Keep the lane watch until a transcript service owns live deltas. const watch = await match.client.watchSession(sessionId); await watch.start(async (event) => { if (event.type === "message_end" && event.runId !== undefined && event.message.role === "assistant") { diff --git a/packages/coding-agent/src/experimental/plugins/hello.ts b/packages/coding-agent/src/experimental/plugins/hello.ts deleted file mode 100644 index 9034c52a9..000000000 --- a/packages/coding-agent/src/experimental/plugins/hello.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { defineFacet } from "@earendil-works/chord"; -import { AgentController } from "../services/agent-controller.ts"; -import { SlashCommands } from "../services/slash-commands.ts"; - -/** Static example presentation facet; examples/plugins/pi-example-plugin demonstrates packaged plugins. */ -const helloPluginFacet = defineFacet({ - id: "@pi/example-hello", - setup(env) { - const commands = env.use(SlashCommands); - const controller = env.use(AgentController); - env.onActivate(() => { - env.own( - commands.register({ - name: "hello", - description: "Send a greeting from the example plugin", - run(args, context) { - return controller.prompt( - { message: args.length === 0 ? "Hello from the example plugin" : args, images: null }, - context, - ); - }, - }), - ); - }); - }, -}); - -export default helloPluginFacet; diff --git a/packages/coding-agent/src/experimental/services/README.md b/packages/coding-agent/src/experimental/services/README.md index ec87e52c4..5ba6d6f83 100644 --- a/packages/coding-agent/src/experimental/services/README.md +++ b/packages/coding-agent/src/experimental/services/README.md @@ -1,6 +1,6 @@ # Experimental client/server service slices -Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. A built-in token 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. +Facet setup generates each host's RPC service catalogue from its provided non-local tokens. Remote service sources obtain those catalogues and bind only services required by consuming facets; there is no handwritten built-in service inventory. With no selected Session, its deferred source admits unresolved requirements as unavailable and keeps their handles disconnected. Attachment validates them against the worker's generated catalogue, which is cached for later detached generations. Keyed services hydrate as an empty directory until their owning feature spawns an instance. | Scope | Service | Current slice | Continuation point | |---|---|---|---| @@ -10,17 +10,15 @@ Facet setup generates each host's RPC service catalogue from its provided non-lo | session | `SessionPlugins` | reloads the configured Session facet generation | add coordinated multi-worker reload reporting | | session | `Models` | state, default-persisting selection, thinking, refresh implemented | move provider/auth composition behind plugin facets | | session | `AgentController` | presentation-safe `AgentLane` facade for prompting, queueing, abort, resume, compaction, and navigation | add new lane operations only when a presentation requires them | -| session | `Accounts` | state declared; mutations throw | connect the local credential service to the presentation-safe facade | -| session | `Transcript` | authoritative snapshot declared but throws | implement snapshot projection and remove the compatibility lane watch | | presentation | `SlashCommands` | process-local contribution registry with model, thinking, compact, reload, and example hello commands | add more presentation hookpoints only as concrete plugin slices require them | | presentation | `PresentationUI` | process-local selection and status capabilities | add narrowly scoped UI capabilities only when commands require them | `ServerServiceSource.connection` and `SessionServiceSource.attachment` are implemented local control states. -With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. `Transcript` remains reserved for a later service-owned live-delta replacement of that compatibility watch. +With `PI_EXPERIMENTAL=1`, an interactive `pi client` creates and attaches a Session before opening the service-only chat TUI. `pi client -c` and `pi client -r` attach the newest existing Session instead, preserving its durable model and thinking configuration. Model selection is available on demand through `/model`; it is never a startup screen. The presentation always uses the stable coding agent's alternate-screen renderer and shared transcript/dock viewport. It loads configured theme resources and uses the stable terminal light/dark detection and appearance-change notifications. Its replicated state feeds the stable editor, message, tool, status, theme, and tool-renderer components. The presentation drives the worker-owned main lane through `AgentController` and renders a coherent lane-watch replica with the Harness reducer while controller calls are pending. A future transcript service can replace that compatibility watch. A foreground server uses repeatable `-e` options to establish its default Session and TUI facets. A local client may instead select packages for the Session it creates or resumes; that branch selection is persisted with the Session and does not alter other workers or the server default. Before attaching, the server asks Chord to build conventional `src/session.ts` and `src/tui.ts` entries into separate `plugin-builds/` directories, passes the generated manifest paths to that Session worker, and returns the matching TUI artifacts. The Session worker loads built-in facets and separately owned plugin generations, then creates one active `FacetHost`. `/reload` atomically rebuilds the packages, loads fresh plugin candidates, cuts them over through `FacetHost.reload()`, and disposes the retired generations. Host-created implementation dependencies such as `AgentLane`, `ModelRuntime`, and `SettingsManager` are passed directly to built-in facet factories; they are not exposed as services. The TUI loads configured presentation facets and adds one private bridge facet that consumes the connected server and selected-Session services for `ExperimentalClientTui`. Its local presentation services are `SlashCommands`, which owns command contributions, and `PresentationUI`, which exposes selection and status rendering without exposing the raw TUI. Command callbacks receive Chord `Context` directly. Facets consume `AgentController` explicitly for prompting, steering, or queueing and return structured controller results to the command dispatcher. A host may reload selected facets when their declared service shape is unchanged; retained consumers keep the same service facades while replacement implementations and singleton snapshots are installed behind them. Synchronous setup-time `env.provide()`, `env.provideMany()`, `env.use()`, and `env.observe()` calls produce the internal dependency graph; setup does not repeat a declarative dependency list, and service handles remain disconnected until the complete graph validates. Providers activate before consumers, observations connect with their consuming facet, and facet replacement or host shutdown disposes affected lifecycles in reverse dependency order. Server and Session service tokens are non-local and automatically published by their providing host. Presentation-only hookpoints such as `SlashCommands` are explicitly local and never enter an RPC catalogue. -A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example `/hello` plugin is one static presentation facet. `examples/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control remain protocol/host infrastructure slices rather than presentation service tokens. +A facet always calls unqualified `env.use()` or `env.observe()`. The host resolves each token across facet-provided and connected services. The example plugin package contributes `/hello` through its TUI facet. `examples/plugins/pi-example-plugin/` is an actual `@earendil-works/pi-example-plugin` package: repeatable `-e` options select plugin packages as server defaults or for one Session branch, Chord discovers and builds their conventional facets, and the attached presentation receives matching TUI artifacts through `PresentationPlugins`. Other host facets from the same plugin remain separate bundle entries rather than one aggregate plugin object. Transport bindings remain internal machinery rather than part of the facet environment. `ExperimentalClientTui` currently owns terminal rendering, state subscriptions, navigation, and action dispatch directly. The server provider remains directly assembled until its complete host environment exists. The question dialog, diff review, Git, indexing-job, canvas, and rich local TUI service examples in `packages/agent/docs/plugins.md` are extension patterns, not built-in coding-agent services. Private references, trace carriers, and flow control 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 deleted file mode 100644 index 1ab6ea969..000000000 --- a/packages/coding-agent/src/experimental/services/accounts.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type Context, defineService, type ReplicatedState } from "@earendil-works/chord"; - -export interface AccountSummary { - provider: string; - configured: boolean; -} - -export interface AccountsState { - providers: AccountSummary[]; -} - -export interface Accounts { - readonly state: ReplicatedState; - remove(provider: string, context: Context): Promise; -} - -export const Accounts = defineService("pi.accounts"); diff --git a/packages/coding-agent/src/experimental/services/stubs-provider.ts b/packages/coding-agent/src/experimental/services/stubs-provider.ts deleted file mode 100644 index 09a5bbca0..000000000 --- a/packages/coding-agent/src/experimental/services/stubs-provider.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineFacet } from "@earendil-works/chord"; -import { Accounts, type AccountsState } from "./accounts.ts"; -import { Transcript } from "./transcript.ts"; - -export class ServiceSliceNotImplemented extends Error { - readonly code = "service_not_implemented" as const; - - constructor(operation: string) { - super(`${operation} is not implemented until its later facet-service slice`); - this.name = "ServiceSliceNotImplemented"; - } -} - -/** Documented built-in surfaces whose implementations belong to later service slices. */ -export const accountsServiceFacet = defineFacet({ - id: "@pi/accounts", - setup(env) { - env.provide(Accounts, { - state: env.replicatedState({ providers: [] }), - async remove() { - throw new ServiceSliceNotImplemented("Accounts.remove"); - }, - }); - }, -}); - -export const transcriptServiceFacet = defineFacet({ - id: "@pi/transcript", - setup(env) { - env.provide(Transcript, { - 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 deleted file mode 100644 index 38ebaa93a..000000000 --- a/packages/coding-agent/src/experimental/services/transcript.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type Context, defineService } from "@earendil-works/chord"; -import type { LaneSnapshot } from "@earendil-works/pi-protocol"; - -export interface TranscriptSnapshot { - revision: number; - lane: LaneSnapshot; -} - -export interface Transcript { - /** Authoritative snapshot at one transcript revision. */ - snapshot(context: Context): Promise; -} - -export const Transcript = defineService("pi.transcript"); diff --git a/packages/coding-agent/src/experimental/services/worker.ts b/packages/coding-agent/src/experimental/services/worker.ts index c47a742de..0f864baae 100644 --- a/packages/coding-agent/src/experimental/services/worker.ts +++ b/packages/coding-agent/src/experimental/services/worker.ts @@ -1,11 +1,9 @@ import { type Context, type ServiceProviderUpdate as CoreServiceProviderUpdate, - combineFacetLoaders, createFacetHost, createStaticFacetLoader, defineFacet, - type Facet, type FacetHost, type FacetLoader, type ServiceCatalogueEntry, @@ -28,7 +26,6 @@ import { AgentController } from "./agent-controller.ts"; import { createAgentController } from "./agent-controller-provider.ts"; import { createModelsServiceFacet } from "./models-provider.ts"; import { SessionPlugins } from "./plugins.ts"; -import { accountsServiceFacet, transcriptServiceFacet } from "./stubs-provider.ts"; export const ServiceOperationResultSchema = Type.Object( { result: Type.Optional(JsonValueSchema) }, @@ -62,18 +59,6 @@ export interface SessionWorkerServices { dispose(): Promise; } -function createBuiltinSessionFacetLoader(options: { - readonly lane: AgentLane; - readonly modelRuntime: ModelRuntime | undefined; - readonly settingsManager?: SettingsManager; -}): FacetLoader { - return createStaticFacetLoader([ - createModelsServiceFacet(options), - accountsServiceFacet, - transcriptServiceFacet, - ] satisfies readonly Facet[]); -} - export async function createSessionWorkerServices(options: { readonly lane: AgentLane; readonly modelRuntime: ModelRuntime | undefined; @@ -94,9 +79,10 @@ export async function createSessionWorkerServices(options: { env.provide(SessionPlugins, { reload: () => reloadPlugins() }); }, }); - const builtins = await combineFacetLoaders([ - createStaticFacetLoader([agentControllerRuntimeFacet, pluginRuntimeFacet]), - createBuiltinSessionFacetLoader(options), + const builtins = await createStaticFacetLoader([ + agentControllerRuntimeFacet, + pluginRuntimeFacet, + createModelsServiceFacet(options), ]).load(); const pluginLoader = options.facetLoader ?? createStaticFacetLoader([]); let loadedPlugins = await pluginLoader.load(); diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6a58cef8c..c6734a0b4 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -584,7 +584,6 @@ async function waitForTermination(serverClosed: Promise): Promise { } async function runExperimentalServerCommand(command: ServerCommand): Promise { - if (command.listen !== undefined) throw new Error("The local demo server uses its server-addressed Unix socket"); let previousRelayStatus = ""; let relayOutputReady = false; let pendingRelayStatus: RadiusRelayHostStatus | undefined; @@ -650,10 +649,9 @@ async function runClientCommand(command: ClientCommand): Promise { } async function runExperimentalCommand(args: string[]): Promise { - if (!areExperimentalFeaturesEnabled()) return false; + if (!areExperimentalFeaturesEnabled() || (args[0] !== "server" && args[0] !== "client")) return false; try { const result = await experimentalCli.execute(args, { - runPi: () => {}, runServer: runExperimentalServerCommand, runClient: runClientCommand, }); @@ -662,7 +660,7 @@ async function runExperimentalCommand(args: string[]): Promise { process.exitCode = 1; return true; } - return result.command.command !== "pi"; + return true; } catch (error) { console.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`)); process.exitCode = 1; diff --git a/packages/coding-agent/test/experimental-cli-command.test.ts b/packages/coding-agent/test/experimental-cli-command.test.ts index 4d2e43c19..c1c5b131c 100644 --- a/packages/coding-agent/test/experimental-cli-command.test.ts +++ b/packages/coding-agent/test/experimental-cli-command.test.ts @@ -1,122 +1,50 @@ import { describe, expect, test } from "vitest"; import { cli } from "../src/cli/experimental/cli.ts"; +const UNSUPPORTED_SERVER_OPTIONS = "The experimental server command does not support existing CLI options yet"; +const UNSUPPORTED_CLIENT_OPTIONS = "The experimental client command does not support existing CLI options yet"; + describe("experimental CLI commands", () => { - test("selects pi mode and parses existing CLI arguments", () => { + test("parses server configuration", () => { expect( cli.parse([ + "server", + "--server-id", + "00000000-0000-4000-8000-000000000001", + "--session-dir", + "~/pi-sessions", "--provider", "anthropic", "--model", - "claude-sonnet", - "--thinking", - "high", - "inspect", - "the project", + "claude-sonnet-4-5", + "-e", + "./first-plugin", + "-e=./second-plugin", ]), - ).toMatchObject({ - ok: true, - command: { - command: "pi", - options: { - provider: "anthropic", - model: "claude-sonnet", - thinking: "high", - messages: ["inspect", "the project"], - }, - }, - }); - }); - - test("parses a server listener", () => { - expect(cli.parse(["server", "--listen", "unix:///tmp/pi.sock"])).toEqual({ - ok: true, - command: { - command: "server", - listen: [{ transport: "unix", path: "/tmp/pi.sock" }], - }, - }); - }); - - test("parses an experimental server ID", () => { - expect(cli.parse(["server", "--server-id", "00000000-0000-4000-8000-000000000001"])).toEqual({ + ).toEqual({ ok: true, command: { command: "server", serverId: "00000000-0000-4000-8000-000000000001", - }, - }); - }); - - test("parses an experimental server session directory", () => { - expect(cli.parse(["server", "--session-dir", "~/pi-sessions"])).toEqual({ - ok: true, - command: { - command: "server", sessionDir: "~/pi-sessions", - }, - }); - }); - - test("parses an experimental server model", () => { - expect(cli.parse(["server", "--provider", "anthropic", "--model", "claude-sonnet-4-5"])).toEqual({ - ok: true, - command: { - command: "server", provider: "anthropic", model: "claude-sonnet-4-5", - }, - }); - }); - - test("parses a provider-qualified server model", () => { - expect(cli.parse(["server", "--model", "anthropic/claude-sonnet-4-5:high"])).toEqual({ - ok: true, - command: { - command: "server", - model: "anthropic/claude-sonnet-4-5:high", - }, - }); - }); - - test("parses repeatable server plugin packages", () => { - expect(cli.parse(["server", "-e", "./first-plugin", "-e=./second-plugin"])).toEqual({ - ok: true, - command: { - command: "server", pluginPackages: ["./first-plugin", "./second-plugin"], }, }); }); - test("leaves experimental-looking existing option values with the existing parser", () => { - expect(cli.parse(["--system-prompt", "--listen", "unix:///tmp/pi.sock"])).toMatchObject({ + test("parses provider-qualified server models", () => { + expect(cli.parse(["server", "--model", "anthropic/claude-sonnet-4-5:high"])).toEqual({ ok: true, - command: { - command: "pi", - options: { systemPrompt: "--listen", messages: ["unix:///tmp/pi.sock"] }, - }, + command: { command: "server", model: "anthropic/claude-sonnet-4-5:high" }, }); }); - test("stops parsing command options when existing CLI arguments begin", () => { - const result = cli.parse(["--model", "claude-sonnet", "--listen=unix:///tmp/second.sock"]); - expect(result).toMatchObject({ - ok: true, - command: { command: "pi", options: { model: "claude-sonnet" } }, - }); - if (!result.ok || result.command.command !== "pi") return; - expect(result.command.listen).toBeUndefined(); - expect(result.command.options.unknownFlags.get("listen")).toBe("unix:///tmp/second.sock"); - }); - test("parses client transport addresses", () => { expect(cli.parse(["client", "--connect", "unix:///tmp/pi.sock"])).toEqual({ ok: true, - command: { - command: "client", - connect: { transport: "unix", path: "/tmp/pi.sock" }, - }, + command: { command: "client", connect: { transport: "unix", path: "/tmp/pi.sock" } }, }); expect( cli.parse(["client", "--connect", "radius://00000000-0000-4000-8000-000000000001", "--session-id", "demo-1"]), @@ -142,52 +70,29 @@ describe("experimental CLI commands", () => { }); }); - test("parses a client session ID", () => { - expect(cli.parse(["client", "--session-id", "demo-1"])).toEqual({ + test("parses a Session selection followed by a one-shot prompt", () => { + expect(cli.parse(["client", "-r", "Explain this project"])).toEqual({ ok: true, - command: { - command: "client", - sessionId: "demo-1", - }, + command: { command: "client", resume: true, prompt: "Explain this project" }, }); }); - test("parses a one-shot client prompt without a session ID", () => { - expect(cli.parse(["client", "Explain this project"])).toEqual({ - ok: true, - command: { - command: "client", - prompt: "Explain this project", - }, - }); - }); - - test("parses the model used for cold client activation", () => { - expect(cli.parse(["client", "--provider", "anthropic", "--model", "claude-sonnet-4-5"])).toEqual({ - ok: true, - command: { - command: "client", - provider: "anthropic", - model: "claude-sonnet-4-5", - }, - }); - }); - - test("parses a provider-qualified model used for cold client activation", () => { - expect(cli.parse(["client", "--model", "anthropic/claude-sonnet-4-5:high"])).toEqual({ + test("parses client model and plugin configuration", () => { + expect( + cli.parse([ + "client", + "--model", + "anthropic/claude-sonnet-4-5:high", + "-e", + "./first-plugin", + "-e", + "./second-plugin", + ]), + ).toEqual({ ok: true, command: { command: "client", model: "anthropic/claude-sonnet-4-5:high", - }, - }); - }); - - test("parses repeatable client plugin packages", () => { - expect(cli.parse(["client", "-e", "./first-plugin", "-e", "./second-plugin"])).toEqual({ - ok: true, - command: { - command: "client", pluginPackages: ["./first-plugin", "./second-plugin"], }, }); @@ -197,68 +102,26 @@ describe("experimental CLI commands", () => { [["--auth-token", "secret"], { type: "token", token: "secret" }], [["--auth-token-file", "/tmp/token"], { type: "file", path: "/tmp/token" }], ] as const)("parses authentication source %j", (argv, auth) => { - expect(cli.parse(argv)).toMatchObject({ - ok: true, - command: { command: "pi", auth }, - }); + for (const command of ["server", "client"] as const) { + expect(cli.parse([command, ...argv])).toMatchObject({ ok: true, command: { command, auth } }); + } }); - test.each([[[]], [["server"]], [["client"]]] as const)( - "permits omitted authentication for later environment/default resolution", - (argv) => { - const result = cli.parse(argv); - expect(result).toMatchObject({ ok: true, command: { command: argv[0] ?? "pi" } }); - if (result.ok) expect(result.command.auth).toBeUndefined(); - }, - ); - - test("passes unknown options, file arguments, and the positional separator to the existing parser", () => { - const result = cli.parse(["--unknown", "@prompt.md", "--", "--listen", "unix:///tmp/pi.sock"]); - expect(result).toMatchObject({ - ok: true, - command: { - command: "pi", - options: { fileArgs: ["prompt.md"], messages: ["--listen", "unix:///tmp/pi.sock"] }, - }, - }); - if (!result.ok || result.command.command !== "pi") return; - expect(result.command.options.unknownFlags).toEqual(new Map([["unknown", true]])); + test.each(["server", "client"] as const)("permits omitted authentication for %s", (command) => { + expect(cli.parse([command])).toEqual({ ok: true, command: { command } }); }); test.each([ - [ - ["--listen", "unix:///tmp/pi.sock", "--listen", "unix:///tmp/pi-admin.sock"], - "--listen may only be specified once", - ], - [ - ["--auth-token", "secret", "--auth-token-file", "/tmp/token"], - "--auth-token and --auth-token-file are mutually exclusive", - ], - [["--auth-token", "first", "--auth-token", "second"], "--auth-token may only be specified once"], - [ - ["--auth-token-file", "/tmp/first", "--auth-token-file=/tmp/second"], - "--auth-token-file may only be specified once", - ], - [["--listen", "/tmp/pi.sock"], 'Invalid --listen address "/tmp/pi.sock"'], - [["--listen", "ws://localhost:8080"], 'Unsupported --listen transport "ws:"'], - [["--listen", "unix://relative.sock"], "Unix transport address must not include an authority"], - [["--listen", "unix:///tmp/pi.sock?wrong=value"], 'Invalid --listen address "unix:///tmp/pi.sock?wrong=value"'], - [["--listen", "unix:///tmp/pi.sock#fragment"], 'Invalid --listen address "unix:///tmp/pi.sock#fragment"'], - [["--listen", "unix:/tmp/pi.sock"], 'Invalid --listen address "unix:/tmp/pi.sock"'], - [["--listen", "unix:///tmp/%00pi.sock"], 'Invalid --listen address "unix:///tmp/%00pi.sock"'], - [ - ["client", "--listen", "unix:///tmp/pi.sock"], - "The experimental client command does not support existing CLI options yet", - ], - [ - ["server", "--connect", "unix:///tmp/pi.sock"], - "The experimental server command does not support existing CLI options yet", - ], + [["client", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_CLIENT_OPTIONS], + [["server", "--listen", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS], + [["server", "--connect", "unix:///tmp/pi.sock"], UNSUPPORTED_SERVER_OPTIONS], [["client", "--connect", "ws://localhost:8080"], 'Unsupported --connect transport "ws:"'], [["client", "--connect", "radius://not-a-server"], "Radius transport address requires"], - [["server", "--listen", "radius://00000000-0000-4000-8000-000000000001"], "only valid for --connect"], + [["client", "--connect", "unix://relative.sock"], "Unix transport address must not include an authority"], + [["client", "--connect", "unix:///tmp/pi.sock?wrong=value"], "Invalid --connect address"], [["client", "--provider", "anthropic"], "--provider requires --model"], [["client", "-c", "-r"], "--session-id, --continue, and --resume are mutually exclusive"], + [["client", "--continue=true"], "--continue does not take a value"], [["server", "--provider", "anthropic"], "--provider requires --model"], [["server", "--server-id", "not-a-uuid"], "Invalid --server-id"], [["server", "--server-id"], "--server-id requires a value"], @@ -268,35 +131,17 @@ describe("experimental CLI commands", () => { ["server", "--session-dir", "/tmp/first", "--session-dir=/tmp/second"], "--session-dir may only be specified once", ], - [["--listen"], "--listen requires a value"], - [["--connect="], "--connect is only valid for client mode"], + [["client", "--connect="], "--connect requires a value"], ] as const)("rejects invalid experimental input %j", (argv, error) => { const result = cli.parse(argv); expect(result).toMatchObject({ ok: false }); if (!result.ok) expect(result.errors).toContainEqual(expect.stringContaining(error)); }); - test("rejects unsupported options without parsing them", () => { - expect( - cli.parse([ - "client", - "--listen", - "ws://localhost:8080", - "--auth-token", - "secret", - "--auth-token-file", - "/tmp/token", - ]), - ).toEqual({ + test("rejects unsupported options without parsing them through the stable CLI", () => { + expect(cli.parse(["client", "--tui-mode", "wrong", "--model", "claude-sonnet"])).toEqual({ ok: false, - errors: ["The experimental client command does not support existing CLI options yet"], - }); - }); - - test("treats command names after the first argument as existing CLI arguments", () => { - expect(cli.parse(["--cwd", "/workspace", "server"])).toMatchObject({ - ok: true, - command: { command: "pi", options: { messages: ["server"] } }, + errors: [UNSUPPORTED_CLIENT_OPTIONS], }); }); }); diff --git a/packages/coding-agent/test/experimental-cli-resolution.test.ts b/packages/coding-agent/test/experimental-cli-resolution.test.ts index 66a99b9f6..985b7e5f7 100644 --- a/packages/coding-agent/test/experimental-cli-resolution.test.ts +++ b/packages/coding-agent/test/experimental-cli-resolution.test.ts @@ -5,41 +5,10 @@ const UNSUPPORTED_SERVER_OPTIONS = "The experimental server command does not sup const UNSUPPORTED_CLIENT_OPTIONS = "The experimental client command does not support existing CLI options yet"; describe("experimental CLI command composition", () => { - test("composes pi command options with the existing parser", () => { - const result = cli.parse([ - "--listen", - "unix:///tmp/pi.sock", - "--auth-token", - "secret", - "--provider", - "anthropic", - "--model", - "claude-sonnet", - "--thinking", - "high", - "inspect", - ]); - - expect(result).toMatchObject({ - ok: true, - command: { - command: "pi", - listen: [{ transport: "unix", path: "/tmp/pi.sock" }], - auth: { type: "token", token: "secret" }, - options: { - provider: "anthropic", - model: "claude-sonnet", - thinking: "high", - messages: ["inspect"], - }, - }, - }); - }); - - test.each(["--help", "--version"] as const)("keeps Pi %s handling in existing CLI options", (option) => { - expect(cli.parse([option])).toMatchObject({ - ok: true, - command: { command: "pi", options: { [option === "--help" ? "help" : "version"]: true } }, + test("requires an experimental subcommand", () => { + expect(cli.parse([])).toEqual({ + ok: false, + errors: ["Expected experimental command: server or client"], }); }); @@ -48,42 +17,10 @@ describe("experimental CLI command composition", () => { ["server", "--version", UNSUPPORTED_SERVER_OPTIONS], ["client", "--help", UNSUPPORTED_CLIENT_OPTIONS], ["client", "--version", UNSUPPORTED_CLIENT_OPTIONS], - ] as const)("rejects deferred %s %s handling", (command, option, error) => { + ] as const)("rejects unsupported %s %s handling", (command, option, error) => { expect(cli.parse([command, option])).toEqual({ ok: false, errors: [error] }); }); - test("accepts provider and model options for the server command", () => { - expect(cli.parse(["server", "--provider", "anthropic", "--model", "claude-sonnet-4-5"])).toEqual({ - ok: true, - command: { - command: "server", - provider: "anthropic", - model: "claude-sonnet-4-5", - }, - }); - }); - - test("rejects existing options that the client command does not support yet", () => { - expect(cli.parse(["client", "--tui-mode", "fullscreen", "@prompt.md"])).toEqual({ - ok: false, - errors: [UNSUPPORTED_CLIENT_OPTIONS], - }); - }); - - test("reports existing parser errors before capability errors", () => { - expect(cli.parse(["client", "--tui-mode", "wrong", "--model", "claude-sonnet"])).toEqual({ - ok: false, - errors: ['Invalid TUI mode "wrong". Valid values: regular, fullscreen', UNSUPPORTED_CLIENT_OPTIONS], - }); - }); - - test("parses an empty server command", () => { - expect(cli.parse(["server"])).toEqual({ - ok: true, - command: { command: "server" }, - }); - }); - test("passes server options to the command action", async () => { const runServer = vi.fn(() => undefined); const result = await cli.execute( @@ -98,11 +35,7 @@ describe("experimental CLI command composition", () => { "--model", "claude-sonnet-4-5", ], - { - runPi: vi.fn(() => undefined), - runServer, - runClient: vi.fn(() => undefined), - }, + { runServer, runClient: vi.fn(() => undefined) }, ); const command = { @@ -116,16 +49,14 @@ describe("experimental CLI command composition", () => { expect(runServer).toHaveBeenCalledWith(command); }); - test.each(["pi", "server", "client"] as const)("executes the parsed %s command", async (name) => { + test.each(["server", "client"] as const)("executes the parsed %s command", async (name) => { const context = { - runPi: vi.fn(() => undefined), runServer: vi.fn(() => undefined), runClient: vi.fn(() => undefined), }; - const result = await cli.execute(name === "pi" ? [] : [name], context); + const result = await cli.execute([name], context); - expect(result).toMatchObject({ ok: true, command: { command: name } }); - expect(context.runPi).toHaveBeenCalledTimes(name === "pi" ? 1 : 0); + expect(result).toEqual({ ok: true, command: { command: name } }); expect(context.runServer).toHaveBeenCalledTimes(name === "server" ? 1 : 0); expect(context.runClient).toHaveBeenCalledTimes(name === "client" ? 1 : 0); }); diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 8800f0b97..1e65db2aa 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -333,19 +333,9 @@ describe("experimental client TUI", () => { expect(component.render(80).join("\n")).not.toContain("Experimental Sessions"); expect(component.render(80).join("\n")).not.toContain("Experimental Models"); - component.handleInput("/"); - await vi.waitFor(() => { - const rendered = component.render(80).join("\n"); - expect(rendered).toContain("Select model"); - expect(rendered).toContain("Set thinking level"); - expect(rendered).toContain("Send a greeting from the example plugin"); - }); component.handleInput("hello"); - component.handleInput("\u001b"); component.handleInput("\r"); - await vi.waitFor(() => - expect(prompt).toHaveBeenCalledWith("Hello from the example plugin", undefined, BACKGROUND_CONTEXT), - ); + await vi.waitFor(() => expect(prompt).toHaveBeenCalledWith("hello", undefined, BACKGROUND_CONTEXT)); await vi.waitFor(() => expect(component.render(80).join("\n")).toContain("remote answer")); expect(component.render(80).join("\n")).toContain("hello"); expect(component.render(80).join("\n")).not.toContain("Operation run-1 completed"); diff --git a/packages/coding-agent/test/experimental-remote-runtime.test.ts b/packages/coding-agent/test/experimental-remote-runtime.test.ts index a02a3f3e6..3c3b48c81 100644 --- a/packages/coding-agent/test/experimental-remote-runtime.test.ts +++ b/packages/coding-agent/test/experimental-remote-runtime.test.ts @@ -21,7 +21,6 @@ import { import { Models } from "../src/experimental/services/models.ts"; import { PresentationPlugins } from "../src/experimental/services/plugins.ts"; import { SessionDirectory, SessionManagement } from "../src/experimental/services/sessions.ts"; -import { Transcript } from "../src/experimental/services/transcript.ts"; import { configureExperimentalWorkerModel, createExperimentalSessions, @@ -646,19 +645,6 @@ describe("experimental durable server composition", () => { expect(errors).toEqual([]); }); - 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 = createSessionServiceBinding(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 facet-service slice", - }); - await services.dispose(BACKGROUND_CONTEXT); - }); - test("observes keyed service instances and fences replacement generations over framed transport", async ({ onTestFinished, }) => { diff --git a/packages/coding-agent/test/experimental-service-stubs.test.ts b/packages/coding-agent/test/experimental-service-stubs.test.ts deleted file mode 100644 index dbf8bdf67..000000000 --- a/packages/coding-agent/test/experimental-service-stubs.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createFacetHost } from "@earendil-works/chord"; -import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; -import { describe, expect, test } from "vitest"; -import { - accountsServiceFacet, - ServiceSliceNotImplemented, - transcriptServiceFacet, -} from "../src/experimental/services/stubs-provider.ts"; - -describe("experimental built-in service surface", () => { - test("exposes later singleton slices as explicit unimplemented providers", async () => { - const host = await createFacetHost({ facets: [accountsServiceFacet, transcriptServiceFacet] }); - try { - const accounts = host.services.subscribe("pi.accounts", "singleton", () => {}).snapshot; - expect(accounts.instances[0]?.members).toContainEqual({ - name: "state", - kind: "state", - sequence: 0, - value: { providers: [] }, - }); - await expect( - host.services.invoke( - { serviceId: "pi.accounts", member: "remove", args: ["anthropic"] }, - BACKGROUND_CONTEXT, - ), - ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); - - await expect( - host.services.invoke({ serviceId: "pi.transcript", member: "snapshot", args: [] }, BACKGROUND_CONTEXT), - ).rejects.toBeInstanceOf(ServiceSliceNotImplemented); - } finally { - await host.dispose(); - } - }); -}); diff --git a/packages/coding-agent/test/experimental-slash-commands.test.ts b/packages/coding-agent/test/experimental-slash-commands.test.ts index 1677c5163..9e353cebe 100644 --- a/packages/coding-agent/test/experimental-slash-commands.test.ts +++ b/packages/coding-agent/test/experimental-slash-commands.test.ts @@ -27,7 +27,7 @@ describe("experimental slash command facets", () => { facets: [ createSlashCommandsRuntimeFacet(registry), defineFacet({ - id: "@pi/example-hello", + id: "@test/example-hello", setup(env) { const commands = env.use(SlashCommands); env.onActivate(() => env.own(commands.register({ name: "hello", run: () => undefined }))); @@ -40,7 +40,7 @@ describe("experimental slash command facets", () => { const replacementRun = vi.fn(); await host.reload([ defineFacet({ - id: "@pi/example-hello", + id: "@test/example-hello", setup(env) { const commands = env.use(SlashCommands); env.onActivate(() =>