From e22b67a893b264a33df17ab32537673412a669f4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 05:20:08 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Run=20the=20packaged=20Plan=20in=20?= =?UTF-8?q?`xmd=20repl`,=20under=20one=20immutable=20profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xmd repl` took a location and nothing else, so an Agent had no way to reach a REPL entry and `` had no way to run in one. It now reads the same five Agent options `xmd run` reads, as a first-class Plugin command of its own — selected Plugins are told `repl`, and the bundled Git Plugin declares for it, because a REPL entry is a document run under the ordinary profile — one declaration, so the two commands cannot come to mean different things by the same spelling — and settles them in a fixed order: the line, then the Agent configuration, then one profile, then the terminal. Everything a wrong invocation can be refused for is refused before a per-user directory is formed, a history file exists or the terminal's modes are touched. What a REPL execution runs under is one immutable `ReplExecutionProfile`, assembled once in the command's own scope: the selected Plugins, the Agent identity components, the real packaged `` declaration and the ordinary evaluation ceiling, with the settled permission mode beside them. The program takes that one value and nothing else, so what an entry may resolve, what ceiling a generated fragment runs under and how a permission request is answered are facts about the command a person invoked rather than about the entry they typed. It carries data and installations and never authority: no stack, provider, Plan writer, live request or scope reaches the model, the route or the Journal through it. The REPL installs no readline permission handling, no foreground launcher and no browser form — a question is answered in the drawer in front of the person, and `` refuses through the established missing-launcher contract because this command is using the terminal it would be given. A returned Plan is a generated fragment, and a fragment that may write may now also ask: canonical `` joins the ordinary write table at core's own origin, key and revision, paired only, as a *pinned capability* rather than a name the fragment resolves. Preflight selects it where it finds an occurrence under a `write` selection, and the body it selects is core's own ``, closed over before any document code runs — exactly as the body behind `` is. Nothing resolves the name: not before the root import, not at invocation, and middleware answering a generated import with a body of its own is refused, because only canonical execution answers one. So a same-name repository, registered, declared, middleware or separately loaded `Elicit` receives no grant and never runs, including the workflow host's own suspension replacement, which goes on answering the *authored* element in a run exactly as it did. An execution whose fragment never writes the element resolves nothing at all, which is why admitting the entry cannot break a document that only writes a ``. `allow={["read"]}` still refuses it, because a read selection promises nobody will be interrupted. What stays contextual is the interaction and only the interaction: the pinned body asks through the Elicitation Api lexically in scope, so the REPL drawer answers a generated question and an enclosing `` region answers it without anybody being asked. Its answer is retained by the ordinary `elicit` operation, and replay restores it without asking anyone. The `generated_xmd` record keeps the identity and the form and nothing about the answer. One defect the journey found is fixed here. The model owned nothing the packaged Plan did: an import's retained selection says where its scope's source came from, and for a component the host *declared* that is the retained origin and the retained bytes rather than a path, which the reader did not know. Every turn and question inside `Plan.md` therefore belonged to nothing, so a live screen quietly kept its last good model and a cold one refused the whole history. Both are read from the Journal — no declaration lookup, no file read, no fallback owner — and a source this prefix never admitted, or admitted twice, is still refused rather than attached to a guess. The program a Plan returns has no file at all, so a position gains a second source it can name. `SourcePosition` takes an optional `generatedSource`: the id the fragment was admitted under, which whole-fragment preflight stamps on every executable position in the candidate text it scans, before the fragment performs anything. A position names one source — a path, that id, or neither for dynamic text — and both together is malformed wherever a reader parses one. The member travels by value through the scanner, expansion snapshots and element sites, the durable source description, component-resolution copies and Workflow history. That identity is what owns the work inside a fragment. The effect names its fragment; journal order and coroutine ancestry only say whether the admission it names could be its — one that happened afterwards, or on work the effect is not part of, owns nothing however recently it ran, and ancestry is by coroutine segment, so `root.1` encloses `root.1.0` and has nothing to do with `root.10`. Two sequential fragments are sibling scopes even though their effects collide in name, line and column; a fragment's spawned descendants belong to it; concurrent siblings are independent of which settled first; and every way a history names no one fragment — never admitted, admitted only afterwards, admitted twice, admitted elsewhere, refused, unreadable, or naming no source at all — refuses whole, with no partial model. Evidence is `packages/cli/tests/repl-agent-journey.test.ts`, which drives `runReplProgram()` over a real Journal, a mounted Freedom tree, the real renderer and a terminal the suite writes bytes to. J1 walks the published Story: the real packaged Plan, its review in the REPL's own drawer, a refused revision that appends no answer and starts no turn, feedback resuming the same conversation, and an approval that admits the returned source byte for byte — then the generated program's two questions, the preview it showed, the one README it wrote after confirmation and the decline path that writes nothing. J2 runs three `` conversations and holds them complete, streaming and queued in one frame. J3 reruns that whole journey and reopens it in a fresh command scope — the Plan's scope, the program it returned, both retained turns, both reviews, both of the program's own questions, the README result and the same History positions — with zero provider calls and a byte-identical history, and restores a failed and a cancelled turn with the text each had. C1 is the command line, and X1 is EOF, a lost renderer, a lost terminal and a cancelled scope — each joining what it owned, appending nothing after, and giving the modes back once. One measured limit is reported rather than papered over: while a turn is still queued it has no provider conversation key yet, so the Sessions surface offers fewer selectors than there are children until it starts. --- architecture.md | 54 + packages/cli/src/agent-stack.ts | 14 +- packages/cli/src/cli.ts | 147 +- packages/cli/src/evaluation-profile.ts | 18 +- packages/cli/src/plugin-selection.ts | 6 + packages/cli/src/repl-profile.ts | 133 ++ packages/cli/src/repl/model.ts | 303 ++- packages/cli/src/repl/program.ts | 52 +- packages/cli/tests/cli-help.test.ts | 65 + .../cli/tests/plan-command-document.test.ts | 54 +- packages/cli/tests/plugin-selection.test.ts | 18 +- .../cli/tests/repl-agent-interface.test.ts | 33 +- packages/cli/tests/repl-agent-journey.test.ts | 1869 +++++++++++++++++ packages/cli/tests/repl-boundaries.test.ts | 106 +- packages/cli/tests/repl-journey.test.ts | 140 +- packages/cli/tests/repl-model.test.ts | 426 ++++ packages/cli/tests/support/fake-acp.ts | 19 + packages/core/host.ts | 1 + packages/core/src/components/Elicit.ts | 8 +- .../src/components/component-resolution.ts | 3 + packages/core/src/components/registry.ts | 27 +- packages/core/src/evaluation-profile.ts | 40 +- packages/core/src/execute.ts | 18 +- packages/core/src/expansion.ts | 8 +- packages/core/src/fragment-capabilities.ts | 49 +- packages/core/src/generated-xmd.ts | 15 +- packages/core/src/scanner.ts | 10 +- packages/core/src/source-position.ts | 6 +- packages/core/src/types.ts | 14 + .../core/tests/evaluate-component.test.ts | 383 ++++ .../core/tests/evaluation-profile.test.ts | 49 + .../core/tests/expansion-identity.test.ts | 42 + packages/core/tests/generated-xmd.test.ts | 71 + packages/core/tests/source-position.test.ts | 61 + packages/git/src/plugin.ts | 6 +- packages/git/tests/plugin.test.ts | 14 +- packages/workflow/src/lifecycle/history.ts | 17 +- .../tests/generated-agent-component.test.ts | 32 +- .../workflow-lifecycle-inspection.test.ts | 74 + specs/acp-client-spec.md | 9 +- specs/executable-mdx-spec.md | 70 +- specs/repl-spec.md | 211 +- 42 files changed, 4407 insertions(+), 288 deletions(-) create mode 100644 packages/cli/src/repl-profile.ts create mode 100644 packages/cli/tests/repl-agent-journey.test.ts diff --git a/architecture.md b/architecture.md index 1c13327da..218edb6f1 100644 --- a/architecture.md +++ b/architecture.md @@ -5640,6 +5640,60 @@ that leaves no record for anything else to observe. directory, identifier and filesystem operations; nothing under `repl/` names a runtime. +### What one REPL command settles before it draws + +The command line is read, the Agent configuration is settled, and then one +immutable profile is assembled in the command's own scope: the selected Plugins, +the Agent identity components, the packaged `` declaration and the ordinary +evaluation ceiling, with the settled permission mode beside them. The program +takes that one profile and nothing else, so what an entry may resolve, what +ceiling a generated fragment runs under and how a permission request is answered +are facts about the command a person invoked rather than about the entry they +typed. The profile carries data and installations, never authority: no stack, +provider, Plan writer, live request or scope reaches the application model, the +route or the Journal through it. + +The `` the REPL declares is the packaged Component `xmd plan` runs, and the +ceiling around a returned program is the shared ordinary profile — whose write +table holds paired canonical `` beside `` and ``, so a +generated program may ask before it writes. That entry is a pinned capability +rather than a name the fragment resolves: preflight selects it where an +`` occurrence appears under a `write` selection, and the body it selects +is core's own ``, closed over before any document code runs. No component +name is resolved for it, so a same-name repository, registered, declared, +middleware or separately loaded definition inherits no grant and never runs, and +an execution that never writes the element resolves nothing. Only the interaction +is contextual: the pinned body asks through the Elicitation Api lexically in +scope, which is what lets the REPL drawer answer a generated question and an +`` region answer it without anybody being asked. + +Permission composes outermost-first: Core's own audit observer, then this +session's authoritative policy, then whatever the document and provider +installed, then the base denial. One private ledger per inherited Prompt scope +holds what was granted, and a retained audit is read rather than answered. The +live overlay, the application and the terminal have one owner each — the session, +the root transition boundary and the screen — and every ending cancels and joins +that owner rather than appending on its way out. + +Ownership in the model is read from the Journal and from nothing else. An +import's retained selection says where its scope's source came from — a path for +a component read from somewhere, the retained origin for one the host declared — +and that origin is the path the effects inside it are recorded against. A +generated fragment has no file, so the work inside it names the admission it was +decided under, and the scope that admission created is what owns it: the +identity chooses the candidate, and journal order and coroutine ancestry only +say whether that candidate could be its owner — an admission that happened afterwards, +or on work the effect is not part of, owns nothing however recently it ran. Two +sequential fragments are therefore sibling scopes, work in a fragment's spawned +descendants belongs to that fragment, and concurrent siblings are independent of +which finished first. A record naming a source this prefix never admitted, one it +admitted twice, or two sources at once is refused rather than attached to a +guessed owner. + +This command installs no foreground launcher, no browser elicitation and no +readline permission handling, reads no runtime global, and adds no durable record +family of its own: what it retains is the ordinary events any other run writes. + ## Changing these rules Spec, tests, and mechanics move together, in the same PR. If a workaround diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index e6e63609c..477c938d6 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -163,9 +163,19 @@ export function hostAcpDependencies(stack: PlanWriterStack): AcpxProviderDepende * * Nothing here starts an agent. The provider validates availability on first * use, and an embedded adapter reaches the disk at that same point. + * + * `acp` is the seam `planAgentContext` already takes, for the same reason and on + * the same terms: production states none and gets what this host carries, while + * a journey states a scriptable runtime so it can drive this exact provider + * stack rather than a copy of it. It reaches the subprocess boundary and nothing + * above it — the provider, the components, the policy and the record are the + * product's own. */ -export function* installAgentProviderStack(stack: AgentStack): Operation { - const acpx = createAcpxProvider(hostAcpDependencies(stack)); +export function* installAgentProviderStack( + stack: AgentStack, + acp?: AcpxProviderDependencies, +): Operation { + const acpx = createAcpxProvider(acp ?? hostAcpDependencies(stack)); yield* registerAgentProvider("acpx", acpx); // The trusted host selects its own root provider by name. Document-level diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 6a5b26ffc..ea454e049 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -107,6 +107,7 @@ import { resolvePlanWriterStack, } from "./agent-stack.ts"; import { planComponentDeclaration } from "./plan-component.ts"; +import { assembleReplProfile } from "./repl-profile.ts"; import { planAgentContext } from "./plan-writer-profile.ts"; import { useVerboseComponent } from "./verbose-component.ts"; import type { AgentStack } from "./agent-stack.ts"; @@ -208,6 +209,41 @@ const SECRET_DETECTION_FIELD = { * a journal, a permission mode, an exec deadline or a presentation option would * each configure work this command never performs. */ +/** + * The Agent configuration two commands share. + * + * Declared once and spread into both, because `xmd run` and `xmd repl` configure + * the same thing: which provider answers, which agent a `` means by + * default, and how a permission request is decided. A second table would be two + * descriptions of one option, free to drift in wording, default or precedence — + * and a person reading either `--help` would have no way to tell which was true. + * + * `xmd test` refuses all five, because its agents are the deterministic + * `` stack, and `xmd plan` accepts only the two that say who writes. + */ +const agentFields = { + agentProvider: { + description: "agent provider for agent components", + ...field(z.string(), field.default("acpx")), + }, + defaultAgent: { + description: "default agent name (overrides DEFAULT_AGENT_NAME)", + ...field(z.string().optional()), + }, + approveAll: { + description: "approve every agent permission request", + ...field(z.boolean(), field.default(false)), + }, + approveReads: { + description: "approve read and search agent permissions, ask for the rest (default)", + ...field(z.boolean(), field.default(false)), + }, + denyAll: { + description: "deny every agent permission request", + ...field(z.boolean(), field.default(false)), + }, +}; + const executionFields = { include: { description: "component search directory", @@ -227,14 +263,7 @@ const executionFields = { description: "output raw markdown without normalization or terminal formatting", ...field(z.boolean(), field.default(false)), }, - agentProvider: { - description: "agent provider for agent components", - ...field(z.string(), field.default("acpx")), - }, - defaultAgent: { - description: "default agent name (overrides DEFAULT_AGENT_NAME)", - ...field(z.string().optional()), - }, + ...agentFields, timeout: { description: "deadline for the whole run, as a duration (500ms, 30s, 5min)", ...field(z.string().optional()), @@ -247,18 +276,6 @@ const executionFields = { description: "default timeout for each fetch, as a duration (500ms, 30s, 5min)", ...field(z.string().optional()), }, - approveAll: { - description: "approve every agent permission request", - ...field(z.boolean(), field.default(false)), - }, - approveReads: { - description: "approve read and search agent permissions, ask for the rest (default)", - ...field(z.boolean(), field.default(false)), - }, - denyAll: { - description: "deny every agent permission request", - ...field(z.boolean(), field.default(false)), - }, secretDetection: SECRET_DETECTION_FIELD, }; @@ -290,10 +307,12 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te * the command reopens exactly that retained history and selects exactly what the * location names; with none, it starts a fresh execution with an empty draft. * - * No option a run configures appears here. This command renders no file, takes - * no document reference and spends no model turn beyond what the one entry a - * person types asks for, so a permission mode, an exec deadline, a props file - * and a journal each configure work this command never performs. + * The entry a person types may run Agent work, so this command configures the + * Agent the same way `xmd run` does: the five shared fields, with the same + * descriptions, defaults and environment precedence. Everything else a run + * configures is absent — this command renders no file, takes no document + * reference and writes no journal of its own, so an exec deadline, a props file + * and a trace each configure work it never performs. */ const replConfig = object({ location: { @@ -302,6 +321,7 @@ const replConfig = object({ "that retained history and the exact view it names", ...field(z.string().optional(), cli.argument()), }, + ...agentFields, }); /** What `xmd --help` says the plan command is for. */ @@ -588,6 +608,12 @@ export type ReplHostInstaller = () => Operation; * ignored — a caller who wrote `--json` asked for something, and silence would * let them believe they got it. */ +/** The two REPL options that carry a value, by the spelling a caller writes. */ +const REPL_VALUES: readonly string[] = ["--agent-provider", "--default-agent"]; + +/** The three REPL switches, which carry none. */ +const REPL_SWITCHES: readonly string[] = ["--approve-all", "--approve-reads", "--deny-all"]; + export function replGrammarError( args: readonly string[], location: string | undefined, @@ -595,14 +621,47 @@ export function replGrammarError( // The command's own name is the first token, as it is for every command that // reads its line directly. const rest = args.slice(1); - const option = rest.find((token) => token.startsWith("-") && token !== "-"); - if (option !== undefined) { + const positional: string[] = []; + for (let at = 0; at < rest.length; at += 1) { + const token = rest[at] ?? ""; + if (!token.startsWith("-") || token === "-") { + positional.push(token); + continue; + } + // `--name=value` and `--name value` are both the parser's forms, so the scan + // reads the name the same way the parser will. + const split = token.indexOf("="); + const name = split === -1 ? token : token.slice(0, split); + const written = split === -1 ? undefined : token.slice(split + 1); + if (REPL_SWITCHES.includes(name)) { + if (written !== undefined) { + return `xmd repl: ${name} is a switch and takes no value`; + } + continue; + } + if (REPL_VALUES.includes(name)) { + if (written !== undefined) { + if (written.length === 0) { + return `xmd repl: ${name} requires a value`; + } + continue; + } + // The next token is this option's value, not a location: a scan that + // counted it as one would refuse `--default-agent claude` for naming two + // things. + const value = rest[at + 1]; + if (value === undefined || value.startsWith("-")) { + return `xmd repl: ${name} requires a value`; + } + at += 1; + continue; + } return ( - `unrecognized option for xmd repl: ${option} — xmd repl takes one optional location ` + - "and no options" + `unrecognized option for xmd repl: ${name} — xmd repl takes one optional location and ` + + "the agent options --agent-provider, --default-agent, --approve-all, --approve-reads " + + "and --deny-all" ); } - const positional = rest.filter((token) => !token.startsWith("-")); if (positional.length > 1) { return ( "xmd repl takes at most one location. This REPL admits one entry per execution, so " + @@ -2835,6 +2894,23 @@ function* dispatch( yield* exit(1); break; } + // The Agent configuration, settled before a terminal exists: mutual + // exclusion, an unknown provider and `DEFAULT_AGENT_NAME` precedence are + // all decided here, so an invocation nobody could run refuses without + // having taken the screen, made a directory or materialized an adapter. + const replStack = yield* settleAgentStack( + { + agentProvider: command.config.agentProvider, + defaultAgent: command.config.defaultAgent, + approveAll: command.config.approveAll, + approveReads: command.config.approveReads, + denyAll: command.config.denyAll, + }, + sessions, + ); + if (replStack === undefined) { + break; + } if (installRepl === undefined) { console.error( "xmd repl: this host assembles no interactive terminal, so there is nothing to open.", @@ -2842,10 +2918,17 @@ function* dispatch( yield* exit(1); break; } + // One profile for the whole command, assembled once in this scope: the + // packaged `` Component, the selected Plugins, the Agent identity + // vocabulary and the ceiling a generated fragment runs under. Nothing is + // assembled again per entry, so two entries of one session cannot run + // under different rules. + const profile = yield* assembleReplProfile(replStack, plugins); yield* installRepl(); - const ran = yield* runReplProgram( - command.config.location === undefined ? {} : { location: command.config.location }, - ); + const ran = yield* runReplProgram({ + profile, + ...(command.config.location === undefined ? {} : { location: command.config.location }), + }); if (!ran.ok) { console.error(`xmd repl: ${ran.error.message}`); yield* exit(1); diff --git a/packages/cli/src/evaluation-profile.ts b/packages/cli/src/evaluation-profile.ts index fc905eeb2..deadf5cb0 100644 --- a/packages/cli/src/evaluation-profile.ts +++ b/packages/cli/src/evaluation-profile.ts @@ -9,11 +9,16 @@ * ## The two tables * * `read` is core's self-closing ``, its self-closing `` and - * canonical ``. `write` is core's paired `…` and - * self-closing ``. A fragment run by `xmd run` therefore reaches - * the caller's own filesystem through the Files provider this command - * installed, and reaches nothing else at all: no network read, no process, no - * repository, no Git, no credential and no agent. + * canonical ``. `write` is core's paired `…`, its + * self-closing `` and paired canonical ``: a fragment + * that may change something may also ask the person first, which is what makes + * "preview this, then confirm it" a program an Agent can write. A `read` + * selection admits no question — it promises nobody will be interrupted. + * + * A fragment run by `xmd run` therefore reaches the caller's own filesystem + * through the Files provider this command installed, and the person through the + * Elicitation provider the run already has. It reaches nothing else at all: no + * network read, no process, no repository, no Git, no credential and no agent. * * Reading and writing stay separate spellings of one name. A selection of * `read` admits `` and not `…`, so a fragment that asks to @@ -40,6 +45,7 @@ */ import { + elicitWriteEntry, fileDeleteEntry, fileReadEntry, fileWriteEntry, @@ -92,7 +98,7 @@ function ordinaryFiles(): FragmentFileAccess { export function ordinaryEvaluationProfile(): FragmentEvaluationInput { return { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], - write: [fileWriteEntry(), fileDeleteEntry()], + write: [fileWriteEntry(), fileDeleteEntry(), elicitWriteEntry()], files: ordinaryFiles(), }; } diff --git a/packages/cli/src/plugin-selection.ts b/packages/cli/src/plugin-selection.ts index 8bce36b04..37c3336ef 100644 --- a/packages/cli/src/plugin-selection.ts +++ b/packages/cli/src/plugin-selection.ts @@ -25,6 +25,12 @@ const COMMANDS: ReadonlySet = new Set([ "syntax", "upgrade", "workflow", + // `repl` runs a document, so a Plugin has to be told it is the command that + // did. Left out, a REPL invocation normalizes to `run` and its own name + // arrives as a positional — which tells every Plugin that something it was + // written for is happening when it is not, and tells none of them that the + // REPL is. + "repl", ]); /** diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts new file mode 100644 index 000000000..532edfc7e --- /dev/null +++ b/packages/cli/src/repl-profile.ts @@ -0,0 +1,133 @@ +/** + * The one immutable profile a REPL execution runs under. + * + * Assembled once, in the command's own scope, before a terminal is opened or a + * history file exists — and then read and never changed. What a REPL execution + * may resolve, what ceiling a generated fragment runs under and how permission + * requests are answered are facts about the command a person invoked, not about + * the entry they later type: a profile assembled per entry could differ between + * two entries of one session, and there is only ever one. + * + * It carries data and installations, never authority. No Agent stack, provider, + * Plan writer, live request or scope reaches the application model, the route or + * the Journal through it. + */ + +import type { AcpxProviderDependencies } from "@executablemd/acp"; +import { agentIdentityComponents } from "@executablemd/core"; +import type { PermissionMode } from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { useScope } from "effection"; +import type { Operation } from "effection"; + +import { installAgentProviderStack } from "./agent-stack.ts"; +import type { AgentStack } from "./agent-stack.ts"; +import { ordinaryEvaluationProfile } from "./evaluation-profile.ts"; +import { planComponentDeclaration } from "./plan-component.ts"; +import { planAgentContext } from "./plan-writer-profile.ts"; +import type { CommandPlugins } from "./plugin-host.ts"; + +/** + * Where a REPL entry looks for components. + * + * Fixed, because this command takes no include option: an entry is typed rather + * than named, so there is no document beside which a caller could have meant + * something else. + */ +const REPL_INCLUDES: readonly string[] = Object.freeze(["components", "."]); + +/** + * The two facts a proof states and production leaves to the host. + * + * `planAgentContext` and `installAgentProviderStack` already take the first: + * production states none and both get the real ACPX runtime, while a journey + * states a scriptable one so it can drive this exact assembler and this exact + * provider stack rather than copies of them. The second is where the Plan writer + * keeps its conversations, which production leaves at the host default and a + * proof points at a directory it created itself — a suite that used the default + * would read and remove directories under the developer's own home. + */ +export interface ReplProfileSeams { + readonly acp?: AcpxProviderDependencies; + readonly planWriterRoot?: string; +} + +/** What one REPL execution runs under, decided once and read from then on. */ +export interface ReplExecutionProfile { + /** Where a declared name is looked for, outermost first. */ + readonly includes: readonly string[]; + /** + * What this command declares to every execution it opens. + * + * In installation order: the selected Plugins as they were assembled, then + * this command's own vocabulary — the Agent identity components, the packaged + * `` Component, and the ceiling a generated fragment runs under. + */ + readonly installations: readonly ExecutionInstallation[]; + /** How this REPL answers an Agent permission request. */ + readonly permissionMode: PermissionMode; +} + +/** + * Assemble the profile this command's REPL runs under. + * + * Runs in the command scope, which is what the packaged `` Component + * captures as its host: putting this build's adapter on disk is the host's act, + * and it happens outside the frame the Component installs around itself. + * + * The provider half of the Agent stack is installed here, exactly once, for the + * whole command. The REPL's own policy is the session's — this installs no + * readline permission handling, no foreground launcher and no browser form, so a + * `` refuses through the established missing-launcher contract + * and `` is answered by the drawer a person is looking at. + */ +export function* assembleReplProfile( + stack: AgentStack, + plugins: CommandPlugins, + seams: ReplProfileSeams = {}, +): Operation { + yield* installAgentProviderStack(stack, seams.acp); + + const plan = yield* planComponentDeclaration({ + surface: "component", + ...(seams.planWriterRoot === undefined ? {} : { planWriterRoot: seams.planWriterRoot }), + includes: REPL_INCLUDES, + // The command's own Plugin assembly, not a second installation of it: two + // would be two catalogs claiming the same names. + plugins, + // The same seam `planAgentContext` already takes: production states none and + // gets the real ACPX runtime, and a proof states a scriptable one so a + // journey can drive this exact assembler rather than a copy of it. + context: planAgentContext(stack, seams.acp), + ...(stack.sessions === undefined ? {} : { sessions: stack.sessions }), + host: yield* useScope(), + // Plan review is answered by the REPL's own Elicit provider, which the + // session installs around the execution. A Component that installed one of + // its own here would answer the question in front of the person with + // something else. + installElicitation: noElicitationOfItsOwn, + }); + + return Object.freeze({ + includes: REPL_INCLUDES, + installations: Object.freeze([ + ...plugins.installations, + Object.freeze({ + components: agentIdentityComponents(), + declarations: Object.freeze([plan]), + evaluation: ordinaryEvaluationProfile(), + }), + ]), + permissionMode: stack.permissionMode, + }); +} + +/** + * The Plan Component's elicitation installer, which installs nothing. + * + * Named rather than inline so what it does is stated where it is read: the + * enclosing REPL owns the question, and a provider installed here would be a + * second answerer inside the one that is already asking. + */ +// deno-lint-ignore require-yield +function* noElicitationOfItsOwn(): Operation {} diff --git a/packages/cli/src/repl/model.ts b/packages/cli/src/repl/model.ts index 873a2b802..218b8bf6e 100644 --- a/packages/cli/src/repl/model.ts +++ b/packages/cli/src/repl/model.ts @@ -64,6 +64,14 @@ export class ReplProjectionError extends Error { /** Where an authored element was written, as the journal recorded it. */ export interface ReplPosition { readonly path: string | undefined; + /** + * The generated fragment this position belongs to, when it belongs to one. + * + * Closed against `path`: an effect inside admitted generated source names the + * admission that decided that source, because generated text has no file for + * it to name. A record carrying both is malformed. + */ + readonly generatedSource: string | undefined; readonly offset: number; readonly line: number; readonly column: number; @@ -401,6 +409,16 @@ function build( let terminal: ReplTerminal | undefined; /** Every scope by the source path it was admitted from, for owner lookup. */ const byPath = new Map(); + /** Every generated fragment this prefix admitted, by the identity it carries. */ + const fragments: GeneratedOwner[] = []; + /** + * Every identity the whole prefix admits, read before anything is projected. + * + * Only so that a history recording work *before* the admission that names it + * can be told from one that never admitted it at all: both refuse, and a + * reader repairing a history needs to know which of the two they have. + */ + const announced = admittedIdentities(events); const occurrences = new Map(); for (let index = 0; index < events.length; index++) { @@ -518,7 +536,14 @@ function build( }); continue; } - const owner = ownerOf(byPath, position, `<${description.name} />`); + const owner = ownerOf( + byPath, + fragments, + announced, + position, + site(event, index), + `<${description.name} />`, + ); if (!owner.ok) { return owner; } @@ -564,7 +589,14 @@ function build( ), ); } - const owner = ownerOf(byPath, position, "an evaluated block"); + const owner = ownerOf( + byPath, + fragments, + announced, + position, + site(event, index), + "an evaluated block", + ); if (!owner.ok) { return owner; } @@ -613,7 +645,14 @@ function build( ), ); } - const owner = ownerOf(byPath, position, "a generated fragment"); + const owner = ownerOf( + byPath, + fragments, + announced, + position, + site(event, index), + "a generated fragment", + ); if (!owner.ok) { return owner; } @@ -622,7 +661,7 @@ function build( const ordinalKey = `${owner.value.scope.key}/generated`; const ordinal = (occurrences.get(ordinalKey) ?? 0) + 1; occurrences.set(ordinalKey, ordinal); - owner.value.scope.scopes.push({ + const fragment: ScopeDraft = { key: `generated-${ordinal}`, kind: "generated", name: "generated", @@ -634,6 +673,27 @@ function build( elicitations: [], generated: [], scopes: [], + }; + owner.value.scope.scopes.push(fragment); + // Retained by the identity the admission carries rather than indexed by + // a path it does not have: what belongs to this scope is the work the + // fragment itself performed, and every one of those effects names this + // exact admission. + const admitted = generatedIdentity(description.name); + if (admitted === undefined) { + return Err( + new ReplProjectionError( + "a recorded generated fragment does not name the admission it is, so the work " + + "inside it cannot be owned.", + ), + ); + } + fragments.push({ + id: admitted, + entry: entry.key, + coroutine: event.coroutineId, + order: index, + scope: fragment, }); } transcript.push({ @@ -665,7 +725,14 @@ function build( if (answer === undefined) { return Err(new ReplProjectionError("a recorded question records no answer at all.")); } - const owner = ownerOf(byPath, position, "an answered question"); + const owner = ownerOf( + byPath, + fragments, + announced, + position, + site(event, index), + "an answered question", + ); if (!owner.ok) { return owner; } @@ -720,7 +787,14 @@ function build( new ReplProjectionError("a recorded Agent prompt does not retain the text it asked."), ); } - const owner = ownerOf(byPath, position, "an Agent prompt"); + const owner = ownerOf( + byPath, + fragments, + announced, + position, + site(event, index), + "an Agent prompt", + ); if (!owner.ok) { return owner; } @@ -879,21 +953,107 @@ function register(index: Map, scope: ScopeDraft): void { held.push(scope); } +/** + * One generated fragment this prefix admitted, as its own effects name it. + * + * Generated source is not a file, so the engine records the work inside it at a + * position carrying the fragment's identity instead of a path — the id the + * admission was decided under. This is that identity, with the two facts that + * say whether a candidate effect could have come from it: which coroutine + * admitted it, and how far into the history that was. + */ +interface GeneratedOwner { + readonly id: string; + /** The entry that admitted it. One execution holds one, and this states it. */ + readonly entry: string; + readonly coroutine: string; + /** Where the admission sits in this prefix, so only earlier work can be its. */ + readonly order: number; + readonly scope: ScopeDraft; +} + +/** Where one recorded effect sits: on which coroutine, and how far in. */ +interface EffectSite { + readonly coroutine: string; + readonly order: number; +} + +function site(event: Yield, order: number): EffectSite { + return { coroutine: event.coroutineId, order }; +} + +/** + * Every generated identity this prefix admits, wherever it admits it. + * + * Read from the same records the projection will read, and used for one thing: + * telling "this history records work before the fragment that owns it" from + * "this history admits no such fragment". Ownership itself is decided in order, + * from the admissions already projected. + */ +function admittedIdentities(events: readonly DurableEvent[]): ReadonlySet { + const found = new Set(); + for (const event of events) { + if (event.type !== "yield" || event.description.type !== "generated_xmd") { + continue; + } + // An admitted one alone. A refused fragment performed nothing and has no + // scope, so a record naming its id is naming something that never ran. + if (event.result.status !== "ok" || !isJsonObject(event.result.value)) { + continue; + } + if (event.result.value["decision"] !== "admitted") { + continue; + } + const identity = generatedIdentity(String(event.description.name)); + if (identity !== undefined) { + found.add(identity); + } + } + return found; +} + +/** The id a generated admission's durable name carries, or none. */ +function generatedIdentity(name: string): string | undefined { + if (!name.startsWith("generated:")) { + return undefined; + } + const id = name.slice("generated:".length); + return id.length === 0 ? undefined : id; +} + +/** + * Whether one coroutine is the other, or an ancestor of it. + * + * Segment-aware on purpose: a child's id is its parent's with a further segment, + * so `root.1` encloses `root.1.0` and has nothing to do with `root.10`. + */ +function descendsFrom(ancestor: string, coroutine: string): boolean { + return coroutine === ancestor || coroutine.startsWith(`${ancestor}.`); +} + /** * The one scope an effect's source position belongs to. * - * Attribution is by the path the position names, which is the path the scope's - * own source was admitted from. A position naming no admitted source, or naming - * one that this prefix admitted more than once, is refused: a binding attached - * to a guessed owner is a value shown in the wrong place, and there is no - * spelling of "probably this one" that a reader could check. + * Two attributions, and a position states which one it is. A position naming a + * path belongs to the scope admitted from that path. A position naming a + * generated fragment belongs to the scope that fragment's admission created — + * chosen by the identity the effect itself carries, with journal order and + * coroutine ancestry deciding only whether that candidate could be its owner: an + * admission that happened afterwards, or on work this effect is not part of, is + * not an owner however recently it ran. Nothing is chosen by the latest + * admission, the current scope, an effect's name or its line and column: a + * binding attached to a guessed owner is a value shown in the wrong place, and + * there is no spelling of "probably this one" that a reader could check. */ function ownerOf( index: Map, + fragments: readonly GeneratedOwner[], + announced: ReadonlySet, position: ReplPosition | undefined, + where: EffectSite, subject: string, ): Result<{ scope: ScopeDraft; position: ReplPosition }> { - if (position === undefined || position.path === undefined) { + if (position === undefined) { return Err( new ReplProjectionError( `${subject} was recorded without the source position that says which part of the entry ` + @@ -901,6 +1061,9 @@ function ownerOf( ), ); } + if (position.path === undefined) { + return generatedOwnerOf(fragments, announced, position, where, subject); + } const held = index.get(position.path) ?? []; if (held.length === 0) { return Err( @@ -923,6 +1086,64 @@ function ownerOf( return Ok({ scope: held[0], position }); } +/** + * The generated fragment one pathless effect belongs to. + * + * The identity chooses the candidate; order and ancestry only say whether it + * could be its owner. Each way that fails is its own refusal, because "nothing + * owns this" and "two things might" are different damage and a reader acting on + * either needs to know which they have. + */ +function generatedOwnerOf( + fragments: readonly GeneratedOwner[], + announced: ReadonlySet, + position: ReplPosition, + where: EffectSite, + subject: string, +): Result<{ scope: ScopeDraft; position: ReplPosition }> { + const identity = position.generatedSource; + if (identity === undefined) { + return Err( + new ReplProjectionError( + `${subject} was recorded with neither a source path nor the generated fragment it ` + + "belongs to, so nothing owns it.", + ), + ); + } + const named = fragments.filter((fragment) => fragment.id === identity); + if (named.length === 0) { + return Err( + new ReplProjectionError( + announced.has(identity) + ? `${subject} names a generated fragment this entry admitted only afterwards, so ` + + "nothing had admitted it when it ran." + : `${subject} names a generated fragment this entry never admitted, so nothing owns it.`, + ), + ); + } + // Only an admission this effect could have come from: one that had already + // happened, on this coroutine or on an ancestor of it. + const earlier = named.filter((fragment) => fragment.order < where.order); + const owning = earlier.filter((fragment) => descendsFrom(fragment.coroutine, where.coroutine)); + if (owning.length === 0) { + return Err( + new ReplProjectionError( + `${subject} names a generated fragment admitted on work it is not part of, so nothing ` + + "owns it.", + ), + ); + } + if (owning.length > 1) { + return Err( + new ReplProjectionError( + `${subject} names a generated fragment this entry admitted more than once, so which ` + + "admission owns it cannot be decided.", + ), + ); + } + return Ok({ scope: owning[0]!.scope, position }); +} + /** What a position that will not read is, as distinct from one that is absent. */ const MALFORMED = Symbol("malformed source position"); @@ -935,27 +1156,75 @@ function readPosition(event: Yield): ReplPosition | undefined | typeof MALFORMED return MALFORMED; } const path = field["path"]; + const generatedSource = field["generatedSource"]; const offset = field["offset"]; const line = field["line"]; const column = field["column"]; - if (path !== undefined && typeof path !== "string") { + if (path !== undefined && (typeof path !== "string" || path.length === 0)) { + return MALFORMED; + } + if ( + generatedSource !== undefined && + (typeof generatedSource !== "string" || generatedSource.length === 0) + ) { + return MALFORMED; + } + // One source, or neither. A position naming a file *and* a generated fragment + // says two different things about where its effect was written, and there is + // no reading of it that is not a choice between them. + if (path !== undefined && generatedSource !== undefined) { return MALFORMED; } if (!isIndex(offset) || !isOrdinal(line) || !isOrdinal(column)) { return MALFORMED; } - return { path, offset, line, column }; + return { path, generatedSource, offset, line, column }; } -/** The exact source a recorded import retained, or none when it retained none. */ +/** + * The exact source a recorded import retained, or none when it retained none. + * + * Two shapes, because an import resolves two kinds of thing. A component read + * from somewhere states the `path` it was read from; a component the host + * *declared* states the `origin` it is known by, its digest and its bytes — and + * that origin is the path every effect inside its body is recorded at, so it is + * the path this model owns the scope under. + * + * Each is read as the closed record it is. A declared selection carries exactly + * four members, or five when the optional `exact` disposition is present, and + * `exact` is present only as `true`: a record with a member this version does + * not know, or one it knows written as something else, is a record this version + * cannot read rather than one to guess the rest of. What comes back for an + * unreadable record is nothing, and an effect that then names its path has no + * owner — which is the refusal, not a scope assembled from a guess. + */ function readRetainedSource(event: Yield): { path: string; content: string } | undefined { if (event.result.status !== "ok" || !isJsonObject(event.result.value)) { return undefined; } const record = event.result.value; - const path = record["path"]; const content = record["content"]; - if (typeof path !== "string" || typeof content !== "string") { + if (typeof content !== "string") { + return undefined; + } + if (record["kind"] === "declared-markdown") { + const origin = record["origin"]; + const digest = record["digest"]; + const exact = record["exact"]; + const withExact = Object.hasOwn(record, "exact"); + const members = Object.keys(record).length; + if ( + members !== (withExact ? 5 : 4) || + typeof origin !== "string" || + typeof digest !== "string" || + (withExact && exact !== true) + ) { + return undefined; + } + return { path: origin, content }; + } + const path = record["path"]; + if (typeof path !== "string") { return undefined; } return { path, content }; diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index 323090a0f..dc11d694b 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -39,7 +39,7 @@ import { type Subscription, withResolvers, } from "effection"; -import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { ReplExecutionProfile } from "../repl-profile.ts"; import { admitted, @@ -55,7 +55,7 @@ import { stateFor, viewFor, } from "./application.ts"; -import type { Json, NormalizedIssue, PermissionMode } from "@executablemd/core"; +import type { Json, NormalizedIssue } from "@executablemd/core"; import type { ReplAction, ReplFormMessage, @@ -84,24 +84,22 @@ import { useReplRoot } from "./storage.ts"; import { ReplTerminal } from "./terminal.ts"; import type { ReplTerminalSize } from "./terminal.ts"; -/** What one `xmd repl` invocation was asked to do. */ +/** + * What one `xmd repl` invocation was asked to do. + * + * Two invocation facts and one profile. What a REPL execution may resolve, the + * ceiling a generated fragment runs under and how a permission request is + * answered are decided by the command before this runs, and are read here rather + * than assembled again: one profile means the second entry of a session cannot + * run under different rules from the first. + */ export interface ReplProgramOptions { /** The location to reopen, or none to start a fresh execution. */ readonly location?: string; - /** Where components are looked for. */ - readonly includes?: readonly string[]; - /** What this host installs around the document. */ - readonly installations?: readonly ExecutionInstallation[]; /** The repository root. Defaults to this host's per-user data directory. */ readonly root?: string; - /** - * How this REPL answers Agent permission requests. - * - * Passed through to the session, which installs the policy. Absent means - * `deny-all`, which is what an execution with no configured mode already does — - * so a REPL that nobody configured asks nobody anything. - */ - readonly permissionMode?: PermissionMode; + /** Everything this command settled before it opened a terminal. */ + readonly profile: ReplExecutionProfile; } /** What the command reports when it ends. */ @@ -120,7 +118,7 @@ export interface ReplOutcome { * something this command cannot show, and the difference decides whether a * terminal was ever opened. */ -export function* runReplProgram(options: ReplProgramOptions = {}): Operation> { +export function* runReplProgram(options: ReplProgramOptions): Operation> { // Before a path is formed, a file is opened or a terminal is touched: what the // caller named has to be a location this grammar defines. if (options.location !== undefined) { @@ -185,9 +183,9 @@ export function* runReplProgram(options: ReplProgramOptions = {}): Operation { + // How far an Agent turn has got is a fact no record carries until the turn + // has ended: a queued turn, the text streaming into one, and a permission + // request waiting on somebody are all live-only. Without this the surface + // showing them is drawn only when something else happens to wake the loop, so + // a turn a person is watching appears already finished. + // // Subscribed here, in the scope that outlives the spawn, and drained there. // A spawned body starts a turn after the spawn returns, and a turn is long - // enough for a queued turn, a delta or a permission request to be sent to - // nobody: the Agent reading moves while the document is doing nothing else, - // so there is no other event to draw the frame that would have shown it. + // enough for the first of those changes to be sent to nobody. const agents = yield* session.agentChanges; yield* spawn(function* conversations(): Operation { while (true) { @@ -833,9 +835,9 @@ function* perform( const submitted = yield* submitReplEntry({ execution, source: intent.source, - ...(options.includes === undefined ? {} : { includes: options.includes }), - ...(options.installations === undefined ? {} : { installations: options.installations }), - ...(options.permissionMode === undefined ? {} : { permissionMode: options.permissionMode }), + includes: options.profile.includes, + installations: options.profile.installations, + permissionMode: options.profile.permissionMode, }); if (!submitted.ok) { // A preflight refusal leaves the draft exactly as it was and the history diff --git a/packages/cli/tests/cli-help.test.ts b/packages/cli/tests/cli-help.test.ts index 907e70e3e..348b61443 100644 --- a/packages/cli/tests/cli-help.test.ts +++ b/packages/cli/tests/cli-help.test.ts @@ -267,4 +267,69 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false expect(malformed.stderr).toContain("xmd repl:"); expect(malformed.stderr).toContain("xmd://repl/"); }); + + it("C1: repl states the same five Agent options as run, described the same way", function* () { + const run = yield* runCli(["run", "--help"]).expect(); + const repl = yield* runCli(["repl", "--help"]).expect(); + + // One declaration serves both commands, so the same option cannot come to + // mean two things: a reader comparing the two helps is comparing one source. + const described = (text: string): string[] => + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => + /^--(agent-provider|default-agent|approve-all|approve-reads|deny-all)\b/.test(line), + ); + const options = described(repl.stdout); + expect(options).toHaveLength(5); + expect(options).toEqual(described(run.stdout)); + // Approve-reads is what an unstated line means, and the help says so. + expect(repl.stdout).toContain("ask for the rest (default)"); + // And no sixth: this command adds no data directory and no REPL-only knob. + expect(repl.stdout).not.toContain("--data-dir"); + }); + + it("C1: each Agent option is accepted, and a wrong line refuses before the terminal", function* () { + // Accepted: the line parses, the stack settles, and the command gets as far + // as wanting a terminal — which a pipe is not. That refusal is the proof the + // option reached the command rather than the parser's error path. + for (const accepted of [ + ["repl", "--approve-all"], + ["repl", "--approve-reads"], + ["repl", "--deny-all"], + ["repl", "--agent-provider", "acpx"], + ["repl", "--default-agent", "claude"], + ]) { + const ran = yield* runCli(accepted).join(); + expect([accepted, ran.code]).toEqual([accepted, 1]); + expect([accepted, ran.stderr.trim()]).toEqual([ + accepted, + "xmd repl: the REPL runs where a terminal is: it is not available over a pipe.", + ]); + } + + // Refused, and each before the terminal: the messages are the command + // line's own, so nothing had been opened when they were printed. + const exclusive = yield* runCli(["repl", "--approve-all", "--deny-all"]).join(); + expect(exclusive.code).toBe(1); + expect(exclusive.stderr).toContain("mutually exclusive"); + expect(exclusive.stderr).not.toContain("terminal"); + + const provider = yield* runCli(["repl", "--agent-provider", "nope"]).join(); + expect(provider.code).toBe(1); + expect(provider.stderr).toContain('Unknown agent provider "nope"'); + expect(provider.stderr).not.toContain("terminal"); + + const missing = yield* runCli(["repl", "--default-agent"]).join(); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain("--default-agent requires a value"); + expect(missing.stderr).not.toContain("terminal"); + + // An option value is not a location: `--default-agent claude` names one + // thing, and a scan that counted the value would refuse it as two. + const withLocation = yield* runCli(["repl", "--default-agent", "claude", "one", "two"]).join(); + expect(withLocation.code).toBe(1); + expect(withLocation.stderr).toContain("at most one location"); + }); }); diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index f8151e903..5adc3b8fc 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -20,7 +20,7 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped, sleep, spawn, withResolvers } from "effection"; +import { ensure, scoped, sleep, spawn, useScope, withResolvers } from "effection"; import type { Operation } from "effection"; import { forEach } from "@effectionx/stream-helpers"; import { ensureDir, rm } from "@effectionx/fs"; @@ -51,6 +51,8 @@ import type { FragmentEvaluationInput, } from "@executablemd/core/host"; import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { elicitWriteEntry } from "@executablemd/core/host"; +import { Elicitation } from "@executablemd/core"; import { recordedFiles } from "../../core/tests/support/fragment-files.ts"; import { answerProvider } from "../../core/tests/support/answer-provider.ts"; import { InMemoryStream } from "@executablemd/durable-streams"; @@ -1245,6 +1247,56 @@ describe("Tier PI — read-only information requests", () => { * its second site nothing. The failure names the turn and quotes the prompt, so * the case that under-scripted is identifiable from the message alone. */ +describe("Tier PI — the ordinary ceiling admits the question with the write", () => { + it("EL1: the ordinary write table holds canonical `` once, and the read table none", function* () { + const profile = ordinaryEvaluationProfile(); + + // One entry, and exactly the one core states. Two would be two grants + // under one spelling; a hand-written copy would be a second place this + // command decided what canonical `` is. + const asking = profile.write?.filter((entry) => entry.name === "Elicit") ?? []; + expect(asking).toEqual([elicitWriteEntry()]); + // And nothing in the read table: `allow={["read"]}` promises nobody will + // be interrupted, so the question is not there to select. + expect(profile.read.map((entry) => entry.name)).toEqual(["File", "Glob", "Syntax"]); + yield* useScope(); + }); + + it("EL3: a read-only information request that asks a question is refused whole", function* () { + const files = recordedFiles({ "notes.md": "the retained note\n" }); + const asked: string[] = []; + const run = yield* scoped(function* () { + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit([request]): Operation { + asked.push(request.message); + return { decision: "Approve" }; + }, + }, + { at: "min" }, + ); + return yield* runDocument({ + // The Plan writer's own requests select `read`, so the question the + // returned program may write is not one the writer may ask while it is + // still deciding what to propose. + turns: [ + { reply: 'Which file?\n' }, + { reply: CANDIDATE }, + ], + evaluation: { read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()], files }, + }); + }); + + expect(run.failure).toBe(undefined); + // Nobody was asked, and no read happened either: the refusal is the whole + // fragment's, before its first effect. + expect(asked).toEqual([]); + expect(files.performed).toEqual([]); + expect(run.prompts[1] ?? "").toContain("That request was refused"); + }); +}); + describe("the scripted agent", () => { it("fails loudly on a turn nobody scripted, naming it", function* () { const run = yield* runDocument({ diff --git a/packages/cli/tests/plugin-selection.test.ts b/packages/cli/tests/plugin-selection.test.ts index 09ad11710..f51b341cb 100644 --- a/packages/cli/tests/plugin-selection.test.ts +++ b/packages/cli/tests/plugin-selection.test.ts @@ -87,11 +87,27 @@ describe("PS4 — the command a Plugin is told about", () => { // deno-lint-ignore require-yield it("reports each public command by its own name", function* () { - for (const command of ["run", "plan", "test", "syntax", "upgrade", "workflow"]) { + for (const command of ["run", "plan", "test", "syntax", "upgrade", "workflow", "repl"]) { expect(selectPlugins([command]).command).toBe(command); expect(selectPlugins(["--plugin=./a.mjs", command]).command).toBe(command); } }); + + it("PS4: the REPL is its own command, and its name is not left as a positional", function* () { + // Left out of the vocabulary, `repl` normalizes to `run` and arrives as a + // document reference — so a Plugin written for `run` installs into a + // terminal session, and one written for the REPL never hears about it. + const selected = selectPlugins(["repl"]); + expect(selected.command).toBe("repl"); + expect(selected.command).not.toBe("run"); + // The argv a Plugin reads is the one the caller wrote, with only the + // `--plugin` tokens taken out — so the command token is still in it, and + // what changed is that `repl` is no longer *also* read as a document. + expect(selected.rest).toEqual(["repl"]); + const located = selectPlugins(["--plugin=./a.mjs", "repl", "xmd://repl/one/repl"]); + expect(located.command).toBe("repl"); + expect(located.rest).toEqual(["repl", "xmd://repl/one/repl"]); + }); }); describe("PS5 — describing a command line loads nothing", () => { diff --git a/packages/cli/tests/repl-agent-interface.test.ts b/packages/cli/tests/repl-agent-interface.test.ts index 106d94383..46dd8a90e 100644 --- a/packages/cli/tests/repl-agent-interface.test.ts +++ b/packages/cli/tests/repl-agent-interface.test.ts @@ -77,6 +77,7 @@ import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; import type { ReplTerminalSize } from "../src/repl/terminal.ts"; import { ReplClock } from "../src/repl/frame.ts"; import { runReplProgram } from "../src/repl/program.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; import type { ReplOutcome } from "../src/repl/program.ts"; import { appendFile, mkdtemp, open } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -411,6 +412,21 @@ function* useStub(stub: Stub): Operation { }); } +/** + * The profile these rows' program runs under. + * + * `approve-reads` is the mode that leaves a non-read decision to a person, which + * is the only way a request reaches this screen at all. + */ +const PROFILE: ReplExecutionProfile = { + includes: [], + installations: [ + { evaluation: ordinaryEvaluationProfile() }, + { components: agentIdentityComponents() }, + ], + permissionMode: "approve-reads", +}; + function installations(): readonly ExecutionInstallation[] { return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; } @@ -1719,12 +1735,7 @@ describe("U2 — the program performs a permission, end to end", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - // `approve-reads` is the mode that leaves a non-read decision to a - // person, which is the only way a request reaches this screen at all. - const ran = yield* runReplProgram({ - installations: installations(), - permissionMode: "approve-reads", - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1801,10 +1812,7 @@ describe("U2 — the program performs a permission, end to end", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - installations: installations(), - permissionMode: "approve-reads", - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1884,10 +1892,7 @@ describe("U4 — the loop wakes for Agent work", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - installations: installations(), - permissionMode: "approve-reads", - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } diff --git a/packages/cli/tests/repl-agent-journey.test.ts b/packages/cli/tests/repl-agent-journey.test.ts new file mode 100644 index 000000000..1101c6840 --- /dev/null +++ b/packages/cli/tests/repl-agent-journey.test.ts @@ -0,0 +1,1869 @@ +/** + * The journeys a person takes through `xmd repl` with an Agent. + * + * Every row here drives the public program: `runReplProgram()` over a real + * Journal, a real Freedom tree, the real renderer and a terminal this suite + * writes bytes to. What is substituted is what a test is allowed to decide — + * which bytes the terminal produces, what the Agent says, where the Plan writer + * keeps its conversations, and when time passes. The document, the route, the + * application model, the Journal and the packaged `` are the product's + * own. + * + * ## J1 is the published Story + * + * The source in `STORY` is the one the Story publishes, exactly. The revised + * program in `REVISED` is what a deterministic Agent returns for it, and it is + * fixture data rather than a claim about what a model would write: what it has + * to be is *legal* — a generated fragment under the ordinary ceiling, which may + * write JSON literals, its own bindings, arrays and objects and may not compute + * anything. That is why it compares two `` renderings instead of + * reading a member of its own answer: `confirmation.decision === "Approve"` is + * a computed expression, and the grammar refuses one (specs §5.3.3). + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, until as untilResolved } from "effection"; +import type { Operation, Task } from "effection"; +import { useTempFileCompiler } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { appendFile, mkdtemp, open, readdir, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomBytes } from "node:crypto"; + +import { decodeLocation, encodeLocation } from "../src/repl/route.ts"; +import { installReplHost } from "../src/repl-assembly.ts"; +import { installReplTerminal } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalCapabilities } from "../src/repl/terminal-host.ts"; +import type { ReplTerminalSize } from "../src/repl/terminal.ts"; +import { ReplClock } from "../src/repl/frame.ts"; +import { surfaceWidth } from "../src/repl/layout.ts"; +import { runReplProgram } from "../src/repl/program.ts"; +import { assembleReplProfile } from "../src/repl-profile.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; +import { NO_PLUGINS } from "../src/plugin-host.ts"; +import { resolveAgentStack } from "../src/agent-stack.ts"; +import { createFakeAcp, makeRegistry, makeStore, tripwireAcp } from "./support/fake-acp.ts"; +import type { FakeAcp } from "./support/fake-acp.ts"; +import { ADAPTERS, AGENT } from "./support/plan-harness.ts"; + +/** The exact source the Story publishes. */ +const STORY = [ + '', + " ", + " Create an XMD program that asks me for a project name and a one-sentence", + " description. Preview the README it will create and ask for confirmation.", + " If I approve, write README.md and report that it was created. If I", + " decline, stop without writing anything.", + " ", + "", +].join("\n"); + +/** The two-field form the generated program asks first. */ +const DETAILS_SCHEMA = + '{ type: "object", properties: { project: { type: "string", title: "Project name", ' + + 'minLength: 1 }, summary: { type: "string", title: "One-sentence description", ' + + 'minLength: 1 } }, required: ["project", "summary"], additionalProperties: false }'; + +/** The approve-or-decline confirmation it asks second. */ +const CONFIRM_SCHEMA = + '{ type: "object", properties: { decision: { type: "string", enum: ["Approve", "Decline"] } }, ' + + 'required: ["decision"], additionalProperties: false }'; + +/** + * The README the program proposes, previews and writes. + * + * One composition, written three times: once as the preview in the document, + * once inside the confirmation's own request, and once as what `` writes. + * Three renderings of one deterministic composition are byte-identical, which is + * what makes "the preview is the file" something a row can compare rather than + * something the fixture asserts about itself. + */ +const README = ["# The project", "", "", ""].join("\n"); + +/** What the first turn returns: structurally valid, and not what was asked for. */ +const DRAFT = ["# A first draft", "", "Ask for a name. Write a file.", ""].join("\n"); + +/** What the second turn returns once the feedback has been given. */ +const REVISED = [ + "# Create a README from a project name and description", + "", + `Name the project and describe it in one ` + + "sentence.", + "", + "Proposed README.md:", + "", + README, + `Write this README?`, + "", + README, + "", + "", + '', + '', + "", + "", + "", + '' + README, + "", + "", + "Created README.md.", + "", + "", + "", + "Stopped without writing anything.", + "", + "", + "", +].join("\n"); + +/** The bytes the write leaves on disk, with the answers the person typed. */ +const WRITTEN = [ + "# The project", + "", + "{", + ' "project": "Ledger",', + ' "summary": "A tiny ledger."', + "}", + "", + "", +].join("\n"); + +const FEEDBACK = "Ask for the description too, and confirm before writing."; + +/** What each branch of the generated program reports. */ +const CREATED = "Created README.md."; +const STOPPED = "Stopped without writing anything."; + +/** What the packaged Plan's own authorship prompt begins with. */ +const AUTHORSHIP = "Create one complete XMD Plan"; + +/** What a turn says before its provider reports it failed or cancelled. */ +const PARTIAL = "half a thought"; + +/** What the Agent says when a row only needs it to have said something. */ +const REPLY = "The plan reads well.\n"; + +/** What each child of the `` asks, and what its provider answers. */ +const PLAN_IT = "plan it"; +const REVIEW_IT = "review it"; +const BUILD_IT = "build it"; +/** + * What every child's provider answers. + * + * One text for all three, because the fake answers turns in the order it is + * asked and three children started at once are asked in whatever order their + * coroutines were scheduled. What tells the turns apart here is what each asked, + * which is the document's own text. + */ +const ANSWERED = "the answer stands"; + +/** One entry running three conversations at once, as a person writes it. */ +const THREE = [ + "", + ``, + ``, + ``, + "", +].join("\n"); + +/** One entry that asks one question of one Agent. */ +const ASKING = ['', '', ""].join("\n"); + +describe("J1 — the Story, from one entry to one README", () => { + beforeAll(() => useTempFileCompiler()); + + it("J1: the packaged Plan is reviewed in the drawer, and its program asks, previews and writes", function* () { + const fake = createFakeAcp(); + fake.script({ reply: DRAFT }); + fake.script({ reply: REVISED }); + // The authorship turn is held twice: before it starts, so a reader sees it + // queued, and before it streams, so a reader sees it active with nothing + // said yet. + const held = holds([AUTHORSHIP]); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 140 }); + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const running = yield* spawn(() => start(fake, held, workspace)); + yield* untilDrawn(terminal); + + terminal.bytes(BYTES.encode(STORY)); + yield* settled(20); + terminal.feed("\r"); + + // Queued: the Prompt is scheduled and the provider has not taken it. + yield* showing(terminal, "· queued"); + expect(shows(terminal, "· streaming")).toBe(false); + // Taken, and streaming: the turn is active before it has said anything. + held.start(AUTHORSHIP); + yield* showing(terminal, "· streaming"); + // Ended, with the fact its provider reported, and then retained: the row a + // person was watching becomes its own record, under the scope the packaged + // Plan was admitted as. + held.deltas(AUTHORSHIP); + yield* showing(terminal, "stopped: end_turn"); + yield* showing(terminal, "completed, recorded"); + yield* awaiting("the turn was never retained", function* () { + return ( + recorded(yield* journal(hostRoot)).filter((kind) => kind === "agent_prompt").length > 0 + ); + }); + + // The review is the REPL's own drawer, over the draft the Agent returned. + yield* showing(terminal, "Request changes"); + expect(shows(terminal, "A first draft")).toBe(true); + + // Request changes with no feedback. Choosing an option submits the form, + // and this one is refused: the schema requires the feedback that option + // asks for, so the review stays open, nothing is answered and no turn + // begins. + yield* click(terminal, "( ) Request changes"); + yield* settled(40); + expect(shows(terminal, "Request changes")).toBe(true); + expect(fake.prompts).toHaveLength(1); + expect(recorded(yield* journal(hostRoot)).filter((kind) => kind === "elicit")).toEqual([]); + + // With feedback, the same conversation continues into the next turn. + yield* click(terminal, "feedback:"); + terminal.bytes(BYTES.encode(FEEDBACK)); + yield* settled(20); + yield* click(terminal, "[submit]"); + yield* until(() => fake.prompts.length >= 2, "the revision was never asked for"); + expect(fake.prompts[1]).toContain(FEEDBACK); + expect(new Set(fake.turns.map((turn) => turn.handle.sessionKey)).size).toBe(1); + + // Approved: the revision is what `` admits. + yield* showing(terminal, "Approve"); + yield* click(terminal, "( ) Approve"); + + // The first question the generated program asks. + yield* showing(terminal, "Name the project"); + const admitted = admission(yield* journal(hostRoot)); + // The approved text, byte for byte, inside the document's own whitespace: + // ``'s content is the newline and the two columns the `` + // element was written at, and then exactly what the provider returned. + expect(admitted.source).toBe(`\n ${REVISED}\n`); + // Every element the fragment named, in the order preflight met them: the + // two questions, the values they compose, and the one write. + expect(admitted.named).toEqual([ + { name: "Elicit", form: "paired" }, + { name: "Json", form: "self-closing" }, + { name: "Elicit", form: "paired" }, + { name: "Json", form: "self-closing" }, + { name: "Json", form: "self-closing" }, + { name: "Json", form: "self-closing" }, + { name: "File", form: "paired" }, + { name: "Json", form: "self-closing" }, + ]); + + yield* answer(terminal, "Project name", "Ledger"); + yield* answer(terminal, "One-sentence description", "A tiny ledger."); + yield* submit(terminal); + + // The preview: the complete proposed file, in the confirmation's own + // request, before anything has been written. + yield* showing(terminal, "Write this README?"); + for (const line of ["# The project", '"project": "Ledger"', '"summary": "A tiny ledger."']) { + expect([line, shows(terminal, line)]).toEqual([line, true]); + } + expect(yield* untilResolved(readdir(workspace))).toEqual([]); + // The report is on the screen only as the source it was admitted from — + // one row, the program's own text — and nothing has rendered it. + expect(occurrences(terminal, CREATED)).toBe(1); + + // Approved. The report follows the write: at the first frame that shows + // it, the file is already there, with the bytes the preview showed. + yield* click(terminal, "( ) Approve"); + yield* awaiting("the report never followed the write", function* () { + yield* settled(10); + return occurrences(terminal, CREATED) > 1; + }); + expect(yield* untilResolved(readdir(workspace))).toEqual(["README.md"]); + expect(yield* untilResolved(readFile(join(workspace, "README.md"), "utf8"))).toBe(WRITTEN); + // And the branch that was not taken rendered nothing: its line is on the + // screen once, as source. + expect(occurrences(terminal, STOPPED)).toBe(1); + + // One write, and the entry settled on it. + yield* awaiting("the entry never settled", function* () { + return (yield* journal(hostRoot)).some((event) => event.type === "close"); + }); + expect(yield* untilResolved(readdir(workspace))).toEqual(["README.md"]); + + // Both authorship turns are retained, under the scope the packaged Plan + // was admitted as: the entry holds a `Plan` component scope, the program + // it produced hangs under it, and both rows are recorded ones rather than + // this process's live readings. + expect(recorded(yield* journal(hostRoot)).filter((kind) => kind === "agent_prompt")).toEqual([ + "agent_prompt", + "agent_prompt", + ]); + expect(occurrences(terminal, "completed, recorded")).toBe(2); + expect(shows(terminal, "component Plan")).toBe(true); + expect(shows(terminal, "generated generated")).toBe(true); + // And both Plan reviews are retained where they were asked, which is + // inside that scope rather than in the entry. + expect(occurrences(terminal, "answered @executablemd/cli/Plan.md")).toBe(2); + + // Two turns in one conversation, and nothing else was asked. + expect(fake.prompts).toHaveLength(2); + terminal.end(); + yield* running; + }); + }); + + it("J1: declining reaches the same preview and writes nothing", function* () { + const fake = createFakeAcp(); + fake.script({ reply: DRAFT }); + fake.script({ reply: REVISED }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 140 }); + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const running = yield* spawn(() => start(fake, holds([]), workspace)); + yield* untilDrawn(terminal); + + terminal.bytes(BYTES.encode(STORY)); + yield* settled(20); + terminal.feed("\r"); + + // The same review, the same feedback, the same approved program. + yield* showing(terminal, "Request changes"); + yield* click(terminal, "( ) Request changes"); + yield* settled(40); + yield* click(terminal, "feedback:"); + terminal.bytes(BYTES.encode(FEEDBACK)); + yield* settled(20); + yield* click(terminal, "[submit]"); + yield* until(() => fake.prompts.length >= 2, "the revision was never asked for"); + yield* showing(terminal, "Approve"); + yield* click(terminal, "( ) Approve"); + + yield* showing(terminal, "Name the project"); + yield* answer(terminal, "Project name", "Ledger"); + yield* answer(terminal, "One-sentence description", "A tiny ledger."); + yield* submit(terminal); + + // The same preview, reached the same way. + yield* showing(terminal, "Write this README?"); + expect(shows(terminal, '"project": "Ledger"')).toBe(true); + expect(yield* untilResolved(readdir(workspace))).toEqual([]); + + // Declined. The two outcomes are observably different: one reports a + // file it wrote, and this one reports that it wrote nothing. + yield* click(terminal, "( ) Decline"); + yield* awaiting("the decline was never reported", function* () { + yield* settled(10); + return occurrences(terminal, STOPPED) > 1; + }); + // And the write branch rendered nothing: its report is on the screen once, + // as the source it was admitted from. + expect(occurrences(terminal, CREATED)).toBe(1); + yield* awaiting("the entry never settled", function* () { + return (yield* journal(hostRoot)).some((event) => event.type === "close"); + }); + // Nothing was written, and the question that decided it is in the + // history exactly as the approving one is. + expect(yield* untilResolved(readdir(workspace))).toEqual([]); + expect(recorded(yield* journal(hostRoot)).filter((kind) => kind === "elicit")).toHaveLength( + 4, + ); + terminal.end(); + yield* running; + }); + }); +}); + +describe("C1 — the command line this REPL runs under", () => { + beforeAll(() => useTempFileCompiler()); + + it("C1: the five options settle into one immutable profile", function* () { + const reached: string[] = []; + const settled = yield* scoped(function* () { + const flags = { + agentProvider: "acpx", + defaultAgent: AGENT, + approveAll: false, + approveReads: false, + denyAll: false, + }; + const stack = yield* resolveAgentStack(flags, undefined); + if (!stack.ok) { + throw stack.error; + } + // Assembling a profile reaches no provider: the adapter behind an option + // is materialized when a turn asks for one, not when the line is read. + return yield* assembleReplProfile(stack.value, NO_PLUGINS, { + acp: { createRuntime: tripwireAcp((what) => reached.push(what)) }, + planWriterRoot: yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-c1-"))), + }); + }); + + expect(reached).toEqual([]); + // Approve-reads is what an unstated line means, and it is the mode the + // profile carries. + expect(settled.permissionMode).toBe("approve-reads"); + // Fixed includes: this command takes no include option, because an entry is + // typed rather than named. + expect(settled.includes).toEqual(["components", "."]); + // One installation of this command's own, holding the packaged `` and + // the ceiling a generated fragment runs under — and the ordinary write + // table, questions included. + expect(settled.installations).toHaveLength(1); + const own = settled.installations[0]!; + expect((own.declarations ?? []).map((declaration) => declaration.name)).toEqual(["Plan"]); + expect((own.evaluation?.write ?? []).map((entry) => entry.name)).toEqual([ + "File", + "File.Delete", + "Elicit", + ]); + // Read once and never changed: a profile a later entry could edit would be + // two sets of rules in one session. + expect(Object.isFrozen(settled)).toBe(true); + expect(Object.isFrozen(settled.installations)).toBe(true); + }); + + it("C1: each permission switch is the mode the profile carries", function* () { + const modes: string[] = []; + yield* scoped(function* () { + for (const flags of [ + { approveAll: true, approveReads: false, denyAll: false }, + { approveAll: false, approveReads: true, denyAll: false }, + { approveAll: false, approveReads: false, denyAll: true }, + ]) { + const stack = yield* resolveAgentStack( + { agentProvider: "acpx", defaultAgent: AGENT, ...flags }, + undefined, + ); + if (!stack.ok) { + throw stack.error; + } + modes.push(stack.value.permissionMode); + } + }); + + expect(modes).toEqual(["approve-all", "approve-reads", "deny-all"]); + }); + + it("C1: an entry that asks for no Agent materializes no adapter", function* () { + const reached: string[] = []; + const { terminal, install } = recordingTerminal(); + + yield* scoped(function* (): Operation { + yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const running = yield* spawn(function* (): Operation { + const stack = yield* resolveAgentStack( + { + agentProvider: "acpx", + defaultAgent: AGENT, + approveAll: false, + approveReads: false, + denyAll: false, + }, + undefined, + ); + if (!stack.ok) { + throw stack.error; + } + const profile = yield* assembleReplProfile(stack.value, NO_PLUGINS, { + acp: { createRuntime: tripwireAcp((what) => reached.push(what)) }, + planWriterRoot: yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-c1-"))), + }); + const ran = yield* runReplProgram({ profile }); + if (!ran.ok) { + throw ran.error; + } + }); + yield* untilDrawn(terminal); + terminal.bytes(BYTES.encode("# A document that asks nobody anything")); + yield* settled(20); + terminal.feed("\r"); + yield* showing(terminal, "A document that asks nobody anything"); + yield* awaiting("the entry never settled", function* () { + return (yield* journal(hostRoot)).some((event) => event.type === "close"); + }); + // Nothing about the provider was reached: no runtime, no registry, no + // adapter on disk. + expect(reached).toEqual([]); + terminal.end(); + yield* running; + }); + }); + + it("C1: `` is unavailable and the REPL keeps the terminal", function* () { + const fake = createFakeAcp(); + const { terminal, install } = recordingTerminal(); + + yield* scoped(function* (): Operation { + yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const running = yield* spawn(() => start(fake, holds([]), "")); + yield* untilDrawn(terminal); + const modes = terminal.raw.length; + + terminal.bytes(BYTES.encode("\ndo the work\n")); + yield* settled(20); + terminal.feed("\r"); + + // The established refusal, from the Api's own default: this command + // installs no foreground launcher, so nothing was started and the entry + // failed on the reservation rather than on anything it launched. + yield* awaiting("the entry never settled", function* () { + return (yield* journal(hostRoot)).some((event) => event.type === "close"); + }); + const closed = (yield* journal(hostRoot)).find((event) => event.type === "close"); + const failure = (closed?.result as { error?: { message?: string; name?: string } }).error; + expect(failure?.name).toBe("NativeLauncherUnavailableError"); + expect(failure?.message).toContain("no native launcher is installed"); + expect(fake.started).toBe(false); + + // And the terminal is still this REPL's: no mode was handed over and + // nothing was restored while the entry ran. + expect(terminal.raw.length).toBe(modes); + expect(terminal.resets).toBe(0); + terminal.end(); + yield* running; + }); + }); +}); + +describe("J3 — durable truth, and a cold process over it", () => { + beforeAll(() => useTempFileCompiler()); + + it("J3: a fresh command scope reconstructs the whole Story from the Journal alone", function* () { + const hostRoot = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-journey-"))); + const live = createFakeAcp(); + live.script({ reply: DRAFT }); + live.script({ reply: REVISED }); + const first = recordingTerminal({ columns: 200, rows: 140 }); + let location = ""; + let markers: string[] = []; + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* first.install(); + yield* immediateClock(); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(() => start(live, holds([]), workspace)); + yield* untilDrawn(first.terminal); + // The exact Story, through the real packaged Plan, exactly as J1 walks it. + yield* takeTheJourney(first.terminal, live, hostRoot, workspace); + // The History positions this run reached, read from the drawer that + // offers them. + yield* click(first.terminal, "[history]"); + yield* settled(40); + markers = historyMarkers(first.terminal); + expect(markers.length).toBeGreaterThan(3); + yield* click(first.terminal, "[close]"); + yield* settled(20); + first.terminal.end(); + location = yield* running; + }); + + // What the live process printed, and what the history holds now. + expect(location).toContain("xmd://repl/"); + const before = yield* history(hostRoot); + expect(live.prompts).toHaveLength(2); + + const cold = createFakeAcp(); + const second = recordingTerminal({ columns: 200, rows: 140 }); + yield* scoped(function* (): Operation { + const elsewhere = yield* useWorkspace(); + yield* second.install(); + yield* immediateClock(); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(() => start(cold, holds([]), elsewhere, location)); + yield* untilDrawn(second.terminal); + + // The whole journey, from the Journal: the packaged Plan's own scope, the + // program it produced, both retained turns and their conversation, the + // forms that were answered, the preview the program rendered and the + // write it reported. + for (const line of [ + "component Plan", + "generated generated", + "# A first draft", + "Proposed README.md:", + '"project": "Ledger"', + '"summary": "A tiny ledger."', + CREATED, + ]) { + yield* showing(second.terminal, line); + } + // Both turns are recorded rows rather than a live process's readings, and + // both Plan reviews are retained where they were asked. + expect(occurrences(second.terminal, "completed, recorded")).toBe(2); + expect(occurrences(second.terminal, "answered @executablemd/cli/Plan.md")).toBe(2); + // The program's own two questions are retained with the answers they + // took. Their positions carry no path — generated source is not a file — + // so they are shown under the fragment that produced them. + expect(occurrences(second.terminal, 'answered 4:1 {"project":"Ledger"')).toBe(1); + expect(occurrences(second.terminal, 'answered 12:1 {"decision":"Approv')).toBe(1); + // And the branch that was not taken is on the screen once, as source. + expect(occurrences(second.terminal, STOPPED)).toBe(1); + + // The same History positions, from the same drawer. + yield* click(second.terminal, "[history]"); + yield* settled(40); + expect(historyMarkers(second.terminal)).toEqual(markers); + yield* click(second.terminal, "[close]"); + yield* settled(20); + + // Nothing was asked of anybody: no provider, no adapter, and no question + // or permission offered to answer a second time. + expect(cold.prompts).toEqual([]); + expect(cold.started).toBe(false); + expect(shows(second.terminal, "[submit]")).toBe(false); + expect(shows(second.terminal, "( ) Approve")).toBe(false); + // And nothing was written: the file the entry wrote is in the first + // process's working directory, and this one has its own. + expect(yield* untilResolved(readdir(elsewhere))).toEqual([]); + second.terminal.end(); + yield* running; + }); + + // Byte for byte the history it opened: reading a run is not appending to it. + expect(yield* history(hostRoot)).toBe(before); + }); + + it("J3: a failed and a cancelled turn restore terminally, with the text they had", function* () { + for (const [what, scripted, status, stopped] of [ + ["failed", { reply: PARTIAL, stopReason: "refusal" }, "failed, recorded", "stopped: refusal"], + ["cancelled", { reply: PARTIAL, cancelled: true }, "cancelled, recorded", undefined], + ] as const) { + const hostRoot = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-journey-"))); + const live = createFakeAcp(); + live.script(scripted); + const first = recordingTerminal({ columns: 200, rows: 60 }); + let location = ""; + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* first.install(); + yield* immediateClock(); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(() => start(live, holds([]), workspace)); + yield* untilDrawn(first.terminal); + yield* typed(first.terminal, ASKING); + // The partial text, and how the turn ended, on the live surface. + yield* showing(first.terminal, PARTIAL); + yield* showing(first.terminal, `· ${status}`); + first.terminal.end(); + location = yield* running; + }); + + // The record holds both facts, and holds them once. + const prompts = (yield* journal(hostRoot)).filter( + (event) => (event.description as { type?: string } | undefined)?.type === "agent_prompt", + ); + expect([what, prompts.length]).toEqual([what, 1]); + const record = (prompts[0]?.result as { value?: Record }).value; + expect([what, record?.status]).toEqual([what, what]); + expect([what, record?.text]).toEqual([what, PARTIAL]); + + // A cold process shows the same terminal facts: the text it streamed and + // the status it ended with, never a turn that is still going. + const cold = createFakeAcp(); + const second = recordingTerminal({ columns: 200, rows: 60 }); + yield* scoped(function* (): Operation { + const elsewhere = yield* useWorkspace(); + yield* second.install(); + yield* immediateClock(); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(() => start(cold, holds([]), elsewhere, location)); + yield* untilDrawn(second.terminal); + yield* showing(second.terminal, PARTIAL); + yield* showing(second.terminal, `· ${status}`); + if (stopped !== undefined) { + yield* showing(second.terminal, stopped); + } + // Never presented as work in progress, and nobody was asked again. + expect([what, shows(second.terminal, "· queued")]).toEqual([what, false]); + expect([what, shows(second.terminal, "· streaming")]).toEqual([what, false]); + expect([what, cold.prompts]).toEqual([what, []]); + expect([what, cold.started]).toEqual([what, false]); + second.terminal.end(); + yield* running; + }); + } + }); + + it("J3: each marker shows only what its own prefix retains", function* () { + const hostRoot = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-journey-"))); + const live = createFakeAcp(); + live.script({ reply: REPLY }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 60 }); + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(() => start(live, holds([]), workspace)); + yield* untilDrawn(terminal); + yield* typed(terminal, ASKING); + yield* showing(terminal, REPLY.trim()); + + // Three real markers, in the order the run reached them: the entry's own + // admission, the turn's publication, and the root settling. + yield* click(terminal, "[history]"); + yield* settled(40); + for (const marker of ["Entry 1 admitted", "Agent prompt completed", "Settled"]) { + expect([marker, shows(terminal, marker)]).toEqual([marker, true]); + } + + // The prefix before the turn was published holds no turn: the entry is + // admitted and nothing has been said. + yield* click(terminal, "Entry 1 admitted"); + yield* settled(40); + expect(shows(terminal, REPLY.trim())).toBe(false); + expect(shows(terminal, "ok? ·")).toBe(false); + + // The drawer stays open on the position it moved to, and now offers only + // that prefix's markers: at `yield:root:0` the turn had not happened, so + // there is nothing about it to select. + expect(maybeLocation(terminal)).toContain("at=yield:root:0"); + expect(shows(terminal, "Agent prompt completed")).toBe(false); + + // Back at the head: the drawer is modal, so it is closed first and then + // the live control is reachable. + yield* click(terminal, "[close]"); + yield* settled(20); + yield* click(terminal, "[live]"); + yield* showing(terminal, REPLY.trim()); + + // The prefix that holds the record holds the turn, with the facts the + // record carries and nothing live. + yield* click(terminal, "[history]"); + yield* settled(40); + yield* click(terminal, "Agent prompt completed"); + yield* settled(40); + yield* showing(terminal, REPLY.trim()); + yield* showing(terminal, "completed, recorded"); + expect(maybeLocation(terminal)).toContain("at=yield:root:3"); + + // Reading a marker asked nobody anything. + expect(live.prompts).toHaveLength(1); + terminal.end(); + yield* running; + }); + }); + + it("J3: a location this host cannot read refuses before any replay", function* () { + const cold = createFakeAcp(); + const { terminal, install } = recordingTerminal(); + + yield* scoped(function* (): Operation { + yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const profile = yield* settledProfile(cold); + + // Malformed: not this grammar at all, so it is refused before a terminal + // is taken, a history file is opened or a provider is named. + const malformed = yield* runReplProgram({ profile, location: "xmd://nope" }); + expect(malformed.ok).toBe(false); + expect(malformed.ok === false && malformed.error.message).toContain( + "a REPL location begins with xmd://repl/", + ); + expect(terminal.presented).toEqual([]); + + // Well formed, and naming an execution this host does not hold. That is a + // readable request about a history that is not here, so the surface says + // so rather than replaying something. + const running = yield* spawn(function* (): Operation { + yield* runReplProgram({ profile, location: "xmd://repl/0123456789abcdef/repl" }); + }); + yield* showing(terminal, "this execution has no history here."); + + // Neither reached a provider, and neither replayed anything: no history + // file was made for a run that never had one. + expect(cold.started).toBe(false); + expect(cold.prompts).toEqual([]); + expect(yield* absentHistory(hostRoot)).toBe(true); + terminal.end(); + yield* running; + }); + }); +}); + +describe("J2 — three conversations at once, through the terminal", () => { + beforeAll(() => useTempFileCompiler()); + + it("J2: complete, streaming and queued are one frame, and filtering changes only Sessions", function* () { + const fake = createFakeAcp(); + // One reply per child, each naming itself so a row can say which turn it + // means on a screen holding three. + for (let turn = 0; turn < 3; turn += 1) { + fake.script({ reply: ANSWERED }); + } + const held = holds([PLAN_IT, REVIEW_IT, BUILD_IT]); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 140 }); + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const running = yield* spawn(() => start(fake, held, workspace)); + yield* untilDrawn(terminal); + yield* typed(terminal, THREE); + + // All three turns are scheduled at once, and each is held where its state + // is decided. Deliberately not in the order the document wrote them: the + // reviewer is observed first, the implementer second, and the planner has + // not been taken at all — so the order these conversations appear in is + // the order they were seen, and cannot be the authored `` order. + yield* showing(terminal, "· queued"); + held.start(REVIEW_IT); + held.deltas(REVIEW_IT); + yield* showing(terminal, `${REVIEW_IT} · completed`); + held.start(BUILD_IT); + + // One frame holding all three states, each named by what its own child + // asked. Read together rather than in three waits: what this row is about + // is a screen, not a sequence. + yield* awaiting("the three states never stood together", function* () { + yield* settled(10); + const rows = screenOf(terminal); + return [`${REVIEW_IT} · completed`, `${BUILD_IT} · streaming`, `${PLAN_IT} · queued`].every( + (state) => rows.some((line) => line.includes(state)), + ); + }); + yield* showing(terminal, "All conversations"); + + // With the implementer's turn recorded too, a conversation control is + // there to filter by while the planner has still not been taken. + held.deltas(BUILD_IT); + // The digest every session key of this run carries, which is how a row is + // recognized as a conversation control on a sidebar too narrow for a key. + const digest = (fake.turns[0]?.handle.sessionKey ?? "").split(":")[2] ?? ""; + expect(digest).not.toBe(""); + yield* awaiting("no conversation was ever offered", function* () { + yield* settled(10); + return conversationRows(terminal, digest).length > 0; + }); + expect(shows(terminal, `${PLAN_IT} · queued`)).toBe(true); + + // Selecting one is the route's own business: it carries that + // conversation's exact provider session key, and the entry column beside + // it is untouched. + const keys = [PLAN_IT, REVIEW_IT, BUILD_IT].map( + (asked) => fake.turns.find((turn) => turn.text.includes(asked))?.handle.sessionKey ?? "", + ); + const unfiltered = locationRow(terminal); + const offered = conversationRows(terminal, digest).length; + yield* click(terminal, conversationRows(terminal, digest)[0] ?? ""); + yield* settled(40); + expect(keys).toContain(sessionOf(terminal)); + // One conversation's turns, and only its own. + expect(childrenShown(terminal)).toHaveLength(1); + // The route gained the session and nothing else: the surface, the entry + // and the history position it was standing on are the same terms. + expect(locationRow(terminal)).toBe(`${unfiltered}?session=${sessionOf(terminal)}`); + expect(shows(terminal, "1. entry-1")).toBe(true); + const standing = locationRow(terminal); + // Where the person is standing now: on the control they just chose. + const focused = focusedRow(terminal); + + // Background work in another conversation: the planner's turn is taken, + // streams and is recorded, while the route, the history position and the + // focused control stay exactly where the person left them. + held.start(PLAN_IT); + held.deltas(PLAN_IT); + yield* awaiting("the third turn was never retained", function* () { + return ( + recorded(yield* journal(hostRoot)).filter((kind) => kind === "agent_prompt").length === 3 + ); + }); + expect(locationRow(terminal)).toBe(standing); + expect(focusedRow(terminal)).toBe(focused); + // And the filter still holds: the others are retained and not shown, + // because this screen is showing one. + expect(childrenShown(terminal)).toHaveLength(1); + + // Cleared back to All, the whole chronology is there again — all three + // children, each with the state its own turn ended in, and one more + // conversation to filter by than before. + yield* click(terminal, "All conversations"); + yield* settled(40); + expect(locationRow(terminal)).not.toContain("session="); + expect(childrenShown(terminal)).toEqual([PLAN_IT, REVIEW_IT, BUILD_IT]); + for (const asked of [PLAN_IT, REVIEW_IT, BUILD_IT]) { + expect([asked, shows(terminal, `${asked} · completed`)]).toEqual([asked, true]); + } + // And one more conversation to filter by than there was, because the + // third one has now been seen. + expect(conversationRows(terminal, digest).length).toBeGreaterThan(offered); + + // Three turns, three records, one per conversation. + yield* awaiting("the three turns were never all retained", function* () { + return ( + recorded(yield* journal(hostRoot)).filter((kind) => kind === "agent_prompt").length === 3 + ); + }); + terminal.end(); + yield* running; + }); + }); +}); + +describe("X1 — how this command ends, with work still in flight", () => { + beforeAll(() => useTempFileCompiler()); + + /** + * One run with a turn the provider has taken and never finishes, so every + * ending below is induced while there is real work to join. + */ + function* inFlight( + terminal: Terminal, + install: () => Operation, + fake: FakeAcp, + ): Operation<{ readonly hostRoot: string; readonly ending: Ending }> { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const ending = yield* commanding(fake, workspace); + yield* untilDrawn(terminal); + yield* typed(terminal, ASKING); + // Taken by the backend and streaming nothing: the turn is in flight, and + // the only way out of it is the ending this row induces. + yield* showing(terminal, "· streaming"); + return { hostRoot, ending }; + } + + /** What one ending left behind. */ + function* ended( + terminal: Terminal, + hostRoot: string, + ending: Ending, + ): Operation<{ readonly before: string[]; readonly after: string[] }> { + void terminal; + const before = recorded(yield* journal(hostRoot)); + yield* until(() => ending.over, "the command never ended"); + // Read after the owner has joined: what a run appends late is exactly what + // this cannot show while it is still going. + yield* settled(40); + return { before, after: recorded(yield* journal(hostRoot)) }; + } + + it("X1: EOF ends the command, joins the turn and appends nothing late", function* () { + const fake = createFakeAcp(); + fake.script({ reply: PARTIAL, manual: true }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 60 }); + + yield* scoped(function* (): Operation { + const { hostRoot, ending } = yield* inFlight(terminal, install, fake); + terminal.end(); + const { before, after } = yield* ended(terminal, hostRoot, ending); + + // The turn was cancelled rather than finished, and it appended nothing: a + // record for work that was interrupted would be a record of something + // that did not happen. + expect(fake.cancels).toBeGreaterThan(0); + expect(after).toEqual(before); + expect(after.filter((kind) => kind === "agent_prompt")).toEqual([]); + // The modes this command took, given back exactly once. + expect(terminal.raw).toEqual([true, false]); + expect(terminal.resets).toBe(1); + }); + }); + + it("X1: a renderer that fails ends the command the same way", function* () { + const fake = createFakeAcp(); + fake.script({ reply: PARTIAL, manual: true }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 60 }); + + yield* scoped(function* (): Operation { + const { hostRoot, ending } = yield* inFlight(terminal, install, fake); + // The screen is gone. A frame cannot be presented, which is not a reason + // to keep running and not a reason to append anything. + terminal.failWrites(new Error("the screen is gone")); + terminal.feed("\t"); + const { before, after } = yield* ended(terminal, hostRoot, ending); + + expect(after).toEqual(before); + expect(after.filter((kind) => kind === "agent_prompt")).toEqual([]); + expect(terminal.raw).toEqual([true, false]); + expect(terminal.resets).toBe(1); + }); + }); + + it("X1: a terminal that fails mid-read ends the command the same way", function* () { + const fake = createFakeAcp(); + fake.script({ reply: PARTIAL, manual: true }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 60 }); + + yield* scoped(function* (): Operation { + const { hostRoot, ending } = yield* inFlight(terminal, install, fake); + terminal.failInput(new Error("the terminal is gone")); + const { before, after } = yield* ended(terminal, hostRoot, ending); + + expect(after).toEqual(before); + expect(terminal.raw).toEqual([true, false]); + expect(terminal.resets).toBe(1); + }); + }); + + it("X1: cancelling the command joins the pending request without answering it", function* () { + const fake = createFakeAcp(); + // A turn that asks for a tool this policy does not approve by itself, so a + // request is waiting on a person when the command is cancelled. + fake.script({ reply: PARTIAL, requestsTool: "rm -rf /", manual: true }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 60 }); + + yield* scoped(function* (): Operation { + const workspace = yield* useWorkspace(); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTemporaryHost(); + const ending = yield* commanding(fake, workspace); + yield* untilDrawn(terminal); + yield* typed(terminal, ASKING); + // A person is being asked. Nothing has answered it. + yield* showing(terminal, "rm -rf /"); + const before = recorded(yield* journal(hostRoot)); + + // The command scope is cancelled — the process is going away. This is not + // a denial: nobody decided anything, and a teardown that answered on the + // person's behalf would be recording a decision they never made. + yield* ending.task.halt(); + yield* until(() => ending.over, "the command never ended"); + yield* settled(40); + + expect(fake.decisions).toEqual([]); + expect(recorded(yield* journal(hostRoot))).toEqual(before); + expect(recorded(yield* journal(hostRoot)).filter((kind) => kind === "agent_prompt")).toEqual( + [], + ); + // And the terminal is given back, once. + expect(terminal.raw).toEqual([true, false]); + expect(terminal.resets).toBe(1); + }); + }); +}); + +/** + * The journey J1 proves, walked without its assertions. + * + * A row that is about what the Journal holds afterwards still has to get there + * the way a person does, so this is the same terminal driving rather than a + * shortcut past it. + */ +function* takeTheJourney( + terminal: Terminal, + fake: FakeAcp, + hostRoot: string, + workspace: string, +): Operation { + terminal.bytes(BYTES.encode(STORY)); + yield* settled(20); + terminal.feed("\r"); + yield* showing(terminal, "Request changes"); + yield* click(terminal, "( ) Request changes"); + yield* settled(40); + yield* click(terminal, "feedback:"); + terminal.bytes(BYTES.encode(FEEDBACK)); + yield* settled(20); + yield* click(terminal, "[submit]"); + yield* until(() => fake.prompts.length >= 2, "the revision was never asked for"); + yield* showing(terminal, "Approve"); + yield* click(terminal, "( ) Approve"); + yield* showing(terminal, "Name the project"); + yield* answer(terminal, "Project name", "Ledger"); + yield* answer(terminal, "One-sentence description", "A tiny ledger."); + yield* submit(terminal); + yield* showing(terminal, "Write this README?"); + yield* click(terminal, "( ) Approve"); + yield* showing(terminal, "Created README.md."); + yield* awaiting("the entry never settled", function* () { + return (yield* journal(hostRoot)).some((event) => event.type === "close"); + }); + expect(yield* untilResolved(readdir(workspace))).toEqual(["README.md"]); +} + +/** The profile this command would assemble, over a provider nothing scripts. */ +function* settledProfile(fake: FakeAcp): Operation { + const stack = yield* resolveAgentStack( + { + agentProvider: "acpx", + defaultAgent: AGENT, + approveAll: false, + approveReads: false, + denyAll: false, + }, + undefined, + ); + if (!stack.ok) { + throw stack.error; + } + return yield* assembleReplProfile(stack.value, NO_PLUGINS, { + acp: { + createRuntime: fake.create, + sessionStore: makeStore(), + agentRegistry: makeRegistry({ [AGENT]: `${AGENT}-cmd` }), + }, + planWriterRoot: yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-plan-"))), + }); +} + +/** Whether this host holds no REPL history at all. */ +function* absentHistory(hostRoot: string): Operation { + const found = yield* untilResolved( + readdir(join(hostRoot, "xmd", "repl")).catch(() => [] as string[]), + ); + return found.length === 0; +} + +/** The one history file this host holds, as text. */ +function* history(hostRoot: string): Operation { + const directory = join(hostRoot, "xmd", "repl"); + const files = yield* untilResolved(readdir(directory)); + return yield* untilResolved(readFile(join(directory, files[0] ?? ""), "utf8")); +} + +/** + * One running command, and whether it is over. + * + * A flag rather than the task's own state, because what every ending row asks is + * the same question — has the owner joined? — and a task that ended by failing + * answers it as much as one that returned. + */ +interface Ending { + readonly task: Task; + readonly over: boolean; +} + +function* commanding(fake: FakeAcp, workspace: string): Operation { + const ending = { task: undefined as unknown as Task, over: false }; + const task = yield* spawn(function* (): Operation { + try { + yield* start(fake, holds([]), workspace); + } catch { + // The ending is what the row is about; how it was reported is the row's + // own business and not this helper's. + } finally { + ending.over = true; + } + }); + ending.task = task; + return ending; +} + +/** Start the program the way the command does, with the profile it assembles. */ +function* start( + fake: FakeAcp, + held: Holds, + workspace: string, + location?: string, +): Operation { + const writerRoot = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-plan-"))); + const profile = yield* assembleReplProfile( + { + provider: "acpx", + defaultAgent: AGENT, + adapters: ADAPTERS, + permissionMode: "approve-reads", + }, + NO_PLUGINS, + { + acp: { + createRuntime: held.create(fake), + sessionStore: makeStore(), + agentRegistry: makeRegistry({ [AGENT]: `${AGENT}-cmd` }), + }, + planWriterRoot: writerRoot, + }, + ); + const ran = yield* runReplProgram({ + profile, + ...(location === undefined ? {} : { location }), + }); + if (!ran.ok) { + throw ran.error; + } + void workspace; + return ran.value.location; +} + +/** + * A temporary working directory every path in a fragment resolves against, with + * the host filesystem this build's entrypoint installs. + * + * Both halves belong to the host rather than to the REPL: `deno.ts`, `node.ts` + * and the compiled entrypoint each install the Files provider before a command + * runs, and the working directory is the caller's own. A row that installed + * neither would prove something about a process no entrypoint assembles. + */ +function* useWorkspace(): Operation { + const root = yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-work-"))); + yield* useHostFiles(); + yield* API.Env.around( + { + // deno-lint-ignore require-yield + *cwd(): Operation { + return root; + }, + }, + // Beneath everything: the ordinary evaluation profile reads the working + // directory when a fragment runs, and a row that let it read the real one + // would write into the checkout. + { at: "min" }, + ); + return root; +} + +/** One promise a test opens by hand. */ +interface Gate { + open(): void; + readonly opened: Promise; +} + +function gate(): Gate { + let release!: () => void; + const opened = new Promise((resolve) => { + release = () => resolve(); + }); + return { open: () => release(), opened }; +} + +/** + * The turns this suite holds, and where. + * + * Two holds per turn, because the states a reader sees are decided by two + * different provider facts: a turn is queued until the backend has taken it, and + * active until it has said what it has to say. Held by what the turn asked, not + * by the order it was asked in — three turns started at once by one `` reach + * the provider in whatever order their coroutines were scheduled, and a row about + * three states at once has to name which turn it means. + */ +interface Holds { + create(fake: FakeAcp): FakeAcp["create"]; + /** Let the turn whose prompt holds this text be taken by the backend. */ + start(asked: string): void; + /** Let that turn stream and settle. */ + deltas(asked: string): void; +} + +function holds(asked: readonly string[]): Holds { + const gates = new Map( + asked.map((text) => [text, { start: gate(), deltas: gate() }]), + ); + const held = (text: string): { readonly start: Gate; readonly deltas: Gate } | undefined => { + for (const [key, gates_] of gates) { + if (text.includes(key)) { + return gates_; + } + } + return undefined; + }; + return { + start: (text) => gates.get(text)?.start.open(), + deltas: (text) => gates.get(text)?.deltas.open(), + create: (fake) => (options) => { + const runtime = fake.create(options); + return { + ...runtime, + startTurn(input) { + const turn = runtime.startTurn(input); + const hold = held(input.text); + if (hold === undefined) { + return turn; + } + const inner = turn.events; + return { + ...turn, + // Held before the backend reports it took the turn, which is what + // the "started" event carries. + materialized: hold.start.opened.then(() => turn.materialized), + events: { + [Symbol.asyncIterator]() { + const events = inner[Symbol.asyncIterator](); + let first = true; + return { + next() { + if (!first) { + return events.next(); + } + first = false; + return hold.deltas.opened.then(() => events.next()); + }, + return: () => + events.return?.() ?? Promise.resolve({ done: true as const, value: undefined }), + }; + }, + }, + }; + }, + }; + }, + }; +} + +/** Every durable record this run wrote, in order, as `type:status`. */ +function* journal(hostRoot: string): Operation[]> { + const directory = join(hostRoot, "xmd", "repl"); + const files = yield* untilResolved(readdir(directory)); + const text = yield* untilResolved(readFile(join(directory, files[0] ?? ""), "utf8")); + return text + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +/** The kinds of the yield records a history holds, in order. */ +function recorded(events: readonly Record[]): string[] { + return events + .filter((event) => event.type === "yield") + .map((event) => String((event.description as { type?: string } | undefined)?.type)); +} + +/** The one generated-XMD admission this run recorded, as the record holds it. */ +function admission(events: readonly Record[]): { + readonly source: string; + readonly named: readonly { readonly name: string; readonly form: string }[]; +} { + const admissions = events.filter( + (event) => + event.type === "yield" && + (event.description as { type?: string } | undefined)?.type === "generated_xmd", + ); + expect(admissions).toHaveLength(1); + const value = (admissions[0]?.result as { value?: Record } | undefined)?.value; + const named = (value?.named ?? []) as { name: string; form: string }[]; + return { + source: String(value?.source), + named: named.map(({ name, form }) => ({ name, form })), + }; +} + +const BYTES = new TextEncoder(); +const DEADLOCK_MS = 30_000; +const TEXT = new TextDecoder(); + +/** Let every task that is ready take its turn. */ +function* settled(turns = 8): Operation { + for (let turn = 0; turn < turns; turn += 1) { + yield* sleep(0); + } +} + +/** A clock the test moves, so nothing in this suite waits on real time. */ +function immediateClock(): Operation { + return ReplClock.around( + { + // deno-lint-ignore require-yield + *now(): Operation { + return 0; + }, + // deno-lint-ignore require-yield + *wait(): Operation { + // Returns at once: this product draws when something changed, so the + // frame interval is the only thing being skipped. + }, + }, + { at: "min" }, + ); +} + +/** Wait for something this run will do that only an operation can read. */ +function* awaiting(what: string, reached: () => Operation): Operation { + const deadline = Date.now() + DEADLOCK_MS; + while (!(yield* reached())) { + if (Date.now() > deadline) { + throw new Error(what); + } + yield* sleep(5); + yield* settled(10); + } +} + +/** Wait for something this run will do, or say what never happened. */ +function* until(holds: () => boolean, what: string): Operation { + const deadline = Date.now() + DEADLOCK_MS; + while (!holds()) { + if (Date.now() > deadline) { + throw new Error(what); + } + yield* sleep(5); + yield* settled(10); + } +} + +/** A terminal the test drives completely. */ +interface Terminal { + /** Everything ever presented, in order. */ + readonly presented: Uint8Array[]; + /** + * When set, the next presentation blocks here until it is released. + * + * The seam the frame-order control needs: while a frame is being written, the + * stream must not have been told that frame was applied. + */ + holdPresent: { release(): void } | undefined; + size: ReplTerminalSize; + readonly raw: boolean[]; + resets: number; + listeners: number; + readers: number; + feed(text: string): void; + bytes(raw: Uint8Array): void; + /** Make the next presentation block, so a test can look at the frame stream. */ + holdNextPresent(): void; + /** Make every later presentation fail, which is how a renderer is lost. */ + failWrites(error: Error): void; + /** Make the byte source fail, which is how a terminal is lost. */ + failInput(error: Error): void; + resized(size: ReplTerminalSize): void; + end(): void; +} + +function recordingTerminal( + size: ReplTerminalSize = { columns: 160, rows: 36 }, + interactive = true, +): { + terminal: Terminal; + install(): Operation; +} { + const queue: Uint8Array[] = []; + const watchers = new Set<() => void>(); + let waiting: ((result: IteratorResult) => void) | undefined; + let ended = false; + + let holding = false; + let writeFailure: Error | undefined; + let inputFailure: Error | undefined; + const terminal: Terminal = { + presented: [], + holdPresent: undefined, + size, + raw: [], + resets: 0, + listeners: 0, + readers: 0, + feed(text: string): void { + terminal.bytes(BYTES.encode(text)); + }, + failWrites(error: Error): void { + writeFailure = error; + }, + failInput(error: Error): void { + inputFailure = error; + const resolve = waiting; + waiting = undefined; + resolve?.({ done: true, value: undefined }); + }, + holdNextPresent(): void { + holding = true; + }, + bytes(raw: Uint8Array): void { + const resolve = waiting; + if (resolve === undefined) { + queue.push(raw); + return; + } + waiting = undefined; + resolve({ done: false, value: raw }); + }, + resized(next: ReplTerminalSize): void { + terminal.size = next; + for (const watcher of watchers) { + watcher(); + } + }, + end(): void { + ended = true; + const resolve = waiting; + if (resolve !== undefined) { + waiting = undefined; + resolve({ done: true, value: undefined }); + } + }, + }; + + const host: ReplTerminalCapabilities = { + interactive: () => interactive, + size: () => terminal.size, + write(bytes: Uint8Array): Promise { + if (writeFailure !== undefined) { + return Promise.reject(writeFailure); + } + terminal.presented.push(new Uint8Array(bytes)); + if (!holding) { + return Promise.resolve(); + } + holding = false; + return new Promise((resolve) => { + terminal.holdPresent = { release: resolve }; + }); + }, + writeNow(): void { + terminal.resets += 1; + }, + setRaw(raw: boolean): void { + terminal.raw.push(raw); + }, + bytes(): AsyncIterable { + return { + [Symbol.asyncIterator](): AsyncIterator { + terminal.readers += 1; + return { + next(): Promise> { + if (inputFailure !== undefined) { + return Promise.reject(inputFailure); + } + const head = queue.shift(); + if (head !== undefined) { + return Promise.resolve({ done: false, value: head }); + } + if (ended) { + return Promise.resolve({ done: true, value: undefined }); + } + return new Promise((resolve) => { + waiting = resolve; + }); + }, + return(): Promise> { + terminal.readers -= 1; + const resolve = waiting; + waiting = undefined; + resolve?.({ done: true, value: undefined }); + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + }, + onResize(listener: () => void): () => void { + watchers.add(listener); + terminal.listeners += 1; + return () => { + watchers.delete(listener); + terminal.listeners -= 1; + }; + }, + }; + + return { terminal, install: () => installReplTerminal(host) }; +} + +/** + * A REPL host over a temporary directory nothing else uses. + * + * The root can be handed back in, which is what a cold row needs: a second + * command scope over the same history file is the only way to prove a screen + * was reconstructed from the Journal rather than from anything this process + * still held. + */ +function* useTemporaryHost(existing?: string): Operation { + const root = existing ?? (yield* untilResolved(mkdtemp(join(tmpdir(), "xmd-repl-journey-")))); + yield* installReplHost({ + dataRoot: () => root, + identify: () => randomBytes(8).toString("hex"), + createExclusive: (path) => open(path, "wx").then((handle) => handle.close()), + appendRecord: (path, record) => appendFile(path, record), + }); + return root; +} + +/** + * What the screen says, by replaying what was written to it. + * + * A real buffer rather than the bytes with escapes stripped, because this + * renderer writes *diffs*: it moves the cursor to what changed and writes only + * that. Concatenating the diffs gives characters in the order they were written + * rather than the order they appear, and a character the previous frame already + * had is not written again at all. + */ +function screenOf(terminal: Terminal): string[] { + const rows: string[][] = []; + let row = 0; + let column = 0; + + const put = (character: string): void => { + while (rows.length <= row) { + rows.push([]); + } + const line = rows[row]!; + while (line.length < column) { + line.push(" "); + } + line[column] = character; + column += 1; + }; + + const written = terminal.presented.map((bytes) => TEXT.decode(bytes)).join(""); + for (let index = 0; index < written.length; index += 1) { + const character = written[index]; + if (character !== "\u001B") { + if (character === "\n") { + row += 1; + column = 0; + } else if (character === "\r") { + column = 0; + } else if (character !== undefined) { + put(character); + } + continue; + } + const csi = /^\u001B\[([0-9;]*)([@-~])/.exec(written.slice(index)); + if (csi !== null) { + const parameters = (csi[1] ?? "").split(";").map((one) => (one === "" ? 0 : Number(one))); + if (csi[2] === "H") { + row = Math.max(0, (parameters[0] ?? 1) - 1); + column = Math.max(0, (parameters[1] ?? 1) - 1); + } else if (csi[2] === "J") { + rows.length = 0; + row = 0; + column = 0; + } + index += csi[0].length - 1; + continue; + } + const osc = /^\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/.exec(written.slice(index)); + if (osc !== null) { + index += osc[0].length - 1; + continue; + } + index += 1; + } + return rows.map((line) => line.join("")); +} + +/** The canonical location the screen is showing, if it has drawn one yet. */ +function maybeLocation(terminal: Terminal): string | undefined { + const rows = screenOf(terminal); + const first = rows.findIndex((line) => line.includes("xmd://repl/")); + if (first === -1) { + return undefined; + } + const at = (rows[first] ?? "").indexOf("xmd://repl/"); + const parts: string[] = []; + for (let row = first; row < rows.length && row < first + 24; row += 1) { + const part = (rows[row] ?? "").slice(at, at + surfaceWidth(terminal.size)); + if (part.trim().length === 0) { + break; + } + parts.push(part.trimEnd()); + } + + const joined = parts.join(""); + for (let length = joined.length; length > "xmd://repl/".length; length -= 1) { + const candidate = joined.slice(0, length); + const decoded = decodeLocation(candidate); + if (decoded.ok && encodeLocation(decoded.value) === candidate) { + return candidate; + } + } + return undefined; +} + +/** + * How many columns the Sessions sidebar owns. + * + * Read here so a row comparing "the entry column did not move" compares the + * band beside the sidebar rather than a number somebody guessed. + */ +const SIDEBAR = 30; + +/** The conversation this screen is filtered by, as the route states it. */ +function sessionOf(terminal: Terminal): string | undefined { + return /session=([^&\s]+)/.exec(locationRow(terminal) ?? "")?.[1]; +} + +/** + * The one row the location is drawn on. + * + * Read as a row rather than reassembled across rows, because the surface column + * has other columns beside it: a location short enough to fit on one line is + * exactly that line, and joining the line below it would append whatever the + * next column happened to hold there. + */ +function locationRow(terminal: Terminal): string | undefined { + const row = screenOf(terminal).find((line) => line.includes("xmd://repl/")); + return row === undefined ? undefined : row.slice(row.indexOf("xmd://repl/")).trimEnd(); +} + +/** + * The conversation rows the Sessions surface offers to filter by, in order. + * + * Found by a digest every one of this run's session keys carries, because the + * row is the key and the sidebar is narrower than one: the selected row also has + * the focus marker written over its first character, so matching the spelling of + * the key's own prefix would find every row except the one a person chose. + */ +function conversationRows(terminal: Terminal, digest: string): string[] { + return screenOf(terminal) + .map((line) => line.slice(0, SIDEBAR).trimEnd()) + .filter((line) => line.includes(digest)) + .map((line) => line.trim()); +} + +/** The positions the open History drawer offers, in order. */ +function historyMarkers(terminal: Terminal): string[] { + const rows = screenOf(terminal).map((line) => line.trimEnd()); + const at = rows.findIndex((line) => line.trim().endsWith("History")); + if (at === -1) { + return []; + } + const found: string[] = []; + for (let row = at + 1; row < rows.length; row += 1) { + const label = (rows[row] ?? "").trim().replace(/^>\s*/, ""); + if (label.length === 0 || label.startsWith("[")) { + break; + } + found.push(label); + } + return found; +} + +/** Which of the three children's turns this screen is showing, in source order. */ +function childrenShown(terminal: Terminal): string[] { + return [PLAN_IT, REVIEW_IT, BUILD_IT].filter((asked) => shows(terminal, `${asked} ·`)); +} + +/** + * How many rows of the screen hold this text. + * + * A count rather than a yes-or-no, because the admitted program's own source is + * on this screen too: the Plan's scope retains it, so a line the program will + * render later is already visible as the source it came from. What says the + * program *ran* that line is a second row holding it. + */ +function occurrences(terminal: Terminal, text: string): number { + return screenOf(terminal).filter((line) => line.includes(text)).length; +} + +/** Every row of the screen holding this text, trimmed, in order. */ +function shown(terminal: Terminal, text: string): string[] { + return screenOf(terminal) + .map((line) => line.trimEnd()) + .filter((line) => line.includes(text)) + .map((line) => line.trim()); +} + +/** One column band of the screen, which is how a row says "this did not move". */ +function column(terminal: Terminal, from: number): string[] { + return screenOf(terminal).map((line) => line.slice(from).trimEnd()); +} + +/** + * The row the focus marker is on, in the sidebar band alone. + * + * The band, because the columns beside it hold a journal that grows: a row read + * across the whole screen would differ between two frames for a reason that has + * nothing to do with where the focus is. + */ +function focusedRow(terminal: Terminal): string | undefined { + return screenOf(terminal) + .map((line) => line.slice(0, SIDEBAR).trimEnd()) + .find((line) => line.trimStart().startsWith(">")); +} + +/** Whether any row of the screen contains this text. */ +function shows(terminal: Terminal, expected: string): boolean { + return screenOf(terminal).some((line) => line.includes(expected)); +} + +/** Wait until the first frame has been drawn. */ +function* untilDrawn(terminal: Terminal): Operation { + yield* until( + () => maybeLocation(terminal) !== undefined, + "the screen never drew its first frame", + ); +} + +/** Wait until the screen shows this text, or say it never did. */ +function* showing(terminal: Terminal, expected: string): Operation { + yield* until(() => shows(terminal, expected), `the screen never showed ${expected}`); +} + +/** Where on the screen one label is, if it is there. */ +function coordinateOf( + terminal: Terminal, + label: string, +): { readonly column: number; readonly row: number } | undefined { + for (const [row, line] of screenOf(terminal).entries()) { + const column = line.indexOf(label); + if (column !== -1) { + return { column, row }; + } + } + return undefined; +} + +/** + * Click one control, by finding it on the screen and pressing there. + * + * The way a person reaches a control without traversing to it: activating by + * pointer needs no focus, so it can reach a control while an execution is still + * moving. The protocol counts from one and the screen counts from zero. + */ +function* click(terminal: Terminal, label: string): Operation { + const at = coordinateOf(terminal, label); + if (at === undefined) { + throw new Error(`no control labelled ${label} is on the screen`); + } + terminal.feed(`\x1b[<0;${at.column + 1};${at.row + 1}M`); + yield* settled(30); +} + +/** + * Type one whole entry and submit it. + * + * The draft is waited for rather than counted: a page-long entry arrives as many + * decoded keys, and Enter means "submit this draft" only once the draft is all + * of it. What says it has settled is the route, which carries the draft. + */ +function* typed(terminal: Terminal, text: string): Operation { + terminal.bytes(BYTES.encode(text)); + const deadline = Date.now() + DEADLOCK_MS; + let last = ""; + let still = 0; + while (still < 3) { + const now = maybeLocation(terminal) ?? ""; + if (now === last && now.includes("draft=")) { + still += 1; + } else { + still = 0; + last = now; + } + if (Date.now() > deadline) { + throw new Error("the draft never settled"); + } + yield* sleep(20); + yield* settled(10); + } + terminal.feed("\r"); + yield* sleep(200); + yield* settled(20); + yield* showing(terminal, "(one entry admitted)"); +} + +/** Type a value into the field this label names. */ +function* answer(terminal: Terminal, label: string, value: string): Operation { + yield* click(terminal, label); + terminal.bytes(BYTES.encode(value)); + yield* settled(20); +} + +/** Submit the open form. */ +function* submit(terminal: Terminal): Operation { + yield* click(terminal, "[submit]"); + yield* settled(40); +} diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index 16856a7a6..d7777c902 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -244,6 +244,57 @@ describe("REPL boundaries: what production code cannot reach", () => { expect(packaged).toContain('"@bomb.sh/tty": "0.9.0"'); }); + it("S1: no production REPL module retains a provider's raw request", function* () { + // `rawInput` is whatever an agent handed the adapter. The surface shows a + // request's title and the options it offered; keeping the raw payload would + // put an agent's own text into this process's state and into a frame. + for (const path of yield* authored()) { + const text = yield* code(path); + expect([path.slice(CLI.length), text.includes("rawInput")]).toEqual([ + path.slice(CLI.length), + false, + ]); + } + }); + + it("S1: the profile installs no readline policy, no launcher and no browser form", function* () { + const text = yield* code(join(CLI, "src", "repl-profile.ts")); + // The REPL's policy is the session's own, and it owns the terminal it is + // drawing on. Each of these is the `xmd run` half this command must not + // inherit, and the check is the source rather than a behaviour, because what + // is claimed is that it cannot reach them. + for (const forbidden of [ + "installRunAgentStack", + "installPermissionMode", + "installForegroundLauncher", + "installWebElicitation", + "readline", + ]) { + expect([forbidden, text.includes(forbidden)]).toEqual([forbidden, false]); + } + // And it does install the two halves it owns. + expect(text).toContain("installAgentProviderStack"); + expect(text).toContain("planComponentDeclaration"); + }); + + it("S1: the profile check rejects a profile that installed one", function* () { + // The same scan against text that deliberately reaches for the launcher, so + // a green row above cannot be green by matching nothing. + const seeded = [ + "// a comment mentioning installForegroundLauncher, which is prose", + 'import { installForegroundLauncher } from "@executablemd/runtime";', + "export function* assemble() {", + " yield* installForegroundLauncher();", + "}", + ].join("\n"); + const stripped = seeded + .replace(/\/\*[\s\S]*?\*\//g, "") + .split("\n") + .map((line) => line.replace(/(^|\s)\/\/.*$/, "")) + .join("\n"); + expect(stripped.includes("installForegroundLauncher")).toBe(true); + }); + it("S1: no REPL record type exists in production", function* () { // Every retained line is an ordinary durable event. A record shape of this // slice's own would be a second protocol nobody else can read. @@ -263,17 +314,58 @@ describe("REPL documentation: what it says is what the code does", () => { for (const rest of commands) { // Stripped of the shell's quoting and of the comment beside it, which is - // prose rather than argv. - const argument = rest + // prose rather than argv. What is left is read the way the command line + // is: option tokens are options, and at most one other token is the + // location. + const tokens = rest .replace(/#.*$/, "") .trim() - .replace(/^'(.*)'$/, "$1"); - const args = argument === "" ? ["repl"] : ["repl", argument]; - const location = argument === "" ? undefined : argument; + .split(/\s+/) + .filter((token) => token.length > 0) + .map((token) => token.replace(/^'(.*)'$/, "$1")); + const positional = tokens.filter((token) => !token.startsWith("-")); + expect([rest, positional.length]).toEqual([rest, Math.min(positional.length, 1)]); // A placeholder is not a location; what is being checked is the shape. - const concrete = location?.replace("", "kf39sla2"); - expect(replGrammarError(args, concrete)).toBeUndefined(); + const concrete = positional[0]?.replace("", "kf39sla2"); + const args = ["repl", ...tokens.map((token) => token.replace("", "kf39sla2"))]; + expect([rest, replGrammarError(args, concrete)]).toEqual([rest, undefined]); + } + }); + + it("D1: the five options the spec names are the five the parser accepts", function* () { + const spec = yield* read(join(CLI, "..", "..", "specs", "repl-spec.md")); + // Every option the specification names, from its own prose. + const named = [...spec.matchAll(/`(--[a-z-]+)`/g)].map((match) => match[1]); + expect([...new Set(named)].sort()).toEqual([ + "--agent-provider", + "--approve-all", + "--approve-reads", + "--default-agent", + "--deny-all", + ]); + + // Each is one the parser accepts, in both spellings a value option has. + for (const option of ["--approve-all", "--approve-reads", "--deny-all"]) { + expect([option, replGrammarError(["repl", option], undefined)]).toEqual([option, undefined]); + expect(replGrammarError(["repl", `${option}=yes`], undefined)).toContain("takes no value"); + } + for (const option of ["--agent-provider", "--default-agent"]) { + expect([option, replGrammarError(["repl", option, "acpx"], undefined)]).toEqual([ + option, + undefined, + ]); + expect([option, replGrammarError(["repl", `${option}=acpx`], undefined)]).toEqual([ + option, + undefined, + ]); + expect(replGrammarError(["repl", option], undefined)).toContain("requires a value"); } + + // And there is no sixth: an option the specification does not name is + // refused by the name it was written as. + expect(replGrammarError(["repl", "--include", "x"], undefined)).toContain( + "unrecognized option for xmd repl: --include", + ); }); it("D1: the spec's route grammar is the one the codec implements", function* () { diff --git a/packages/cli/tests/repl-journey.test.ts b/packages/cli/tests/repl-journey.test.ts index 2ec6a040e..775728a5b 100644 --- a/packages/cli/tests/repl-journey.test.ts +++ b/packages/cli/tests/repl-journey.test.ts @@ -32,6 +32,7 @@ import type { ReplTerminalSize } from "../src/repl/terminal.ts"; import { ReplClock } from "../src/repl/frame.ts"; import { initialState, NO_AGENT, reduceRepl } from "../src/repl/application.ts"; import { runReplProgram } from "../src/repl/program.ts"; +import type { ReplExecutionProfile } from "../src/repl-profile.ts"; import type { ReplOutcome } from "../src/repl/program.ts"; import { parseDurableEvent, serializeDurableEvent } from "@executablemd/durable-streams"; import { drawerWidth, NARROW, surfaceWidth } from "../src/repl/layout.ts"; @@ -63,6 +64,20 @@ const TEXT = new TextDecoder(); /** What this host installs around the reference entry. */ const INSTALLATIONS = [{ evaluation: ordinaryEvaluationProfile() }]; +/** + * The profile this suite's REPL runs under. + * + * One value rather than two arguments, because the program takes one: what an + * execution may resolve and how it answers a permission request are settled by a + * command before a terminal exists. This suite runs no Agent, so the mode is the + * one an unconfigured command settles. + */ +const PROFILE: ReplExecutionProfile = { + includes: [REFERENCE_DIRECTORY], + installations: INSTALLATIONS, + permissionMode: "deny-all", +}; + /** What the reference entry's eval publishes, as the model retains it. */ const PLAN = { title: "Ship the REPL", steps: 2 }; @@ -709,10 +724,7 @@ describe("REPL journey: one entry, from raw bytes", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -763,10 +775,7 @@ describe("REPL journey: one entry, from raw bytes", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -816,10 +825,7 @@ describe("REPL journey: one entry, from raw bytes", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -878,10 +884,7 @@ describe("REPL journey: one entry, from raw bytes", () => { root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -978,8 +981,7 @@ describe("REPL journey: one entry, from raw bytes", () => { const running = yield* spawn(function* (): Operation { const ran = yield* runReplProgram({ location, - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); if (!ran.ok) { throw ran.error; @@ -1030,10 +1032,7 @@ describe("REPL journey: one entry, from raw bytes", () => { let outcome: ReplOutcome | undefined; const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1082,10 +1081,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1160,10 +1156,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1201,10 +1194,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1261,8 +1251,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { const running = yield* spawn(function* (): Operation { const ran = yield* runReplProgram({ location: "xmd://repl/broken/repl", - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); refused = !ran.ok; }); @@ -1292,7 +1281,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { yield* immediateClock(); const root = yield* useTemporaryHost(); - const ran = yield* runReplProgram({ location: "xmd://repl//repl" }); + const ran = yield* runReplProgram({ location: "xmd://repl//repl", profile: PROFILE }); expect(ran.ok).toBe(false); // No directory was formed, no file was created, and the terminal's modes @@ -1313,10 +1302,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { let outcome: ReplOutcome | undefined; const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1351,10 +1337,7 @@ describe("REPL journey: what it refuses, and what it leaves alone", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1395,10 +1378,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { const terminal = first.terminal; const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1577,8 +1557,7 @@ describe("REPL journey: the whole of it, from raw bytes", () => { const running = yield* spawn(function* (): Operation { const ran = yield* runReplProgram({ location: captured, - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); if (!ran.ok) { throw ran.error; @@ -1643,10 +1622,7 @@ describe("REPL journey: when a frame counts as applied", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1709,10 +1685,7 @@ describe("REPL journey: the same product at every size", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1767,10 +1740,7 @@ describe("REPL journey: the same product at every size", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1835,10 +1805,7 @@ describe("REPL journey: the same product at every size", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1883,10 +1850,7 @@ describe("REPL journey: what it settles before it acts", () => { // is caught as a command still running instead of as a hanging test. let ran: Result | undefined; const running = yield* spawn(function* (): Operation { - ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + ran = yield* runReplProgram({ profile: PROFILE }); }); yield* settled(60); @@ -1928,10 +1892,7 @@ describe("REPL journey: what it settles before it acts", () => { retained = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -1987,8 +1948,7 @@ describe("REPL journey: what it settles before it acts", () => { const running = yield* spawn(function* (): Operation { outcome = yield* runReplProgram({ location: mistyped, - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); }); // A refusal screen, not a reconstruction: waited for by what it says @@ -2026,10 +1986,7 @@ describe("REPL journey: what it settles before it acts", () => { retained = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -2090,8 +2047,7 @@ describe("REPL journey: what it settles before it acts", () => { const running = yield* spawn(function* (): Operation { outcome = yield* runReplProgram({ location: stale, - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); }); yield* until_(second.terminal, "the refusal", (t) => shows(t, "nothing is being asked")); @@ -2142,8 +2098,7 @@ describe("REPL journey: what it settles before it acts", () => { const running = yield* spawn(function* (): Operation { outcome = yield* runReplProgram({ location: `xmd://repl/unclosed/repl/${model.entry?.key ?? ""}/+elicit`, - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, + profile: PROFILE, }); }); yield* until_(terminal, "the refusal", (t) => shows(t, "nothing is being asked")); @@ -2169,10 +2124,7 @@ describe("REPL journey: what it settles before it acts", () => { const root = yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -2222,10 +2174,7 @@ describe("REPL journey: what it settles before it acts", () => { yield* useTemporaryHost(); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } @@ -2328,10 +2277,7 @@ describe("REPL journey: output nothing records still reaches the screen", () => }); const running = yield* spawn(function* (): Operation { - const ran = yield* runReplProgram({ - includes: [REFERENCE_DIRECTORY], - installations: INSTALLATIONS, - }); + const ran = yield* runReplProgram({ profile: PROFILE }); if (!ran.ok) { throw ran.error; } diff --git a/packages/cli/tests/repl-model.test.ts b/packages/cli/tests/repl-model.test.ts index b26e2c734..5f8c91f72 100644 --- a/packages/cli/tests/repl-model.test.ts +++ b/packages/cli/tests/repl-model.test.ts @@ -785,3 +785,429 @@ describe("REPL model: the Agent histories it refuses", () => { expect(projected(events).turns).toHaveLength(4); }); }); + +/** + * Tier M3 — the scopes a declared component and its generated program own. + * + * Hand-built journals rather than executed ones, because what is under test is + * the reader: these rows state the exact records a run writes for a host-declared + * Markdown component and for the fragment it produced, and then doctor one member + * at a time. A model assembled from a guess would show a Plan's own turns and + * questions inside the entry that never asked them. + */ +const POSITION = "executablemd.source-position"; +const SCHEMA_FIELD = "executablemd.elicitation-schema"; +const PLAN_ORIGIN = "@executablemd/cli/Plan.md"; +const PLAN_SOURCE = "# Plan\n\nThe packaged Plan's own bytes.\n"; +const ENTRY_SOURCE = "do the thing\n"; + +/** One recorded effect, as the runtime writes it. */ +function yielded( + description: { [key: string]: Json }, + value: Json, + position?: { [key: string]: Json }, + coroutine = "root", +): DurableEvent { + return { + type: "yield", + coroutineId: coroutine, + description: position === undefined ? description : { ...description, [POSITION]: position }, + result: { status: "ok", value }, + } as DurableEvent; +} + +/** Where an effect inside the packaged Plan's own source is recorded. */ +function insidePlan(): { [key: string]: Json } { + return { path: PLAN_ORIGIN, offset: 12, line: 4, column: 1 }; +} + +/** Where an effect inside generated source is recorded: a place, and no path. */ +function insideGenerated(): { [key: string]: Json } { + return { offset: 58, line: 4, column: 1 }; +} + +/** The closed `declared-markdown` selection a host's own component records. */ +function declared(overrides: { [key: string]: Json } = {}): Json { + return { + kind: "declared-markdown", + origin: PLAN_ORIGIN, + digest: "b2c3", + content: PLAN_SOURCE, + ...overrides, + }; +} + +/** + * One entry that admits ``, which then asks one question of its own. + * + * The shape every row below starts from: the entry's own source, the import that + * admitted the declared component, and an effect recorded inside that + * component's source. + */ +function planJournal( + selection: Json = declared(), + options: { readonly twice?: boolean } = {}, +): DurableEvent[] { + const admission = yielded({ type: "import_component", name: "Plan" }, selection, { + path: "", + offset: 0, + line: 1, + column: 1, + }); + return [ + yielded( + { type: "import_component", name: "__root__" }, + { + kind: "file", + path: "", + content: ENTRY_SOURCE, + }, + ), + admission, + ...(options.twice === true ? [admission] : []), + yielded( + { type: "elicit", name: `elicit:${PLAN_ORIGIN}:4:1`, [SCHEMA_FIELD]: { type: "object" } }, + { decision: "Approve" }, + insidePlan(), + ), + ]; +} + +describe("REPL model: the declared sources it owns", () => { + it("M3: a declared component's retained origin and bytes become its scope", function* () { + const model = projected(planJournal()); + const entry = model.entry; + if (entry === undefined) { + throw new Error("the journal admits one entry"); + } + + // The scope the import created, under the origin the record retained — the + // path every effect inside that component is recorded at — holding the exact + // bytes the record carried. + const plan = child(entry, "Plan-1"); + expect(plan.kind).toBe("component"); + expect(plan.path).toBe(PLAN_ORIGIN); + expect(plan.source).toBe(PLAN_SOURCE); + // And the question asked inside it belongs to it, not to the entry. + expect(plan.elicitations.map((one) => one.location)).toEqual([`${PLAN_ORIGIN}:4:1`]); + expect(entry.elicitations).toEqual([]); + yield* useTempFileCompiler(); + }); + + it("M3: the optional exact disposition is read, and only as `true`", function* () { + const exact = projected(planJournal(declared({ exact: true }))); + const entry = exact.entry; + if (entry === undefined) { + throw new Error("the journal admits one entry"); + } + expect(child(entry, "Plan-1").source).toBe(PLAN_SOURCE); + + // Anything else under that name is a record this version cannot read, so + // nothing owns what was recorded inside it. + expect(refusal(planJournal(declared({ exact: false })))).toContain("never admitted"); + expect(refusal(planJournal(declared({ exact: "yes" })))).toContain("never admitted"); + yield* useTempFileCompiler(); + }); + + it("M3: a missing, malformed, repointed or duplicated source owns nothing", function* () { + // Missing: the selection retains no bytes at all. + expect(refusal(planJournal({ kind: "declared-markdown", origin: PLAN_ORIGIN }))).toContain( + "never admitted", + ); + // Malformed: a member this version does not know, beside the four it does. + expect(refusal(planJournal(declared({ mode: "loose" })))).toContain("never admitted"); + // Malformed: a member it knows, written as something else. + expect(refusal(planJournal(declared({ digest: 3 })))).toContain("never admitted"); + // Repointed: the component admitted under one origin, and the effect + // recorded under another. + expect(refusal(planJournal(declared({ origin: "@executablemd/cli/Other.md" })))).toContain( + "never admitted", + ); + // Duplicated: two admissions of the same origin, so which one owns the + // question cannot be decided. + expect(refusal(planJournal(declared(), { twice: true }))).toContain("more than once"); + yield* useTempFileCompiler(); + }); + + it("M3: a position naming both a path and a generated fragment is malformed", function* () { + // Two answers to "where was this written". A record carrying both is damage, + // and reading either of them would be choosing which damage to believe. + expect( + refusal([ + ...planJournal(), + yielded( + { type: "elicit", name: "elicit:4:1", [SCHEMA_FIELD]: { type: "object" } }, + { a: "x" }, + { path: PLAN_ORIGIN, generatedSource: "gen-1", offset: 1, line: 4, column: 1 }, + ), + ]), + ).toContain("cannot read"); + yield* useTempFileCompiler(); + }); +}); + +/** + * Tier GS — the generated fragment a pathless effect belongs to. + * + * Generated source is not a file, so the work inside it is recorded with the + * identity of the admission that decided that source. These rows state that + * identity's exact behaviour: which candidate it chooses, and every way a history + * can fail to name one. + */ + +/** One admitted fragment, as its own record and its own scope. */ +function admission( + id: string, + source: string, + coroutine = "root", + position: { [key: string]: Json } = insidePlan(), +): DurableEvent { + return yielded( + { type: "generated_xmd", name: `generated:${id}` }, + { decision: "admitted", source }, + position, + coroutine, + ); +} + +/** One question asked inside generated source, which names its own fragment. */ +function askedInside( + id: string, + where: { readonly line: number; readonly coroutine?: string } = { line: 4 }, +): DurableEvent { + return yielded( + { type: "elicit", name: `elicit:${where.line}:1`, [SCHEMA_FIELD]: { type: "object" } }, + { a: "x" }, + { generatedSource: id, offset: 58, line: where.line, column: 1 }, + where.coroutine ?? "root", + ); +} + +/** The entry, and the Plan scope every fragment below is admitted inside. */ +function scopes(model: ReplModel): ReplScope { + const entry = model.entry; + if (entry === undefined) { + throw new Error("the journal admits one entry"); + } + return child(entry, "Plan-1"); +} + +describe("REPL model: which generated fragment owns pathless work", () => { + it("GS1: one fragment's own work projects under it", function* () { + const model = projected([ + ...planJournal(), + admission("gen-a", "ask\n"), + askedInside("gen-a"), + ]); + + const fragment = child(scopes(model), "generated-1"); + expect(fragment.kind).toBe("generated"); + expect(fragment.source).toBe("ask\n"); + expect(fragment.elicitations.map((one) => one.location)).toEqual(["4:1"]); + yield* useTempFileCompiler(); + }); + + it("GS2: two sequential fragments become the scopes their own ids name", function* () { + // Both asked at their own 4:1, so the effect names and the line and column + // collide exactly. What tells them apart is the identity each carries. + const model = projected([ + ...planJournal(), + admission("gen-a", "first\n"), + askedInside("gen-a"), + admission("gen-b", "second\n"), + askedInside("gen-b"), + ]); + + const plan = scopes(model); + const first = child(plan, "generated-1"); + const second = child(plan, "generated-2"); + expect([first.source, second.source]).toEqual(["first\n", "second\n"]); + expect(first.elicitations.map((one) => one.answer)).toEqual([{ a: "x" }]); + expect(second.elicitations.map((one) => one.answer)).toEqual([{ a: "x" }]); + expect(first.elicitations).toHaveLength(1); + expect(second.elicitations).toHaveLength(1); + + // And the second fragment's work does not belong to the first even though + // the first was admitted earlier on the same coroutine. + const crossed = projected([ + ...planJournal(), + admission("gen-a", "first\n"), + admission("gen-b", "second\n"), + askedInside("gen-b"), + askedInside("gen-a"), + ]); + const plans = scopes(crossed); + expect(child(plans, "generated-1").elicitations).toHaveLength(1); + expect(child(plans, "generated-2").elicitations).toHaveLength(1); + yield* useTempFileCompiler(); + }); + + it("GS3: sibling admissions keep their own work whatever order they settle in", function* () { + // Two spawned children, each admitting its own fragment, and the second one + // finishing first. + const model = projected([ + ...planJournal(), + admission("gen-left", "left\n", "root.0"), + admission("gen-right", "right\n", "root.1"), + askedInside("gen-right", { line: 4, coroutine: "root.1" }), + askedInside("gen-left", { line: 4, coroutine: "root.0" }), + ]); + + const plan = scopes(model); + expect(child(plan, "generated-1").source).toBe("left\n"); + expect(child(plan, "generated-2").source).toBe("right\n"); + expect(child(plan, "generated-1").elicitations).toHaveLength(1); + expect(child(plan, "generated-2").elicitations).toHaveLength(1); + + // Neither sibling's work reaches the other's scope, so a coroutine that is + // not the admission's and not beneath it owns nothing. + expect( + refusal([ + ...planJournal(), + admission("gen-left", "left\n", "root.0"), + askedInside("gen-left", { line: 4, coroutine: "root.1" }), + ]), + ).toContain("is not part of"); + yield* useTempFileCompiler(); + }); + + it("GS4: a fragment that spawns keeps the work its descendants performed", function* () { + const model = projected([ + ...planJournal(), + admission("gen-a", "…\n"), + askedInside("gen-a", { line: 4, coroutine: "root.0" }), + askedInside("gen-a", { line: 7, coroutine: "root.1.0" }), + ]); + + const fragment = child(scopes(model), "generated-1"); + expect(fragment.elicitations.map((one) => one.location)).toEqual(["4:1", "7:1"]); + + // Ancestry is by segment, not by prefix: `root.1` encloses `root.1.0` and + // has nothing to do with `root.10`. + expect( + refusal([ + ...planJournal(), + admission("gen-a", "spawned\n", "root.1"), + askedInside("gen-a", { line: 4, coroutine: "root.10" }), + ]), + ).toContain("is not part of"); + const nested = projected([ + ...planJournal(), + admission("gen-a", "spawned\n", "root.1"), + askedInside("gen-a", { line: 4, coroutine: "root.1.0" }), + ]); + expect(child(scopes(nested), "generated-1").elicitations).toHaveLength(1); + yield* useTempFileCompiler(); + }); + + it("GS5: every way a history names no one fragment refuses whole", function* () { + // No admission at all. + expect(refusal([...planJournal(), askedInside("gen-a")])).toContain("never admitted"); + + // An identity that is present and empty, and one that is not a string. + const malformed: readonly Json[] = ["", 3]; + for (const identity of malformed) { + expect( + refusal([ + ...planJournal(), + admission("gen-a", "first\n"), + yielded( + { type: "elicit", name: "elicit:4:1", [SCHEMA_FIELD]: { type: "object" } }, + { a: "x" }, + { generatedSource: identity, offset: 58, line: 4, column: 1 }, + ), + ]), + ).toContain("cannot read"); + } + + // Neither a path nor an identity, where an owned effect needs one. + expect( + refusal([ + ...planJournal(), + admission("gen-a", "first\n"), + yielded( + { type: "elicit", name: "elicit:4:1", [SCHEMA_FIELD]: { type: "object" } }, + { a: "x" }, + { offset: 58, line: 4, column: 1 }, + ), + ]), + ).toContain("neither a source path nor the generated fragment"); + + // Two admissions under one identity. + expect( + refusal([ + ...planJournal(), + admission("gen-a", "first\n"), + admission("gen-a", "again\n"), + askedInside("gen-a"), + ]), + ).toContain("more than once"); + + // An admission that happens afterwards owns nothing that ran before it. + expect( + refusal([...planJournal(), askedInside("gen-a"), admission("gen-a", "first\n")]), + ).toContain("only afterwards"); + + // A refused admission creates no scope, so its id owns nothing. + expect( + refusal([ + ...planJournal(), + yielded( + { type: "generated_xmd", name: "generated:gen-a" }, + { decision: "refused", construct: "block" }, + insidePlan(), + ), + askedInside("gen-a"), + ]), + ).toContain("never admitted"); + + // An admission this version cannot read refuses before anything is owned. + expect( + refusal([ + ...planJournal(), + yielded( + { type: "generated_xmd", name: "generated:gen-a" }, + { decision: "maybe" }, + insidePlan(), + ), + askedInside("gen-a"), + ]), + ).toContain("generated fragment cannot be read"); + + // An admission whose durable name carries no identity owns nothing either. + expect( + refusal([ + ...planJournal(), + yielded( + { type: "generated_xmd", name: "generated:" }, + { decision: "admitted", source: "first\n" }, + insidePlan(), + ), + askedInside("gen-a"), + ]), + ).toContain("does not name the admission it is"); + yield* useTempFileCompiler(); + }); + + it("GS6: projecting the same history twice reproduces the same hierarchy", function* () { + const events = [ + ...planJournal(), + admission("gen-a", "first\n"), + askedInside("gen-a"), + admission("gen-b", "second\n"), + askedInside("gen-b", { line: 9 }), + ]; + // The protocol's own text form, so what a cold process reads is what this + // read: the same ownership from the same bytes, with nothing live involved. + const one = projected(events); + const two = projected(copied(events)); + + expect(two.transcript).toEqual(one.transcript); + expect(two.checkpoints).toEqual(one.checkpoints); + expect(scopes(two).scopes.map((scope) => [scope.key, scope.source])).toEqual( + scopes(one).scopes.map((scope) => [scope.key, scope.source]), + ); + expect(scopes(two).scopes.map((scope) => scope.elicitations.length)).toEqual([1, 1]); + yield* useTempFileCompiler(); + }); +}); diff --git a/packages/cli/tests/support/fake-acp.ts b/packages/cli/tests/support/fake-acp.ts index a0e9401ed..c819a7d62 100644 --- a/packages/cli/tests/support/fake-acp.ts +++ b/packages/cli/tests/support/fake-acp.ts @@ -93,6 +93,16 @@ export interface ScriptedTurn { * how a partial reply is driven without an adapter that misbehaves on purpose. */ readonly stopReason?: string; + /** + * Whether the backend reports this turn cancelled after it has streamed. + * + * Distinct from `manual`: that turn never settles and is the one a test + * interrupts, while this one settles the way an adapter reports a turn the + * backend cancelled — keeping whatever text it had already streamed. It is how + * a cancelled Prompt *record* is produced, which teardown cannot do: a run + * that is being torn down appends nothing. + */ + readonly cancelled?: boolean; /** * The App Server turn this reply is, as an adapter that names its turns says * it: on this exact response's own `_meta`. @@ -387,12 +397,21 @@ export function createFakeAcp(): FakeAcp { const asked = permissionAsked(options, input, turn, (outcome) => { decisions.push(outcome); }); + // Observed here, as the provider's own deferreds are: a host that is + // torn down while a request is pending rejects the operation behind + // this promise, and an unobserved rejection would fail the runner + // instead of being the ending the case is about. + asked.catch(() => {}); if (turn.manual) { // Nothing settles it. `cancel()` is the only way out. released.resolve(); } else { void asked.then(() => { + if (turn.cancelled === true) { + settled.resolve({ status: "cancelled" }); + return; + } settled.resolve({ status: "completed", stopReason: turn.stopReason ?? "end_turn", diff --git a/packages/core/host.ts b/packages/core/host.ts index cda04dd30..0fed6dc88 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -149,6 +149,7 @@ export { fileDeleteEntry, fileReadEntry, fileWriteEntry, + elicitWriteEntry, globReadEntry, syntaxReadEntry, } from "./src/evaluation-profile.ts"; diff --git a/packages/core/src/components/Elicit.ts b/packages/core/src/components/Elicit.ts index 8f190e74d..d76146bb5 100644 --- a/packages/core/src/components/Elicit.ts +++ b/packages/core/src/components/Elicit.ts @@ -47,7 +47,7 @@ import { refuseChangedQuestion, } from "../elicit-journal.ts"; import { prepareElicitation, runPreparedElicitation } from "../elicit.ts"; -import type { Json } from "../types.ts"; +import type { Json, PropsSchema, ReturnsSchema } from "../types.ts"; import type { Expansion } from "../expansion.ts"; export const props = { @@ -59,7 +59,7 @@ export const props = { }, required: ["schema"], additionalProperties: false, -}; +} satisfies PropsSchema; /** * Any JSON value, because the author's `schema` is the real contract and core @@ -70,7 +70,9 @@ export const props = { * object's properties (§5.1.1), so a bare `{}` would declare an object with no * properties — the opposite of any value. */ -export const returns = { $schema: "http://json-schema.org/draft-07/schema#" }; +export const returns = { + $schema: "http://json-schema.org/draft-07/schema#", +} satisfies ReturnsSchema; export default function* Elicit(props: Record): Operation { const prepared = yield* prepareElicitation(props.schema); diff --git a/packages/core/src/components/component-resolution.ts b/packages/core/src/components/component-resolution.ts index 39393220a..22f5d131c 100644 --- a/packages/core/src/components/component-resolution.ts +++ b/packages/core/src/components/component-resolution.ts @@ -732,6 +732,9 @@ export class CanonicalImports { function capturePosition(position: Readonly): Readonly { return Object.freeze({ ...(position.path === undefined ? {} : { path: position.path }), + ...(position.generatedSource === undefined + ? {} + : { generatedSource: position.generatedSource }), offset: position.offset, line: position.line, column: position.column, diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index a9fa2be55..59d4aca51 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -15,7 +15,7 @@ * having to install them. */ -import type { ComponentRegistry, RegistryEntry } from "../types.ts"; +import type { ComponentRegistry, FunctionComponentDefinition, RegistryEntry } from "../types.ts"; import { form as codeBlockForm, props as codeBlockProps } from "./CodeBlock.ts"; import Elicit, { props as elicitProps, returns as elicitReturns } from "./Elicit.ts"; import TempDir, { props as tempDirProps } from "./TempDir.ts"; @@ -110,9 +110,32 @@ function core( ]; } +/** + * The one name canonical core registers `` under. + * + * Stated here rather than spelled at each use, because two places depend on it + * being exactly this: the registration below, and the evaluation-profile entry + * that admits core's own answer for it to a generated fragment. + */ +export const ELICIT_COMPONENT = "Elicit"; + +/** + * The exact definition canonical core registered for this name. + * + * Read by evaluation-profile capture, and by nothing else: a component-answer + * entry naming core's own identity is admitted only when what the import chain + * produced *is* this object. A registration a nested scope layered over, a + * repository component of the same name and a middleware's replacement are all + * other objects, so none of them receives the grant. + */ +export function coreComponentDefinition(name: string): FunctionComponentDefinition | undefined { + const entry = CORE_REGISTRY.get(name)?.default; + return entry === undefined ? undefined : entry.definition; +} + export const CORE_REGISTRY: ComponentRegistry = new Map([ core( - "Elicit", + ELICIT_COMPONENT, Elicit, parseJsonObject(elicitProps), { diff --git a/packages/core/src/evaluation-profile.ts b/packages/core/src/evaluation-profile.ts index 3ab187551..9531e2fb9 100644 --- a/packages/core/src/evaluation-profile.ts +++ b/packages/core/src/evaluation-profile.ts @@ -38,7 +38,7 @@ import type { Operation } from "effection"; -import { CORE_ORIGIN } from "./components/registry.ts"; +import { CORE_ORIGIN, ELICIT_COMPONENT } from "./components/registry.ts"; import { SYNTAX_COMPONENT } from "./components/Syntax.ts"; import { CAPABILITY_FORMS, @@ -335,6 +335,37 @@ export function syntaxReadEntry(): ComponentAnswerEntry { }; } +/** + * Canonical ``, admitted as a question a generated fragment may ask. + * + * A capability, not a component answer. What core owns here is the *behavior*, + * so the body a fragment reaches is pinned the way ``'s is rather than + * resolved by name: the ordinary import chain answers for the authored element, + * where a repository file, a registration, a declaration and the workflow's own + * suspension replacement all legitimately shadow it, and none of them is what a + * generated fragment was admitted to run. + * + * Resolving it was also the wrong *time*. An admitted component answer is + * resolved for every execution the profile belongs to, before any document code + * — so a name a host had quite properly shadowed made executions that never ask + * anything refuse, and a document holding only `` was refused for an + * entry it never wrote. Nothing is resolved now: preflight selects this entry + * only where it finds an `` occurrence, and what it selects is core's. + * + * In the write table and not the read one. Asking a person is not reading: + * `allow={["read"]}` admits what a fragment may look at without anyone being + * interrupted, and a question is an interaction whose answer then drives what + * the fragment writes. A program a person is asked to approve before it writes + * is the case this exists for, and a `read` fragment that could ask would be + * able to interrupt on a permission that promised it would not. + * + * Paired only. The question is the content, so a self-closing spelling would be + * a request with nothing in it. + */ +export function elicitWriteEntry(): CapabilityEntry { + return coreEntry(ELICIT_COMPONENT, ELICIT_COMPONENT, "elicit:ask"); +} + /** Core's `…`, admitted to write and not to read. */ export function fileWriteEntry(): CapabilityEntry { return coreEntry("File", "File:write", "file:write"); @@ -398,6 +429,9 @@ export function fetchEntry(requests: readonly GeneratedRequest[]): CapabilityEnt /** What core's own entries tell an agent they do. */ const CORE_DESCRIPTIONS: Readonly> = Object.freeze({ + "elicit:ask": + "Ask a person a structured question and bind the validated answer. Written paired, with " + + "the request as its content.", "file:read": "Read one file and render its text. Written self-closing.", "files:glob": "Select the files under the working directory that these patterns match, as a sorted " + @@ -431,6 +465,10 @@ const CORE_LEGACY: Readonly> = Obj // and states its own alias. "directory:ensure": Object.freeze([]), fetch: Object.freeze(["@executablemd/core#Fetch"]), + // A new grant. No version-1 record ever admitted a generated question, so + // there is no older string for this to assert it authorizes no more than — + // and a record naming one refuses rather than resuming against this. + "elicit:ask": Object.freeze([]), }); function coreEntry(name: string, key: string, capability: FragmentCapability): CapabilityEntry { diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index f205bdeaa..9176011c9 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -151,7 +151,7 @@ import { import type { AnswerIdentity, ImportTier } from "./components/component-resolution.ts"; import type { ExecutionEnvironment } from "./execution-environment.ts"; import { PROTECTED_COMPONENTS, ProtectedImports } from "./components/protected.ts"; -import { CORE_ORIGIN } from "./components/registry.ts"; +import { coreComponentDefinition, CORE_ORIGIN } from "./components/registry.ts"; import { CORE_REVISION } from "./generated-xmd.ts"; import { rootSyntaxReference } from "./syntax-reference.ts"; import { capturedDocumentation } from "./documentation-api.ts"; @@ -778,6 +778,20 @@ function differentAnswer(name: string, expected: FragmentIdentity, stated: Answe ); } +/** + * What canonical core itself holds for this name, or nothing. + * + * A protected component's implementation is the one this execution built; an + * ordinary core component's is the definition core registered. Both are core's + * own object, and the identity comparison is the whole check: an answer that is + * anything else — a repository component of the same name, a registration a + * nested scope layered over, a middleware's replacement — is not this, so it + * receives no canonical claim and the entry refuses it. + */ +function sealedByCore(inputs: ImportInputs, name: string): FunctionComponentDefinition | undefined { + return inputs.guarded.get(name) ?? coreComponentDefinition(name); +} + /** * Resolve every provider-backed name this profile admits, once, before the root * import and before any document code exists. @@ -842,7 +856,7 @@ function* resolveComponentAnswers( if ( canonicalProvider === undefined || !canonical.has(name) || - answer !== inputs.guarded.get(name) + answer !== sealedByCore(inputs, name) ) { return answer; } diff --git a/packages/core/src/expansion.ts b/packages/core/src/expansion.ts index 67fb8f34b..f0e9a120d 100644 --- a/packages/core/src/expansion.ts +++ b/packages/core/src/expansion.ts @@ -83,10 +83,11 @@ export function elementSite(position: SourcePosition | undefined, index: number) if (position === undefined) { return `@${index}`; } - if (position.path === undefined) { + const source = position.path ?? position.generatedSource; + if (source === undefined) { return `#${position.offset}`; } - return `${position.path}#${position.offset}`; + return `${source}#${position.offset}`; } /** The frame an authored element contributes. */ @@ -108,6 +109,9 @@ export function snapshot( name, position: Object.freeze({ ...(position.path === undefined ? {} : { path: position.path }), + ...(position.generatedSource === undefined + ? {} + : { generatedSource: position.generatedSource }), offset: position.offset, line: position.line, column: position.column, diff --git a/packages/core/src/fragment-capabilities.ts b/packages/core/src/fragment-capabilities.ts index f6cffe4a3..a003338f9 100644 --- a/packages/core/src/fragment-capabilities.ts +++ b/packages/core/src/fragment-capabilities.ts @@ -32,10 +32,17 @@ * * ## What is deliberately absent * - * No temporary directory, no environment, no process, no elicitation and no - * agent. Those are operations the ordinary components have and an admitted - * fragment does not, and leaving them out here is what makes that true rather - * than a claim about what a host will remember not to admit. + * No temporary directory, no environment, no process and no agent. Those are + * operations the ordinary components have and an admitted fragment does not, + * and leaving them out here is what makes that true rather than a claim about + * what a host will remember not to admit. + * + * Asking a person is here, and is the one capability whose body is not closed + * over a captured operation: `elicit:ask` *is* canonical core's ``, and + * what reaches a person is the Elicitation Api lexically in scope where the + * fragment runs. That is the one contextual thing about it — a host chooses + * whether a fragment may ask at all, and the provider already in place decides + * how. Nothing about which implementation asks is resolved by name. */ import type { Operation, Result } from "effection"; @@ -47,6 +54,7 @@ import { getExpansion } from "./expansion.ts"; import { persistFetch } from "./fetch-journal.ts"; import { parseResponseRecord } from "./fetch-response.ts"; import type { FetchResponseRecord } from "./fetch-response.ts"; +import Elicit, { props as ELICIT_PROPS, returns as ELICIT_RETURNS } from "./components/Elicit.ts"; import { GLOB_PROPS, GLOB_RETURNS, globFailure, globPatterns } from "./glob-source.ts"; import { markGeneratedRequestRefusal } from "./generated-request-refusal.ts"; import { formDispatcher } from "./invocation-identity.ts"; @@ -267,6 +275,13 @@ export function capabilityProps(capability: FragmentCapability): PropsSchema { if (capability === "fetch") { return FETCH_PROPS; } + if (capability === "elicit:ask") { + // The component's own declaration, not a copy of it: the admitted element + // and the authored one cannot come to describe different contracts, and + // nothing here asserts that — `components/Elicit.ts` types both schemas + // where it declares them. + return ELICIT_PROPS; + } // The ordinary component's own schema, not a copy of it: a fragment writes // the same element an author does. return capability === "files:glob" ? GLOB_PROPS : PATH_PROPS; @@ -282,6 +297,12 @@ export function capabilityProps(capability: FragmentCapability): PropsSchema { * asked to traverse anything. */ export function capabilityReturns(capability: FragmentCapability): ReturnsSchema | undefined { + if (capability === "elicit:ask") { + // The author's `schema` is the contract core enforces against the answer, so + // what this binds is any JSON value — and declaring it is what makes `as` + // required, exactly as it is for the authored element. + return ELICIT_RETURNS; + } return capability === "files:glob" ? GLOB_RETURNS : undefined; } @@ -292,7 +313,8 @@ export type FragmentCapability = | "file:write" | "file:delete" | "directory:ensure" - | "fetch"; + | "fetch" + | "elicit:ask"; /** The authored forms each capability is written in. */ export const CAPABILITY_FORMS: Readonly< @@ -304,6 +326,9 @@ export const CAPABILITY_FORMS: Readonly< "file:delete": ["self-closing"], "directory:ensure": ["paired"], fetch: ["self-closing"], + // The question is the content, so there is nothing a self-closing spelling + // could be asking. + "elicit:ask": ["paired"], }); /** @@ -433,6 +458,20 @@ function body( if (capability === "fetch") { return fetchBody(capabilities, requests); } + if (capability === "elicit:ask") { + // Canonical core's own body, not a copy of its behavior and not a lookup of + // its name: a generated question compiles the same schema, renders the same + // message, is identified in the journal the same way, and reaches a person + // through the same contextual Elicitation Api — so a REPL drawer answers it, + // a surrounding `` region answers it without anybody being asked, + // and a replay restores the retained answer and contacts no provider. + // + // Which implementation this is cannot be chosen at document time. It is + // closed over here, like every other pinned body, so a same-named + // repository file, registration, declaration, middleware or separately + // loaded definition is never what a fragment runs. + return Elicit; + } const files = capabilities.files; if (files === undefined) { throw new FragmentCapabilityError( diff --git a/packages/core/src/generated-xmd.ts b/packages/core/src/generated-xmd.ts index 91778dc41..59de297dc 100644 --- a/packages/core/src/generated-xmd.ts +++ b/packages/core/src/generated-xmd.ts @@ -1810,6 +1810,7 @@ interface Preflight { * could disagree with the one `` will make. */ function* preflight( + id: string, source: string, table: ReadonlyMap, ceilings: ReadonlyMap, @@ -1821,7 +1822,12 @@ function* preflight( yield* timeoutFetch; const named: Planned[] = []; - const segments = scanSegments(source); + // Scanned as the fragment it is: generated text has no path, so every + // executable position in it names this admission instead. That is what lets a + // reader of the history say which fragment performed an effect — and it is + // decided here, in the scan preflight runs, rather than by anything watching + // the expansion afterwards. + const segments = scanSegments(source, { generatedSource: id, baseOffset: 0, baseLine: 1 }); // A generated fragment starts with no bindings: it imports none from the // document that admitted it, and exports none back to it. yield* walk(segments, table, ceilings, named, new Set()); @@ -2299,13 +2305,14 @@ const RECORD_VERSION = 2; * one word already says. */ function* admitSource( + id: string, source: string, table: ReadonlyMap, ceilings: ReadonlyMap, policy: Policy, ): Operation { try { - const { named } = yield* preflight(source, table, ceilings); + const { named } = yield* preflight(id, source, table, ceilings); return parseJson({ version: RECORD_VERSION, decision: "admitted", @@ -2350,7 +2357,7 @@ function* persistAdmission( input: policyRecord(policy), ...sourceDescription(position), }, - () => admitSource(source, table, ceilings, policy), + () => admitSource(id, source, table, ceilings, policy), ); return parseJson(stored); } @@ -2558,6 +2565,6 @@ export function* evaluateProtectedGeneratedXmd( // The retained source is what expands, so a continuation runs exactly the // bytes this run admitted rather than a caller's copy of them. - const restored = yield* preflight(decided.source, table, ceilings); + const restored = yield* preflight(request.id, decided.source, table, ceilings); return yield* expand(request.id, restored.segments, restored.named, componentRouting, syntax); } diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index d98e58e34..6cdf8f188 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -124,12 +124,17 @@ export function parseInfoString(infoString: string): ParsedInfoString { } /** - * Where scanned text sits in its original file. `baseOffset`/`baseLine` + * Where scanned text sits in the source it came from. `baseOffset`/`baseLine` * translate body-relative positions to original-file positions (frontmatter * included). Omitted for dynamically scanned strings. + * + * One of two sources, never both: a file states its `path`, and a generated + * fragment states the `generatedSource` its admission was decided under — + * generated text has no path, and the id is what an effect inside it can name. */ export interface SourceOrigin { - path: string; + path?: string; + generatedSource?: string; baseOffset: number; baseLine: number; } @@ -169,6 +174,7 @@ function positionAt(index: PositionIndex, offset: number): SourcePosition { const column = offset - lineStarts[low]! + 1; return { path: origin?.path, + ...(origin?.generatedSource === undefined ? {} : { generatedSource: origin.generatedSource }), offset: (origin?.baseOffset ?? 0) + offset, line: (origin?.baseLine ?? 1) + localLine - 1, column, diff --git a/packages/core/src/source-position.ts b/packages/core/src/source-position.ts index 03512f091..cbb17f3d6 100644 --- a/packages/core/src/source-position.ts +++ b/packages/core/src/source-position.ts @@ -8,7 +8,8 @@ * part of the event (spec §10.1). * * The value is the position the scanner produced for that element, carried from - * the authoring boundary. Nothing reconstructs one from an expansion id, a + * the authoring boundary — a file's path, or the generated fragment's own id for + * an element inside admitted generated source. Nothing reconstructs one from an expansion id, a * formatted `path:line:column` name or the current source: a name is a name, and * a document that has been edited since would answer a different question. */ @@ -34,6 +35,9 @@ export function sourceDescription( return { [SOURCE_POSITION_FIELD]: { ...(position.path === undefined ? {} : { path: position.path }), + ...(position.generatedSource === undefined + ? {} + : { generatedSource: position.generatedSource }), offset: position.offset, line: position.line, column: position.column, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1ed03ce60..7730e1337 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -80,6 +80,20 @@ export interface ComponentElement { export interface SourcePosition { /** Workspace-relative file path. Undefined for dynamically scanned text. */ path?: string; + /** + * The generated fragment this position belongs to, when it belongs to one. + * + * Generated source is not a file: an Agent's fragment arrived as text, so + * there is no path for the effects inside it to name. What they name instead + * is the admission that decided that text — the request id canonical + * `` already derives from its own deterministic expansion — so a + * reader of the history can say which fragment performed a given effect + * without inferring it from order, from a name, or from anything live. + * + * Closed against `path`: a position carries one of the two, or neither for + * dynamic text that has no durable source at all. Both together is malformed. + */ + generatedSource?: string; /** Character offset in the original file. */ offset: number; line: number; diff --git a/packages/core/tests/evaluate-component.test.ts b/packages/core/tests/evaluate-component.test.ts index 8cad4b4b8..87ef5d0a0 100644 --- a/packages/core/tests/evaluate-component.test.ts +++ b/packages/core/tests/evaluate-component.test.ts @@ -33,6 +33,9 @@ import { } from "../host.ts"; import type { ExecutionInstallation, FragmentEvaluationInput } from "../host.ts"; import { registerComponents } from "../src/components/registration.ts"; +import { elicitWriteEntry } from "../host.ts"; +import { Elicitation } from "../src/elicitation-api.ts"; +import type { ElicitationRequest } from "../src/elicitation-api.ts"; import { retainedSource } from "../src/root-source.ts"; import { recordedFiles } from "./support/fragment-files.ts"; import type { RecordedFiles } from "./support/fragment-files.ts"; @@ -2422,3 +2425,383 @@ describe("Tier FE14 — the chain answers, and the answer is held to its identit expect(String(output)).toContain("the retained note"); }); }); + +describe("Tier FE36 — canonical `` is a write a person answers", () => { + /** The question the fragment asks, and the shape its answer must have. */ + const SCHEMA = + '{ type: "object", properties: { decision: { type: "string", enum: ["Approve", "Decline"] } }, ' + + 'required: ["decision"], additionalProperties: false }'; + + /** + * The fragment every row here admits or refuses: ask, then write the answer. + * + * The write reads the binding the question produced, so a row that finds + * `out.md` holding the answer has proved the whole chain — the provider + * answered, the answer became a binding, and the binding reached the + * mutation. `` is core's always-on composition, which is what lets a + * generated fragment state a value it holds without an expression that + * computes one. + */ + const ASKING = + `Approve this write?\n\n` + + `\n\n`; + + /** A profile admitting the write table Elicit belongs to. */ + function writing(files: RecordedFiles): ExecutionInstallation { + return { + evaluation: { + read: [fileReadEntry()], + write: [fileWriteEntry(), elicitWriteEntry()], + files, + }, + }; + } + + /** The same tables, with the question left out of the one that holds it. */ + function withoutElicit(files: RecordedFiles): ExecutionInstallation { + return { + evaluation: { read: [fileReadEntry()], write: [fileWriteEntry()], files }, + }; + } + + /** + * Install a provider on the calling scope, recording into the caller's list. + * + * The list belongs to the row rather than to this installer, because the + * provider is installed inside the scope the run happens in and a row reads + * what it saw after that scope has closed. + */ + function* asked( + requests: ElicitationRequest[], + answer: Json = { decision: "Approve" }, + ): Operation { + yield* Elicitation.around( + { + // deno-lint-ignore require-yield + *elicit([request]: [ElicitationRequest]): Operation { + requests.push(request); + return answer; + }, + }, + // Where a provider installs: the default position would let an outer + // install shadow this one, which is the opposite of what a provider is. + { at: "min" }, + ); + } + + const DOCUMENT = `\n\n\n`; + + /** A producer that hands this exact fragment to the element below it. */ + function producing(fragment: string): Operation { + return registerComponents([ + { + name: "Program", + origin: "test://producer", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + *fn(): Operation { + return fragment; + }, + }, + ]); + } + + it("EL2: `write` admits the question, the host provider answers it, and the answer writes", function* () { + const files = recordedFiles(); + const stream = new InMemoryStream(); + const requests: ElicitationRequest[] = []; + + const output = yield* scoped(function* () { + yield* asked(requests); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(files)], stream); + }); + + // One question, carrying the rendered request and the compiled schema. The + // provider is the enclosing host's: the fragment installed nothing. + expect(requests).toHaveLength(1); + expect(requests[0]?.message).toContain("Approve this write?"); + expect(requests[0]?.schema).toEqual({ + type: "object", + properties: { decision: { type: "string", enum: ["Approve", "Decline"] } }, + required: ["decision"], + additionalProperties: false, + }); + // And the answer drove the write that followed it, through the captured + // operation and nothing else. + expect(files.performed).toEqual(["check out.md", "write out.md"]); + expect(files.entries.get("out.md")).toContain('"decision": "Approve"'); + expect(String(output)).not.toContain("did not admit"); + + // One admission, and the question's own ordinary durable record beside it. + expect(admissions(yield* stream.readAll())).toHaveLength(1); + const kinds = (yield* stream.readAll()) + .filter((event) => event.type === "yield") + .map((event) => (event.type === "yield" ? event.description.type : "")); + expect(kinds).toContain("elicit"); + }); + + it("EL7: an admitted question nothing writes resolves nothing, and breaks nothing", function* () { + const files = recordedFiles(); + const requests: ElicitationRequest[] = []; + const asksFor: string[] = []; + + // The entry is admitted and the fragment never writes the element. Nothing + // about `Elicit` is resolved for it — which is the whole reason this is a + // pinned capability: an admitted *component answer* is resolved once per + // execution before any document code, so a name a host had legitimately + // shadowed, or a repository file of the same name on the include path, made + // executions that ask nothing refuse. + const output = yield* scoped(function* () { + yield* asked(requests); + yield* Component.around({ + *importComponent([name, position], next) { + asksFor.push(name); + return yield* next(name, position); + }, + }); + yield* producing(`just a write\n`); + return yield* run(DOCUMENT, [writing(files)]); + }); + + expect(asksFor).not.toContain("Elicit"); + expect(requests).toEqual([]); + expect(files.entries.get("out.md")).toContain("just a write"); + expect(String(output)).not.toContain("did not admit"); + }); + + it("EL8: a surrounding `` answers the generated question, and asks nobody", function* () { + const files = recordedFiles(); + const requests: ElicitationRequest[] = []; + + // The region is the author's, around the element that evaluates the + // fragment. A generated question is an ordinary elicitation, so the region + // answers it exactly as it answers an authored one — which is a fact about + // reaching the active Elicitation Api rather than about who supplied the + // body. + yield* scoped(function* () { + yield* asked(requests); + yield* producing(ASKING); + return yield* run( + `\n\nApprove this write?\n\n` + + `${DOCUMENT}\n`, + [writing(files)], + ); + }); + + // Nobody was asked, and what the region said is what the fragment wrote. + expect(requests).toEqual([]); + expect(files.entries.get("out.md")).toContain("Decline"); + }); + + it("EL3: `read` refuses the same fragment before the provider or a write is reached", function* () { + const files = recordedFiles({ "notes.md": NOTE }); + const requests: ElicitationRequest[] = []; + + const failed = yield* refusal( + scoped(function* () { + yield* asked(requests); + yield* producing(ASKING); + return yield* run(`\n\n\n`, [ + writing(files), + ]); + }), + ); + + // The class, and nothing about the fragment: a question is admitted by the + // table that also admits mutation, and a read selection promises nobody + // will be interrupted. + expect(failed).toContain("did not admit"); + expect(requests).toEqual([]); + expect(files.performed).toEqual([]); + }); + + it("EL3: a write selection that omits the entry refuses it too", function* () { + const files = recordedFiles(); + const requests: ElicitationRequest[] = []; + + const failed = yield* refusal( + scoped(function* () { + yield* asked(requests); + yield* producing(ASKING); + return yield* run(DOCUMENT, [withoutElicit(files)]); + }), + ); + + expect(failed).toContain("did not admit"); + expect(requests).toEqual([]); + expect(files.performed).toEqual([]); + }); + + it("EL4: a prohibited sibling refuses the whole fragment before the question is asked", function* () { + const files = recordedFiles(); + const requests: ElicitationRequest[] = []; + + // The question is first and legal; what follows it is not. Preflight reads + // the whole fragment before its first effect, so nobody is asked at all. + const failed = yield* refusal( + scoped(function* () { + yield* asked(requests); + yield* producing(`${ASKING}\n\n`); + return yield* run(DOCUMENT, [writing(files)]); + }), + ); + + expect(failed).toContain("did not admit"); + expect(requests).toEqual([]); + expect(files.performed).toEqual([]); + }); + + it("EL5: a same-name definition never runs, whichever tier installed it", function* () { + // Nothing resolves `Elicit` for a generated fragment, so a same-name + // component is not a competing *answer* to be refused — it is simply not + // what the admitted entry runs. What a person is asked, and what the + // fragment binds, comes from canonical core's own body either way. + // + // A registration is the tier that would win if the import chain were + // consulted at all: it sits in front of core's table, and the workflow's own + // suspension replacement reaches a run exactly this way. + const impostorAnswer = { decision: "Impostor" }; + const registered = recordedFiles(); + const registeredAsks: ElicitationRequest[] = []; + let impostorRan = false; + yield* scoped(function* () { + yield* asked(registeredAsks); + yield* registerComponents([ + { + name: "Elicit", + origin: "test://impostor", + props: { type: "object", properties: {}, additionalProperties: true }, + // deno-lint-ignore require-yield + *fn(): Operation { + impostorRan = true; + return impostorAnswer; + }, + }, + ]); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(registered)]); + }); + // The real provider was asked, the impostor was not run, and what reached + // the write is the provider's answer rather than the impostor's. + expect(registeredAsks).toHaveLength(1); + expect(impostorRan).toBe(false); + expect(registered.entries.get("out.md")).toContain("Approve"); + expect(registered.entries.get("out.md")).not.toContain("Impostor"); + + // And middleware that answers the name with a body of its own does not get + // to be what the fragment runs. This is the tier every other one reaches + // through — a repository file, a declaration, a separately loaded copy are + // all things the import chain would answer with — so a substitution refused + // here is refused for all of them. + const wrapped = recordedFiles(); + const wrappedAsks: ElicitationRequest[] = []; + let middlewareRan = false; + const refused = yield* refusal( + scoped(function* () { + yield* asked(wrappedAsks); + yield* Component.around({ + *importComponent([name, position], next) { + if (name !== "Elicit") { + return yield* next(name, position); + } + return { + kind: "function", + name: "Elicit", + props: { type: "object", properties: {}, additionalProperties: true }, + // deno-lint-ignore require-yield + *fn(): Operation { + middlewareRan = true; + return impostorAnswer; + }, + }; + }, + }); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(wrapped)]); + }), + ); + // Only canonical execution answers a generated import. A handler may watch + // one or refuse it; answering one with a body of its own is refused at the + // import, before anybody is asked and before the fragment writes anything. + expect(refused).toContain("canonical execution did not produce"); + expect(middlewareRan).toBe(false); + expect(wrappedAsks).toEqual([]); + expect(wrapped.performed).toEqual([]); + }); + + it("EL5: a continuation whose retained Elicit identity moved refuses before any effect", function* () { + const files = recordedFiles(); + const stream = new InMemoryStream(); + yield* scoped(function* () { + yield* asked([]); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(files)], stream); + }); + const complete = yield* stream.readAll(); + const admitted = complete.findIndex( + (event) => event.type === "yield" && event.description.type === "generated_xmd", + ); + expect(admitted).toBeGreaterThanOrEqual(0); + const partial = complete.slice(0, admitted + 1); + + // The same fragment and the same text, under a table that now states + // another revision behind the same name. + const second = recordedFiles(); + const moved: ExecutionInstallation = { + evaluation: { + read: [fileReadEntry()], + write: [ + fileWriteEntry(), + { + ...elicitWriteEntry(), + identity: { origin: "@executablemd/core", key: "Elicit", revision: "99" }, + }, + ], + files: second, + }, + }; + const requests: ElicitationRequest[] = []; + const failed = yield* refusal( + scoped(function* () { + yield* asked(requests); + yield* producing(ASKING); + return yield* run(DOCUMENT, [moved], new InMemoryStream(partial)); + }), + ); + + expect(failed.length).toBeGreaterThan(0); + expect(requests).toEqual([]); + expect(second.performed).toEqual([]); + }); + + it("EL6: a replay restores the answer without asking anyone, byte for byte", function* () { + const files = recordedFiles(); + const stream = new InMemoryStream(); + const first = yield* scoped(function* () { + yield* asked([]); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(files)], stream); + }); + expect(files.performed).toEqual(["check out.md", "write out.md"]); + const complete = yield* stream.readAll(); + + // The whole history, and a provider that would answer differently if it + // were reached — so a second ask would be visible in the output rather + // than only in a counter. + const second = recordedFiles(); + const requests: ElicitationRequest[] = []; + const replayStream = new InMemoryStream(complete); + const replayed = yield* scoped(function* () { + yield* asked(requests, { decision: "Decline" }); + yield* producing(ASKING); + return yield* run(DOCUMENT, [writing(second)], replayStream); + }); + + expect(requests).toEqual([]); + expect(second.performed).toEqual([]); + expect(String(replayed)).toBe(String(first)); + // And the retained history is the one it started from. + expect(yield* replayStream.readAll()).toEqual(complete); + }); +}); diff --git a/packages/core/tests/evaluation-profile.test.ts b/packages/core/tests/evaluation-profile.test.ts index ffb6d9066..a3df99628 100644 --- a/packages/core/tests/evaluation-profile.test.ts +++ b/packages/core/tests/evaluation-profile.test.ts @@ -20,13 +20,18 @@ import { installIdentities } from "../src/invocation-identity.ts"; import type { ComponentRouting, ProtectedSite } from "../src/invocation-identity.ts"; import { + CANONICAL_PROFILE_ANSWERS, + elicitWriteEntry, fileReadEntry, globReadEntry, prepareEvaluationProfile, syntaxReadEntry, } from "../src/evaluation-profile.ts"; import { CORE_ORIGIN } from "../src/components/registry.ts"; +import { CORE_REVISION } from "../src/generated-xmd.ts"; import { props as globProps } from "../src/components/Glob.ts"; +import { props as elicitProps } from "../src/components/Elicit.ts"; +import { capabilityReturns } from "../src/fragment-capabilities.ts"; import type { CapabilityEntry, EvaluationProfile, @@ -641,3 +646,47 @@ describe("Tier EP — a provider-backed name states one identity", () => { expect(failed).toContain("different grants"); }); }); + +describe("Tier EP — the entry that admits canonical ``", () => { + it("EL1: the factory pins core's own body under core's identity, paired only", function* () { + const entry = elicitWriteEntry(); + + // A capability rather than a component answer: what core owns here is the + // behavior, so the body is pinned the way ``'s is. Resolving the name + // instead would hand a generated fragment whatever legitimately shadows + // `Elicit` — a repository file, a registration, the workflow's own + // suspension replacement — and would resolve it for every execution the + // profile belongs to, including ones that ask nothing. + expect(entry.kind).toBe("capability"); + expect(entry.capability).toBe("elicit:ask"); + expect(entry.name).toBe("Elicit"); + expect(entry.identity).toEqual({ + origin: CORE_ORIGIN, + key: "Elicit", + revision: CORE_REVISION, + }); + // Paired only. The question is the content, so a self-closing spelling + // would be a request with nothing in it. + expect(entry.forms).toEqual(["paired"]); + // It declares ``'s own contract, read from the component rather than + // restated, and binds a value — so a generated question written without + // `as` is refused for the ordinary reason, before anybody is asked. + expect(entry.props).toEqual(elicitProps); + expect(capabilityReturns("elicit:ask")).toBeDefined(); + + // And it is not a name anything resolves. `Elicit` left the set canonical + // execution resolves eagerly, which is what stops an execution that never + // asks anything from depending on what `Elicit` resolves to. + expect(CANONICAL_PROFILE_ANSWERS.has("Elicit")).toBe(false); + // A new grant: no version-1 record ever admitted a generated question, so + // there is no older string this says it authorizes no more than. + expect(entry.legacy).toBe(undefined); + + // Two factories state the same entry and share nothing: a host that edited + // what it was given would be editing its own copy. + const second = elicitWriteEntry(); + expect(second).toEqual(entry); + expect(second).not.toBe(entry); + yield* useScope(); + }); +}); diff --git a/packages/core/tests/expansion-identity.test.ts b/packages/core/tests/expansion-identity.test.ts index 947e7a4f7..bfedcac07 100644 --- a/packages/core/tests/expansion-identity.test.ts +++ b/packages/core/tests/expansion-identity.test.ts @@ -23,6 +23,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { Component, content } from "../src/component-api.ts"; import { collect } from "../src/collect.ts"; +import { elementSite, snapshot } from "../src/expansion.ts"; import { execute } from "../src/execute.ts"; import { inlineSource } from "../src/root-source.ts"; import { registerComponents } from "../src/components/registration.ts"; @@ -835,3 +836,44 @@ describe("Tier XP — expansion identity", () => { expect(yield* executed(only!, truncated)).toEqual(live); }); }); + +/** + * Tier GS — the site a generated element sits at. + * + * An element's site is where it was written, so an element inside generated + * source is sited by the fragment that source is — and two fragments' elements + * at the same offset are two different sites, because they are two different + * sources. + */ +describe("Tier GS — generated element sites", () => { + it("GS1: a generated position sites an element by its fragment", function* () { + expect(elementSite({ generatedSource: "gen-a", offset: 58, line: 4, column: 1 }, 0)).toBe( + "gen-a#58", + ); + // Two fragments, one offset: different sites, because the sources differ. + expect(elementSite({ generatedSource: "gen-b", offset: 58, line: 4, column: 1 }, 0)).not.toBe( + elementSite({ generatedSource: "gen-a", offset: 58, line: 4, column: 1 }, 0), + ); + // A file still sites by its path, and a position with neither by its offset. + expect(elementSite({ path: "Doc.md", offset: 4, line: 1, column: 1 }, 0)).toBe("Doc.md#4"); + expect(elementSite({ offset: 4, line: 1, column: 1 }, 0)).toBe("#4"); + expect(elementSite(undefined, 3)).toBe("@3"); + }); + + it("GS1: an expansion snapshot carries the fragment it was written in", function* () { + const taken = snapshot("path-1", "Elicit", { + generatedSource: "gen-a", + offset: 58, + line: 4, + column: 1, + }); + + expect(taken.position).toEqual({ + generatedSource: "gen-a", + offset: 58, + line: 4, + column: 1, + }); + expect(Object.isFrozen(taken.position)).toBe(true); + }); +}); diff --git a/packages/core/tests/generated-xmd.test.ts b/packages/core/tests/generated-xmd.test.ts index 071d87985..2f0f506a1 100644 --- a/packages/core/tests/generated-xmd.test.ts +++ b/packages/core/tests/generated-xmd.test.ts @@ -46,6 +46,7 @@ import { CORE_REGISTRY } from "../src/components/registry.ts"; import { collect } from "../src/collect.ts"; import { retainedSource } from "../src/root-source.ts"; import { useTempFileCompiler } from "../src/temp-file-compiler.ts"; +import { SOURCE_POSITION_FIELD } from "../src/source-position.ts"; import { useSecretScannerFactory } from "../src/secrets/policy.ts"; import { createSecretScanner } from "../src/secrets/scanner.ts"; import type { SecretScanner } from "../src/secrets/scanner.ts"; @@ -3610,3 +3611,73 @@ describe("Tier GXC — the authored form survives the public content chain", () }; } }); + +/** + * Tier GS — which fragment an effect inside generated source belongs to. + * + * Generated source is not a file, so an effect inside it has no path to name. + * What it names instead is the admission that decided that source, stamped by + * the scan whole-fragment preflight performs — so a reader of the history can + * say which fragment performed which effect without inferring it from order or + * from anything live. + */ +describe("Tier GS — generated work names its own admission", () => { + beforeAll(() => useTempFileCompiler()); + + /** The position one recorded effect retained, exactly as the journal holds it. */ + function positionOf(events: DurableEvent[], type: string): Json | undefined { + const found = events.find((event) => event.type === "yield" && event.description.type === type); + if (found === undefined || found.type !== "yield") { + throw new Error(`no ${type} event was journaled`); + } + return found.description[SOURCE_POSITION_FIELD]; + } + + it("GS1: the effect inside a fragment carries that fragment's id and no path", function* () { + const transport = yield* useTransport(() => ({ status: 200, body: "body" })); + const attempt = yield* evaluate( + request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), + ); + + expect(attempt.failure).toBe(undefined); + expect(transport.performed).toHaveLength(1); + // The admission is the caller's own effect, so it carries whatever position + // the caller had. The read inside the fragment carries the fragment. + expect(positionOf(attempt.events, "fetch")).toEqual({ + generatedSource: "turn-1", + offset: 0, + line: 1, + column: 1, + }); + }); + + it("GS2: two fragments in one run stamp their own ids on identical places", function* () { + const transport = yield* useTransport(() => ({ status: 200, body: "body" })); + const second: GeneratedXmdRequest = { + ...request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), + id: "turn-2", + }; + const attempt = yield* evaluate( + request(`\n`, [pinnedFetch([ADMITTED_REQUEST])]), + { + *after(): Operation { + yield* evaluateGeneratedXmd(second); + }, + }, + ); + + expect(attempt.failure).toBe(undefined); + expect(transport.performed).toHaveLength(2); + // Both fragments asked at their own 1:1 — the same line, the same column, + // the same effect name — and the ids are what tell the two reads apart. + const reads = attempt.events.flatMap((event) => + event.type === "yield" && event.description.type === "fetch" + ? [event.description[SOURCE_POSITION_FIELD]] + : [], + ); + expect(reads).toEqual([ + { generatedSource: "turn-1", offset: 0, line: 1, column: 1 }, + { generatedSource: "turn-2", offset: 0, line: 1, column: 1 }, + ]); + }); +}); diff --git a/packages/core/tests/source-position.test.ts b/packages/core/tests/source-position.test.ts index 18260ac8e..749d28486 100644 --- a/packages/core/tests/source-position.test.ts +++ b/packages/core/tests/source-position.test.ts @@ -3,6 +3,7 @@ import { expect } from "@executablemd/test-support/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import { useStubFs } from "@executablemd/runtime/test"; import { scanSegments } from "../src/scanner.ts"; +import { SOURCE_POSITION_FIELD, sourceDescription } from "../src/source-position.ts"; import { getExpansion } from "../src/expansion.ts"; import { registerComponents } from "../src/components/registration.ts"; import { execute } from "../src/execute.ts"; @@ -137,3 +138,63 @@ describe("source positions", () => { }); }); }); + +/** + * Tier GS — a position that names a generated fragment instead of a file. + * + * Generated source has no path, so the scanner stamps the admission the text was + * decided under. These rows are about the two shapes a position may have and the + * one it may not. + */ +describe("generated source positions", () => { + it("GS1: a generated scan stamps the fragment, and no path", function* () { + const [ask] = componentsOf( + scanSegments("ask\n", { + generatedSource: "gen-a", + baseOffset: 0, + baseLine: 1, + }), + ); + + expect(ask?.position).toEqual({ + path: undefined, + generatedSource: "gen-a", + offset: 0, + line: 1, + column: 1, + }); + yield* useStubFs({}); + }); + + it("GS1: a file scan stamps a path and no fragment, and a dynamic scan neither", function* () { + const [named] = componentsOf( + scanSegments("\n", { path: "Doc.md", baseOffset: 0, baseLine: 1 }), + ); + expect(named?.position?.generatedSource).toBe(undefined); + expect(named?.position?.path).toBe("Doc.md"); + + const [dynamic] = componentsOf(scanSegments("\n")); + expect(dynamic?.position?.generatedSource).toBe(undefined); + expect(dynamic?.position?.path).toBe(undefined); + yield* useStubFs({}); + }); + + it("GS1: the description an effect carries holds whichever source it has", function* () { + // The durable field is the whole of what a later reader gets, so it carries + // the fragment exactly as the position does — and omits the member the + // position does not have. + expect(sourceDescription({ generatedSource: "gen-a", offset: 58, line: 4, column: 1 })).toEqual( + { + [SOURCE_POSITION_FIELD]: { generatedSource: "gen-a", offset: 58, line: 4, column: 1 }, + }, + ); + expect(sourceDescription({ path: "Doc.md", offset: 1, line: 1, column: 1 })).toEqual({ + [SOURCE_POSITION_FIELD]: { path: "Doc.md", offset: 1, line: 1, column: 1 }, + }); + expect(sourceDescription({ offset: 1, line: 1, column: 1 })).toEqual({ + [SOURCE_POSITION_FIELD]: { offset: 1, line: 1, column: 1 }, + }); + expect(sourceDescription(undefined)).toEqual({}); + yield* useStubFs({}); + }); +}); diff --git a/packages/git/src/plugin.ts b/packages/git/src/plugin.ts index 721a1a9a1..f8ed8b032 100644 --- a/packages/git/src/plugin.ts +++ b/packages/git/src/plugin.ts @@ -39,8 +39,12 @@ import { gitHostIdentityAdmission, issueIdentityAdmission } from "./identities.t * `xmd workflow list` would advertise components nothing there can expand. * Which workflow *action* was asked for decides it, and * {@link executesDocument} is what reads that off the argv. + * + * `repl` is one of them: it runs an entry a person typed, under the same + * ordinary profile `run` uses, so a document opened there can write the same + * Git vocabulary a document run anywhere else can. */ -const DOCUMENT_COMMANDS: ReadonlySet = new Set(["run", "plan", "syntax"]); +const DOCUMENT_COMMANDS: ReadonlySet = new Set(["run", "plan", "syntax", "repl"]); /** The workflow actions that execute a document. */ const EXECUTING_ACTIONS: ReadonlySet = new Set(["start", "resume", "fork"]); diff --git a/packages/git/tests/plugin.test.ts b/packages/git/tests/plugin.test.ts index 771f43030..ce62aff70 100644 --- a/packages/git/tests/plugin.test.ts +++ b/packages/git/tests/plugin.test.ts @@ -219,11 +219,23 @@ function* record( describe("the Git Plugin's command profile", () => { it("declares for the commands that execute or describe a document", function* () { - for (const command of ["run", "plan", "syntax"]) { + for (const command of ["run", "plan", "syntax", "repl"]) { expect(declaresFor({ command, args: [command] })).toBe(true); } }); + it("declares for the REPL, which runs an entry under the ordinary profile", function* () { + // Explicit rather than incidental: the REPL runs a document a person typed, + // so the Git vocabulary is available there exactly as it is to `run`. A + // Plugin that only meant `run` is a different question, and `repl` arriving + // under its own name is what lets one answer it. + expect(declaresFor({ command: "repl", args: ["repl"] })).toBe(true); + expect(declaresFor({ command: "repl", args: ["repl", "xmd://repl/one/repl"] })).toBe(true); + const runOnly = (request: PluginInstallRequest): boolean => request.command === "run"; + expect(runOnly({ command: "repl", args: ["repl"] })).toBe(false); + expect(runOnly({ command: "run", args: ["run", "doc.md"] })).toBe(true); + }); + it("declares for the workflow actions that execute a document, and no others", function* () { for (const action of ["start", "resume", "fork"]) { expect(declaresFor({ command: "workflow", args: ["workflow", action, "flow.md"] })).toBe( diff --git a/packages/workflow/src/lifecycle/history.ts b/packages/workflow/src/lifecycle/history.ts index a96907fa8..675460f77 100644 --- a/packages/workflow/src/lifecycle/history.ts +++ b/packages/workflow/src/lifecycle/history.ts @@ -8,7 +8,8 @@ * * The authored source is the one optional part. A durable operation written by * an author carries its normalized position under the stable namespaced - * description field, so history parses that field and never derives a location + * description field — a file's path, or the generated fragment's own id for an + * operation inside admitted generated source — so history parses that field and never derives a location * from an expansion id, an effect name or the current source. A field that is * present and does not parse makes the entry unreadable — reporting it as * source-less would describe history the run does not hold — and the diagnostic @@ -42,7 +43,7 @@ export interface InheritedEventProvenance { readonly sourceEventId: string; } -const MEMBERS = ["path", "offset", "line", "column"]; +const MEMBERS = ["path", "generatedSource", "offset", "line", "column"]; /** * The authored position an event retained, or none when it retained none. @@ -69,9 +70,21 @@ function parseSourcePosition(value: unknown): Readonly { if (path !== undefined && (typeof path !== "string" || path === "")) { throw fail(`expected a non-empty string, found ${describe(path)}`, "$.path"); } + // The generated fragment an operation inside admitted generated source belongs + // to. Closed against `path`: one source, or neither for dynamic text, because + // a row naming two of them says two different things about where it was + // written. + const generated = members.get("generatedSource"); + if (generated !== undefined && (typeof generated !== "string" || generated === "")) { + throw fail(`expected a non-empty string, found ${describe(generated)}`, "$.generatedSource"); + } + if (path !== undefined && generated !== undefined) { + throw fail("expected one source, found both a path and a generated source", "$"); + } return Object.freeze({ ...(path === undefined ? {} : { path }), + ...(generated === undefined ? {} : { generatedSource: generated }), offset: coordinate(members.get("offset"), "$.offset", 0), line: coordinate(members.get("line"), "$.line", 1), column: coordinate(members.get("column"), "$.column", 1), diff --git a/packages/workflow/tests/generated-agent-component.test.ts b/packages/workflow/tests/generated-agent-component.test.ts index e56311c78..df344830e 100644 --- a/packages/workflow/tests/generated-agent-component.test.ts +++ b/packages/workflow/tests/generated-agent-component.test.ts @@ -14,7 +14,7 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; -import { directoryEntry, executeInstalled } from "@executablemd/core/host"; +import { directoryEntry, elicitWriteEntry, executeInstalled } from "@executablemd/core/host"; import type { FragmentEntry } from "@executablemd/core/host"; import { expect } from "@executablemd/test-support/expect"; import { scoped, spawn, suspend, withResolvers } from "effection"; @@ -873,6 +873,36 @@ describe("Tier WGAC — the registered Evaluate component", () => { }); }); + it("WGAC18: a generated question is admitted only where this host admits one", function* () { + const root = yield* useStorageRoot(); + yield* withStorage(root, function* () { + const database = yield* createRun(); + + // The workflow's own generated write table lists no question. A fragment + // that writes `` is refused for not being admitted — before the + // provider is reached, and whatever the run's authored documents may do + // with the workflow's own suspension replacement for the same name. + const refused = yield* runDocument( + database, + `ask?'} />\n`, + ); + expect(reported(refused)).toContain("did not admit"); + }); + + const second = yield* useStorageRoot(); + yield* withStorage(second, function* () { + const database = yield* createRun(); + // And a host that states the entry admits it: the table is the host's, + // which is what "unless its own profile admits it" means. + const admitted = yield* runDocument( + database, + `ask?'} />\n`, + { writes: [elicitWriteEntry()] }, + ); + expect(reported(admitted)).not.toContain("did not admit"); + }); + }); + it("WGAC5: the exact Fetch ceiling is the host's, and an empty one admits no request", function* () { const root = yield* useStorageRoot(); yield* withStorage(root, function* () { diff --git a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts index cfc71b32a..c70663b9c 100644 --- a/packages/workflow/tests/workflow-lifecycle-inspection.test.ts +++ b/packages/workflow/tests/workflow-lifecycle-inspection.test.ts @@ -436,6 +436,80 @@ describe("Tier WLI — immutable lifecycle inspection", () => { }); }); + it("WLI18: a generated operation exposes the fragment it was written in", function* () { + const root = yield* useStorageRoot(); + yield* retainedRun(root, "release-1.4"); + const path = runPath(root, "release-1.4"); + // What the engine records for an operation inside admitted generated source: + // the fragment's own identity, and no path, because generated text is not a + // file. + tamper(path, (database) => { + database.prepare("UPDATE journal_events SET record = ? WHERE sequence = 1").run( + `${JSON.stringify({ + type: "yield", + coroutineId: "root", + description: { + type: "fetch", + name: "fetch:1", + [SOURCE_POSITION_FIELD]: { + generatedSource: "turn-1", + offset: 58, + line: 4, + column: 1, + }, + }, + result: { status: "ok", value: "done" }, + })}\n`, + ); + }); + + yield* withLifecycle(root, function* () { + const entries = yield* historyOf("release-1.4"); + expect(entries[0]?.source).toEqual({ + generatedSource: "turn-1", + offset: 58, + line: 4, + column: 1, + }); + }); + }); + + it("WLI19: a source naming both a file and a fragment makes the entry unreadable", function* () { + const root = yield* useStorageRoot(); + yield* retainedRun(root, "release-1.4"); + const path = runPath(root, "release-1.4"); + // Two answers to where one operation was written. Reading either would be + // choosing which of them to believe. + tamper(path, (database) => { + database.prepare("UPDATE journal_events SET record = ? WHERE sequence = 1").run( + `${JSON.stringify({ + type: "yield", + coroutineId: "root", + description: { + type: "fetch", + name: "fetch:1", + [SOURCE_POSITION_FIELD]: { + path: "workflows/release.md", + generatedSource: "turn-1", + offset: 58, + line: 4, + column: 1, + }, + }, + result: { status: "ok", value: "done" }, + })}\n`, + ); + }); + + yield* withLifecycle(root, function* () { + const answered = yield* history("release-1.4"); + expect(answered.ok).toBe(false); + const error = answered.ok ? undefined : answered.error; + expect(error).toBeInstanceOf(WorkflowRecordMalformedError); + expect(error?.message).toContain("found both a path and a generated source"); + }); + }); + it("WLI7: a present source that does not parse makes the entry unreadable", function* () { const root = yield* useStorageRoot(); yield* retainedRun(root, "release-1.4"); diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index 12a0e8872..84275cf1f 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -760,8 +760,13 @@ own session store and one empty working directory per session, both disposable. ## Command-line configuration `xmd run` configures the agent stack, and `xmd plan` settles the part of it -authorship needs — the provider and the default agent. `xmd test` rejects those -options, driving agents through the deterministic test-agent stack instead. +authorship needs — the provider and the default agent. `xmd repl` resolves the +same five fields from its own command line and installs the provider and +component half of the stack with its own interactive policy: it presents +permission requests and elicitations in its own surface, so it installs neither +readline permission handling nor a foreground launcher, and `` is +unavailable there (`specs/repl-spec.md`). `xmd test` rejects those options, +driving agents through the deterministic test-agent stack instead. That division also applies to a nested run profile. The outer `xmd test` invocation supplies no live Agent configuration to a child and no contextual diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 6b7327e8c..2882b4f11 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -4232,9 +4232,38 @@ capture. **The ordinary read table is File, Glob and canonical Syntax.** `xmd run` and every `host="run"` child state one profile: `read` holds the self-closing ``, the self-closing `` and canonical ``, and `write` -holds the paired `…` and the self-closing `` -unchanged. A `read` selection therefore never reaches a write form, and -`` remains absent rather than admitted with an empty ceiling. +holds the paired `…`, the self-closing `` and paired +canonical ``. A `read` selection therefore never reaches a write form, +and `` remains absent rather than admitted with an empty ceiling. + +`` is in the write table because asking is not reading. A fragment that +may change something may also ask the person first — which is what makes +"preview this, then confirm it" a program an Agent can write — while a `read` +selection promises nobody will be interrupted. It is admitted at canonical core's +own origin, key and revision, in its paired spelling only: the question is the +content, so a self-closing one would be a request with nothing in it. + +It is a *pinned capability*, not a name the fragment resolves. Preflight selects +it where it finds an `` occurrence under a `write` selection, and the body +it selects is canonical core's own `` — closed over before any document +code runs, exactly as the body behind `` is. No component name is resolved +for it at any point: nothing is looked up before the root import, nothing is +looked up at invocation, and middleware answering a generated import with a body +of its own is refused, because only canonical execution answers one. A same-name +repository, registered, declared, middleware or separately loaded `Elicit` +therefore receives no generated permission and never runs — including the +workflow host's own suspension replacement, which continues to answer *authored* +`` in a run exactly as it did. An execution whose fragment never writes +the element resolves nothing and is unaffected by any of them. + +What stays contextual is the interaction and only the interaction. The pinned +body reaches the Elicitation Api lexically in scope where the fragment runs, so +the host, an enclosing `` region or the established missing-provider +refusal owns how a person is asked — exactly as for an authored ``. Its +validated answer is retained by the ordinary `elicit` durable operation, may drive +a following write through ordinary bindings, and is restored on replay without +contacting a provider. The `generated_xmd` record keeps the component identity and +the admitted form and nothing about the answer. An admitted `` is the same element an author writes. Its props, return contract, source rules and sanitized failure sentence are one shared definition @@ -5324,10 +5353,11 @@ function* getExpansion(): Operation; `getExpansion()` answers with a detached, frozen snapshot, and answers with the same object throughout one live expansion. `name` is the authored tag name, independent of which repository, registered or built-in component resolved it. -`position` is the opening tag's source position, carrying `path`, `offset`, -`line` and `column`; `path` is absent for markdown scanned at runtime, which -belongs to no file, and `position` itself is absent for an element that carries -none. A nested expansion covers the enclosing one, and leaving it uncovers that +`position` is the opening tag's source position, carrying `offset`, `line`, +`column` and the one source it names — `path` for a file, or `generatedSource` +for an element inside admitted generated source. Both are absent for markdown +scanned at runtime, which belongs to no durable source, and `position` itself is +absent for an element that carries none. A nested expansion covers the enclosing one, and leaving it uncovers that one again. Nothing else about the expansion is reachable — not the element, its props, its bindings, its projected content, the definition resolution selected, or any live scope. @@ -9905,8 +9935,8 @@ interface PluginInstallation { `Plugin(input)` is the canonical constructor and preserves the value it is given. A selected module's default export is its Plugin. `command` is the -normalized public top-level command — `run`, `plan`, `test`, `syntax`, `upgrade` -or `workflow`, with the shorthand document form reported as `run` — and `args` +normalized public top-level command — `run`, `plan`, `test`, `syntax`, `upgrade`, +`workflow` or `repl`, with the shorthand document form reported as `run` — and `args` is a frozen copy of the original argv, taken before `--plugin` extraction or any other scanner changed it. The internal `test-agent` worker mode, `--help` and `--version` install no Plugin and load no module. @@ -10747,6 +10777,23 @@ Workflow history parses the optional field as a `SourcePosition` and never reconstructs it from an expansion ID or current source. Root, Close and trusted-host events may have no authored source. +A position names **one** source. A file states `path`. An operation inside +admitted generated source states `generatedSource`: the id that fragment was +admitted under, which is the `generated_xmd` record's own `generated:{id}` name. +Generated text is not a file, so there is no path for it to name, and the +identity is what lets a reader say which fragment performed a given effect +without inferring it from journal order, from an effect's name or from anything +live. Dynamic text that belongs to no durable source states neither member. Both +members together is malformed history, and a reader refuses the record rather +than choosing between them; `offset`, `line` and `column` are unchanged and are +relative to the source the position names. + +Whole-fragment preflight is where the member is decided: the scan it performs +over the candidate text stamps the request id on every executable position in +that fragment, before the fragment performs anything. Nothing watches the +expansion to attribute effects afterwards, and the stamp ends with the +fragment's own scanned positions. + | Operation | Effect type | Effect name | Notes | |-----------|------------|-------------|-------| | Import component | `import_component` | `{ComponentName}` | path + content in result | @@ -12667,6 +12714,7 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8.4. | WGAC11 | Explicit composition | A fragment binds read results locally, composes selected values through Json, and renders no value or mutation receipt it did not explicitly render | | WGAC12 | Committed mutations | A completed replay of a write-enabled document journals nothing new, performs no second mutation, and leaves the retained content | | WGAC16 | Bundled continuation | `` inside a committed bundled Markdown component, then a parent `` write and the real `` outside it, for a generated read and a generated write alike: the real start suspends holding the exactly ordered `generated_xmd → nested workspace_file → parent workspace_file → suspension_request` subsequence; answer delivery and the completed resume leave the journal counts, that subsequence, the Workspace root-publication count and the authoritative current root unchanged; and the delivered value reaches the document after the wait. `API.Files` component calls are not evidence here — re-expansion legitimately enters that boundary before the durable effect restores | +| WGAC18 | A generated question is the host's to admit | This host's generated write table states no question, so a fragment writing `` under `allow={["write"]}` is refused for a component it did not admit, with nothing asked and nothing performed; a host that states the entry admits the same fragment. The workflow's own suspension replacement for the authored element is unaffected either way — it answers an authored `` in a run, and a generated one never resolves a name for it to shadow | | WGAC17 | Directory mutation permission | `allow={["write"]}` admits the versioned paired `` and intentionally authorizes its persistent recursive creation; a continuation retaining the former `@executablemd/workflow/composition#Dir` refuses before generated execution and creates nothing, while an unchanged current admission replays without a second ensure | ### Tier WAL — The workflow Agent observation loop @@ -13411,7 +13459,7 @@ Plugin is the run it always was. | PL2 | Module admission | An object with a non-empty `name` and an absent or callable `install` is admitted; a non-object, a missing, empty or non-string name, and a non-callable `install` each refuse naming the specifier and the member that failed | | PL2a | Admission returns the value | What comes back is the admitted object itself: members outside the contract survive, `install` is the same function the module exported, and calling it through admission gives it the receiver its own module gave it | | PL3 | The flag grammar | Both spellings are read in occurrence order, only those tokens are removed, the scan stops at `--`, the original argv is retained frozen, and a missing or option-shaped value refuses before anything loads | -| PL4 | The command a Plugin is told | Each public command reports its own name and the shorthand document form reports `run`; help, `--version` and the internal worker mode install no Plugin and load no module | +| PL4 | The command a Plugin is told | Each public command reports its own name — `repl` included — and the shorthand document form reports `run`; help, `--version` and the internal worker mode install no Plugin and load no module | | PL5 | Order composes | The bundled Plugin comes first where the command's profile carries it, then the repeated `--plugin` occurrences in written order; the first Plugin installed is the outermost `Document` wrapper, and reversing the selection reverses the composition behind the prefix | | PL6 | One name, one Plugin | Two selections claiming one Plugin name refuse before the first `install()` runs, whichever modules they came from | | PL7 | The active list | Every Plugin, including the first, reads the complete frozen list — bundled value first where the profile carries it — and a snapshot is not the installed array | @@ -13426,7 +13474,7 @@ Plugin is the run it always was. | PL14 | The key, not the name | An Api built under the bare names `Document`, `RootMetadata` or `ActivePlugins` composes nothing and observes nothing; one built under the published key composes, including from a second loaded copy inside the compiled binary | | PL15 | Selection by package | A bare specifier resolves in the invocation directory's package environment, and the Plugin's own name is what identifies it — not the package or module it came from | | PL16 | Cancellation | A command halted while a Plugin is still installing releases what the Plugins before it acquired, installs nothing after it, and stays a cancellation; a scope whose body and teardown both fail reports exactly what it reported before Plugins existed | -| PL17 | One bundled Plugin, and nothing else by default | A command that names no `--plugin` installs exactly the bundled `@executablemd/git` where its profile carries it — `run`, `plan`, `syntax` and workflow `start`/`resume`/`fork` — and nothing at all for `xmd test`, `upgrade` and a workflow management action; an unselected Plugin such as the review graph is absent from the symbols and from a run until it is named, and present in both once it is | +| PL17 | One bundled Plugin, and nothing else by default | A command that names no `--plugin` installs exactly the bundled `@executablemd/git` where its profile carries it — `run`, `plan`, `syntax`, `repl` and workflow `start`/`resume`/`fork` — and nothing at all for `xmd test`, `upgrade` and a workflow management action; an unselected Plugin such as the review graph is absent from the symbols and from a run until it is named, and present in both once it is | | PL19 | The reserved selector | `--plugin git` names the bundled value rather than a module: it loads nothing, and writing it once or repeatedly leaves one `@executablemd/git` first in the active list. It cannot give a command a profile it does not have — `--plugin git` before a workflow management action or `xmd test` still installs none | | PL20 | The selector is not the name | `--plugin @executablemd/git` is an ordinary specifier: it loads, and a module claiming the bundled Plugin's name is refused as a duplicate like any other collision. Identity-based idempotence belongs to the host's own selector for its own value, and a nested run child prefixing the bundled value drops it by identity so an impostor still collides | | PL21 | Test isolation | The outer `xmd test` root carries no bundled Plugin and resolves none of its names; a nested `` child assembles the run profile in its own scope and resolves all of them, and does not inherit what its parent declined | diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 70a4fb89f..6cf39b3c2 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -7,8 +7,40 @@ process or in another one. ```bash xmd repl # a fresh execution with an empty draft xmd repl 'xmd://repl//repl' # reopen exactly that retained history +xmd repl --deny-all # the same, answering every agent request with no ``` +## The command line, and the order it is read in + +`xmd repl` takes one optional location and the five Agent options `xmd run` +takes, with the same spellings, the same descriptions and the same defaults: +`--agent-provider` (default `acpx`), `--default-agent` (falling back to +`DEFAULT_AGENT_NAME`), and the mutually exclusive `--approve-all`, +`--approve-reads` and `--deny-all`. An unstated permission line means +`--approve-reads`. There is no sixth option: no include, no data directory and +no REPL-only knob. + +Everything that can be refused is refused in this order, and each step happens +before anything the next one would touch: + +1. **The line.** A token that is not the location or one of those five options, + a switch given a value, a value option given none, and a second location are + each refused here — before a per-user directory is formed, a history file is + created or the terminal's modes are touched. +2. **The Agent configuration.** Two permission switches together and an unknown + `--agent-provider` are refused next, before any adapter exists. +3. **The profile.** What this command's REPL runs under is assembled once, in the + command's own scope: the selected Plugins, the Agent identity vocabulary, the + packaged `` Component, the ordinary evaluation ceiling and the settled + permission mode. It is read from then on and never rebuilt, so two entries of + one session cannot run under different rules. +4. **The terminal.** Only then is a terminal asked for. Over a pipe the command + refuses, having left nothing behind. + +Nothing here starts an agent. An entry that asks for none materializes no +adapter at all; the provider validates availability the first time a turn wants +one. + ## One live journey Run `xmd repl`. The screen shows an empty draft, a Sessions list that says it is @@ -50,48 +82,6 @@ to the head. When the root settles, its recorded output is what the transcript shows, and the screen stops asking for frames. -## Agent conversations, and what one turn is waiting on - -When the entry runs Agent work, the Sessions surface is one chronology of it: -every turn this process observed and every turn the history holds, in the order -their Prompts were scheduled. A turn keeps its place when it publishes — the -same row, where it already was, reading from the record once there is one — -because a turn that has been recorded is still the turn you were looking at, and -a list that appended the live ones to the retained ones would reorder Prompts -that finished out of order. - -Each turn says what is known about it at that moment: its prompt, whether it is -queued, streaming, finished or recorded, what it has said so far, which agent and -which conversation it joined, and how it ended. - -The conversations you can filter by are the ones a provider actually started. A -queued turn belongs to none of them yet, and the name the document gave its -`` is not an answer to which conversation a provider opened, so a turn -without one appears under **All conversations** and nowhere else. Choosing a -conversation changes the filter and nothing else — not the surface, the selected -scope, the history position, the draft, the drawers or where focus is — and work -going on behind the screen never chooses or clears one for you. - -A turn waiting for permission says so on its own row. It opens nothing: nothing -moves, nothing takes focus, and nothing else stops. Activate it and a drawer -shows what is being asked — its kind, the call, whose turn is waiting and every -choice the provider offered, in the provider's order. A choice that lasts says it -lasts *for this Agent session*, because nothing here can make a rule that -outlives the conversation asking. Closing or pressing Escape denies the request -while the session keeps running, and the drawer closes only when the request is -really settled: a screen that closed first would be claiming an answer nobody -gave. Afterwards focus returns to the turn that was waiting. - -A permission the history already holds is a record of what a turn was granted. -It is read, never answered, and a view frozen at a history position shows no -live request at all. - -Both readings are windowed rather than clipped. The Sessions list and the -permission drawer each move through as many rows as the frame can place, with -their earlier and later controls — and the way to the other surface — staying -put while the rows move beneath them. What the window is not showing is not -drawn, not focusable and reaches no pointer. - ## One cold journey The command prints the location it ended at. Pass that location to a new @@ -106,6 +96,128 @@ reconstructed from the location and the retained events. A location naming a history this version cannot read refuses, with nothing appended and no execution started. +## The packaged Plan, in the REPL's own drawer + +`` is the same packaged Component `xmd plan` runs, declared by this command +rather than reimplemented by it. Its authorship turns go to the configured +provider, and its review is asked through the REPL's own Elicitation provider — +so the plan you are reviewing appears in the drawer in front of you rather than +in a readline this process is not reading. + +The review is a bounded form: approve, request changes, or stop. Choosing +"request changes" without the feedback that option requires is refused like any +other invalid answer — the review stays open, no answer is recorded and no turn +begins. Supplying it resumes the same provider conversation, and what comes back +is reviewed again. Approving admits exactly the source the provider returned: the +returned program is rendered at the `` invocation and executed inline by the +enclosing ``, never appended as unrelated output. + +A returned program is a generated fragment, so it runs under the ordinary ceiling +and the generated-expression grammar: it may ask questions, write files and +compose values it holds, and it may not compute one +(`specs/executable-mdx-spec.md` §5.3.3). Canonical `` is in that ceiling's +write table, so a generated program can preview what it is about to do and ask +before doing it — and its validated answer drives what follows through ordinary +bindings. + +## Sessions: one chronology, and which conversation you are reading + +The Sessions surface shows every Agent turn this execution has, in one order: +the turns the history retains, then the turns this process is watching, by the +order they were scheduled. A turn says how far it has got — queued, streaming, +how it ended, and whether the history holds it yet — and a turn that has finished +is not the same as one that has been recorded: only the second is a position you +can go to. + +A turn keeps its identity when its record arrives. The row a person is reading is +the same row before and after publication, and what replaces it is its own +record rather than a second row beside it. + +Each turn says what is known about it at that moment: its prompt, what it has +said so far, which agent and which conversation it joined, and how it ended. + +Conversations are the provider's own session keys, offered earliest-observed +first. A queued turn belongs to none of them yet, and the name the document gave +its `` is not an answer to which conversation a provider opened, so a +turn without one appears under **All conversations** and nowhere else. Selecting +one filters the surface to that conversation and changes nothing else: the route +gains a session and keeps its surface, its scopes and its history position. +Clearing it back to All restores the whole chronology. Work in the conversations +you are not reading — a turn starting, text streaming, a record appending — +moves neither the route, the history position nor the focused control. + +The list is windowed rather than clipped: it moves through as many rows as the +frame can place, with its earlier and later controls — and the way to the other +surface — staying put while the rows move beneath them. What the window is not +showing is not drawn, not focusable and reaches no pointer. + +## Permission: a live request, and a retained audit + +A turn waiting for permission says so on its own row, and that is all arriving +does: nothing moves, nothing takes focus, and nothing else stops. Activate it and +a drawer shows what is being asked — its kind, the call, whose turn is waiting, +and every choice the provider offered in the provider's order. A choice that +lasts says it lasts *for this Agent session*, because nothing here can make a +rule that outlives the conversation asking. The drawer is windowed like the list +behind it, so however many options a provider offers, each one can be reached and +`[close]` never scrolls away. + +Choosing one answers that request; closing the drawer denies it, which is the +same decision the session's own policy would make and is recorded as such. Either +way the drawer closes only when the request is settled — a screen that closed +first would be claiming an answer nobody gave — and focus returns to the turn +that was waiting. + +A retained audit is inert. What the history holds is what was granted, read and +never answered again — a recorded request offers no control, and a cold process +shows the audit without offering to decide anything. Ending the command answers +nothing: a torn-down process does not fabricate a denial, a selection or a +cancelled audit on the person's behalf. + +## The form language a question is drawn in + +A question's schema is read whole before anybody is asked. What this REPL draws +is a closed object of string fields, each with its title, description, whether it +is required, its minimum length and the exact enum values it accepts, plus at +most one `if`/`then` condition that requires further fields when one field takes a +stated value. A schema outside that language is refused by the provider, naming +the exact path that put it outside — and the refusal has asked nobody, published +no drawer and moved no counter. + +Every answer is validated by the compiler that will judge it, so what the screen +enforces and what the record accepts cannot disagree. An invalid answer keeps the +question open with its issues under the fields they belong to. A field that was +deliberately cleared is present and empty; one nobody touched is absent. + +## Cold Agent reconstruction + +A cold process over a retained location shows the Agent work the history holds: +the turns, their conversations, their text, how each ended, the questions that +were asked and the answers that were given, the program a generated fragment +admitted and what it wrote. + +Work a packaged Component did on the entry's behalf is shown under that +Component. A declared component's record retains the origin it is known by and +the bytes it is, so the scope every effect inside it is recorded against is the +one the history already describes — and the program that Component returned is a +scope of its own beneath it, owning the questions the program asked, which carry +a place in the generated source and no path at all. A turn whose provider failed or was cancelled +restores with the text it had streamed and the status it ended with, as terminal +retained state — never as queued, streaming or reconnecting. + +No provider is reached to do it, no adapter is materialized, and nothing is +appended: the history file is byte-identical before and after a cold run. + +## How this command ends + +EOF, a renderer that cannot present, a terminal that fails mid-read and a +cancelled command scope all end the same way: the owner cancels and joins every +Prompt, provider task, pending permission operation, observer and frame +subscription it started, appends nothing after that, and gives the terminal's +modes back exactly once. Work that was interrupted leaves no record, because a +record for it would be a record of something that did not happen. What was +already appended stays readable, and is what a cold process shows. + ## What the screen does at each size | terminal | what it shows | @@ -138,8 +250,8 @@ was refused. It does not replace the screen. This product admits zero or one entry per execution. There is no second entry, no catalog of past runs, no fork, no snapshot and no sidecar file. The Sessions -surface presents the Agent work that entry did, and says it is empty until there -is some. +surface presents the Agent work that entry did, and an execution with no Agent +work has one that says so. ## What is retained, and what is not @@ -187,8 +299,15 @@ schedules no timer. - Two processes writing one execution is unsupported. There is no lease protocol; do not open the same location twice for writing. -- `xmd repl` takes one optional location and no options. It renders no file and - takes no document reference. +- `xmd repl` takes one optional location and the five Agent options above. It + renders no file and takes no document reference. +- `` is unavailable. This command has no terminal to give away — + it is using it — so a document that reaches for a native agent UI refuses + through the established missing-launcher contract, starts no foreground + process, and leaves terminal ownership and restoration with the REPL. +- There is no browser elicitation and no readline permission handling. A question + is answered in the drawer and a permission request in the surface, or not at + all. - The REPL runs where a terminal is: it is not available over a pipe, and help acquires no terminal or filesystem capability. Whether there is a terminal is settled before a history exists, so a piped invocation leaves nothing behind.