From a4907b468777e4112f87ff80d26f0e692fa73e35 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 20 Oct 2026 22:45:53 -0400 Subject: [PATCH] fix(cli): support component includes in the REPL --- packages/cli/src/cli.ts | 18 +- packages/cli/src/repl-profile.ts | 15 +- packages/cli/tests/cli-help.test.ts | 68 ++++- packages/cli/tests/repl-agent-journey.test.ts | 259 ++++++++++++++++- packages/cli/tests/repl-boundaries.test.ts | 25 +- packages/cli/tests/repl-execution.test.ts | 48 +++- packages/cli/tests/repl-include-pty.test.ts | 263 ++++++++++++++++++ specs/repl-spec.md | 30 +- 8 files changed, 687 insertions(+), 39 deletions(-) create mode 100644 packages/cli/tests/repl-include-pty.test.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 10a39878e..afb9c0d79 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -308,10 +308,9 @@ const REPL_DESCRIPTION = "Run and reconstruct one XMD entry in an interactive te * * 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. + * descriptions, defaults and environment precedence. Component directories use + * the same ordered include option. The command takes no document reference or + * props file and exposes no trace or execution deadline. * * The one option of its own is `--sidekick-agent`, because this command has a * Sidekick and a run has none. It is a separate selection from @@ -326,6 +325,7 @@ const replConfig = object({ "that retained history and the exact view it names", ...field(z.string().optional(), cli.argument()), }, + include: executionFields.include, ...agentFields, sidekickAgent: { description: "agent name for the Sidekick's own conversations", @@ -597,6 +597,10 @@ const REPL_HELP = [ " Each one starts from the bindings the entries before it published, so a", " later entry can read and revise what an earlier one left behind.", "", + " --include and --include= select component directories.", + " Repeat them in search order; explicit values replace the default list [components, .].", + " Relative directories resolve from the invocation working directory.", + "", " The screen always shows a location of the form", " xmd://repl//..., and when the command ends it prints the", " whole xmd repl that reopens the one it ended at. Running it", @@ -652,7 +656,7 @@ export interface ReplHostAssembly { * silence would let them believe they got it. */ /** - * The three REPL options that carry a value, by the spelling a caller writes. + * The four REPL options that carry a value, by the spelling a caller writes. * * Exported because the documentation check reads this command's own examples, * and a token after one of these is that option's value rather than a location. @@ -660,6 +664,7 @@ export interface ReplHostAssembly { * command about what it had been handed. */ export const REPL_VALUES: readonly string[] = [ + "--include", "--agent-provider", "--default-agent", "--sidekick-agent", @@ -716,7 +721,7 @@ export function replGrammarError( return ( `unrecognized option for xmd repl: ${name} — xmd repl takes one optional location, ` + "the agent options --agent-provider, --default-agent, --approve-all, --approve-reads " + - "and --deny-all, and --sidekick-agent for the Sidekick's own conversations" + "and --deny-all, --include for component directories, and --sidekick-agent for the Sidekick's own conversations" ); } if (positional.length > 1) { @@ -2978,6 +2983,7 @@ function* dispatch( // whether anybody answers to that name is the first explicit message's // question rather than this command's. const profile = yield* assembleReplProfile(replStack, plugins, { + includes: command.config.include, ...(command.config.sidekickAgent === undefined ? {} : { sidekickAgent: command.config.sidekickAgent }), diff --git a/packages/cli/src/repl-profile.ts b/packages/cli/src/repl-profile.ts index e2c363073..929de5dcd 100644 --- a/packages/cli/src/repl-profile.ts +++ b/packages/cli/src/repl-profile.ts @@ -33,13 +33,6 @@ import { ReplEntryHost } from "./repl-entry-installation.ts"; import type { ReplEntryInstallation } from "./repl-entry-installation.ts"; import { renderSyntaxDocumentation, renderSyntaxMarkdown, syntaxSymbols } from "./syntax.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", "."]); /** @@ -65,6 +58,7 @@ export interface ReplProfileSeams { * parameter: a caller states what it knows and leaves out the rest. */ export interface ReplProfileFacts extends ReplProfileSeams { + readonly includes?: readonly string[]; /** * The name `--sidekick-agent` carried, or none when the line named none. * @@ -201,6 +195,7 @@ export function* assembleReplProfile( plugins: CommandPlugins, facts: ReplProfileFacts = {}, ): Operation { + const includes = Object.freeze([...(facts.includes ?? REPL_INCLUDES)]); yield* installAgentProviderStack(stack, facts.acp); // Settled here, with the stack and the Plugins, because it is a fact about // the command a person invoked rather than about any conversation they later @@ -212,7 +207,7 @@ export function* assembleReplProfile( const plan = yield* planComponentDeclaration({ surface: "component", ...(facts.planWriterRoot === undefined ? {} : { planWriterRoot: facts.planWriterRoot }), - includes: REPL_INCLUDES, + includes, // The command's own Plugin assembly, not a second installation of it: two // would be two catalogs claiming the same names. plugins, @@ -234,8 +229,8 @@ export function* assembleReplProfile( const entryInstallation = yield* ReplEntryHost.get(); return Object.freeze({ - includes: REPL_INCLUDES, - vocabulary: entryVocabulary(REPL_INCLUDES, plugins), + includes, + vocabulary: entryVocabulary(includes, plugins), // Read off the admitted assembly here, once, while it is in hand. Copied // rather than referenced so nothing downstream can reach a Plugin value // through the profile, and frozen because a profile is read and never diff --git a/packages/cli/tests/cli-help.test.ts b/packages/cli/tests/cli-help.test.ts index 6b6745283..945531a20 100644 --- a/packages/cli/tests/cli-help.test.ts +++ b/packages/cli/tests/cli-help.test.ts @@ -9,13 +9,15 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { runCli } from "@executablemd/test-support/launch"; +import { cliRuntime, runCli, runShell, shellQuote } from "@executablemd/test-support/launch"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { ensure, scoped } from "effection"; import type { Operation } from "effection"; import { randomUUID } from "node:crypto"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { useTempDirectory } from "@executablemd/test-support/temp"; function* useFixture( files: Record, @@ -248,6 +250,8 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false // claim this row makes is unchanged — the command's own help describes what // an execution does with the entries it is given — and it is now the // behavior the mounted journeys actually prove. + expect(own.stdout).toContain("--include"); + expect(own.stdout).toContain("explicit values replace the default list [components, .]"); expect(own.stdout).toContain("ENTRIES"); expect(own.stdout).toContain("An execution admits one entry at a time"); expect(own.stdout).toContain("the bindings the entries before it published"); @@ -293,7 +297,7 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false 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)"); - // The five are all of what it shares: this command adds no data directory, + // The Agent fields are shared alongside the include field: this command adds no data directory, // and the one option it adds of its own is covered by C2 below. expect(repl.stdout).not.toContain("--data-dir"); }); @@ -308,6 +312,11 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false ["repl", "--deny-all"], ["repl", "--agent-provider", "acpx"], ["repl", "--default-agent", "claude"], + ["repl", "--include", "../first"], + ["repl", "--include=../first"], + ["repl", "--include", "../first", "--include=/absolute/second"], + ["repl", "xmd://repl/kf39sla2/entries", "--include", "../first"], + ["repl", "--include=../first", "xmd://repl/kf39sla2/entries"], ]) { const ran = yield* runCli(accepted).join(); expect([accepted, ran.code]).toEqual([accepted, 1]); @@ -341,6 +350,61 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false expect(withLocation.stderr).toContain("at most one location"); }); + it("CH13: missing include values refuse before terminal acquisition", function* () { + for (const args of [ + ["repl", "--include"], + ["repl", "--include="], + ["repl", "--include", ""], + ["repl", "--include", "--deny-all"], + ]) { + const ran = yield* runCli(args).join(); + expect([args, ran.code, ran.stderr]).toEqual([ + args, + 1, + "xmd repl: --include requires a value\n", + ]); + } + }); + + if (cliRuntime() === "deno") { + it("A7: the repository task accepts selected directories through the source CLI", function* () { + const root = yield* useTempDirectory("xmd-include-task-"); + const first = path.join(root, "first"); + yield* ensureDir(first); + const cacheInfo: unknown = JSON.parse((yield* runShell("deno info --json").expect()).stdout); + if ( + typeof cacheInfo !== "object" || + cacheInfo === null || + !("denoDir" in cacheInfo) || + typeof cacheInfo.denoDir !== "string" + ) { + throw new Error("deno info supplied no prepared cache path"); + } + const repository = fileURLToPath(new URL("../../../", import.meta.url)); + for (const args of [ + ["--include", first], + [`--include=${first}`, "--include", first], + ]) { + const ran = yield* runShell( + ["deno", "task", "xmd", "repl", ...args].map(shellQuote).join(" "), + { + cwd: repository, + env: { + HOME: root, + XDG_DATA_HOME: path.join(root, "share"), + DENO_DIR: cacheInfo.denoDir, + }, + }, + ).join(); + expect(ran.code).toBe(1); + expect(ran.stderr).toContain( + "xmd repl: the REPL runs where a terminal is: it is not available over a pipe.", + ); + expect(ran.stderr).not.toContain("unrecognized option"); + } + }); + } + it("C2: repl states its own Sidekick agent option, and names a value or refuses", function* () { const repl = yield* runCli(["repl", "--help"]).expect(); const run = yield* runCli(["run", "--help"]).expect(); diff --git a/packages/cli/tests/repl-agent-journey.test.ts b/packages/cli/tests/repl-agent-journey.test.ts index ec1750591..81d6577cd 100644 --- a/packages/cli/tests/repl-agent-journey.test.ts +++ b/packages/cli/tests/repl-agent-journey.test.ts @@ -34,10 +34,16 @@ import { } from "effection"; import type { Operation, Stream, Subscription, Task } from "effection"; import { useTempFileCompiler } from "@executablemd/core"; -import { API, useHostFiles } from "@executablemd/runtime"; +import { ensureDir, writeTextFile } from "@effectionx/fs"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import { ReplHost } from "../src/repl/host.ts"; +import { rootReads, sidekickRequests } from "../src/repl/sidekick/requests.ts"; +import { API, cwd, 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 { join, relative } from "node:path"; import { randomBytes } from "node:crypto"; import { decodeLocation, encodeLocation } from "../src/repl/route.ts"; @@ -444,8 +450,7 @@ describe("C1 — the command line this REPL runs under", () => { // 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. + // Omission preserves both default directories. expect(settled.includes).toEqual(["components", "."]); // One installation of this command's own, holding the packaged ``. expect(settled.installations).toHaveLength(1); @@ -576,6 +581,252 @@ describe("C1 — the command line this REPL runs under", () => { }); }); +function greetingSource(which: "First" | "Second"): string { + const prop = `from${which}`; + return [ + "---", + `description: ${which} greeting contract.`, + "props:", + " type: object", + " properties:", + ` ${prop}: { type: string }`, + ` required: [${prop}]`, + " additionalProperties: false", + "---", + "", + `${which} greeting rendered.`, + "", + ].join("\n"); +} + +function* includeWorkspace(): Operation<{ + workspace: string; + first: string; + second: string; + root: string; +}> { + const root = yield* useTempDirectory("xmd-includes-"); + const workspace = join(root, "workspace"); + const first = join(root, "first"); + const second = join(root, "second"); + for (const directory of [join(workspace, "components"), join(first, "File"), second]) { + yield* ensureDir(directory); + } + yield* writeTextFile(join(first, "Greeting.md"), greetingSource("First")); + yield* writeTextFile(join(second, "Greeting.md"), greetingSource("Second")); + yield* writeTextFile(join(workspace, "components", "Local.md"), "local-only output\n"); + yield* writeTextFile(join(workspace, "RootLocal.md"), "root-only output\n"); + for (const candidate of ["Syntax", "Evaluate", "File", "File/Delete", "Dir"]) { + yield* writeTextFile(join(first, `${candidate}.md`), "POISON protected candidate\n"); + } + yield* useHostFiles(); + return { root, workspace, first, second }; +} + +function selectedProfile( + includes: readonly string[], + root: string, + fake: FakeAcp, +): Operation { + return assembleReplProfile( + { provider: "acpx", defaultAgent: AGENT, adapters: ADAPTERS, permissionMode: "deny-all" }, + NO_PLUGINS, + { + includes, + planWriterRoot: join(root, "plans"), + acp: { + createRuntime: fake.create, + sessionStore: makeStore(), + agentRegistry: makeRegistry({ [AGENT]: `${AGENT}-cmd` }), + }, + }, + ); +} + +describe("A2–A5 — the selected REPL component environment", () => { + beforeAll(() => useTempFileCompiler()); + + it("A2/A5: one detached ordered selection describes vocabulary, protected names and environment", function* () { + const fixture = yield* includeWorkspace(); + const relativeFirst = relative(yield* cwd(), fixture.first); + const input = [relativeFirst, fixture.second, relativeFirst]; + const reached: string[] = []; + const facts = { + includes: input, + planWriterRoot: join(fixture.root, "plans"), + acp: { createRuntime: tripwireAcp((what) => reached.push(what)) }, + }; + const profile = yield* assembleReplProfile( + { provider: "acpx", defaultAgent: AGENT, adapters: ADAPTERS, permissionMode: "deny-all" }, + NO_PLUGINS, + facts, + ); + input.reverse(); + input.splice(0, input.length, "components", "."); + facts.includes = ["elsewhere"]; + expect(profile.includes).toEqual([relativeFirst, fixture.second, relativeFirst]); + expect(Object.isFrozen(profile)).toBe(true); + expect(Object.isFrozen(profile.includes)).toBe(true); + const symbols = yield* profile.vocabulary.symbols(); + const documentation = yield* profile.vocabulary.documentation(["Greeting"]); + for (const text of [symbols, documentation]) { + expect(text).toContain(`${relativeFirst}/Greeting.md`); + expect(text).toContain("First greeting contract."); + expect(text).not.toContain("Second greeting contract."); + expect(text).not.toContain(""); + expect(text).not.toContain(""); + expect(text).not.toContain("POISON"); + } + const protectedNames = ["Syntax", "Evaluate", "File", "File.Delete", "Dir"]; + const protectedDocs = yield* profile.vocabulary.documentation(protectedNames); + for (const name of protectedNames) { + expect(symbols.split(`### \`<${name}>\``)).toHaveLength(2); + expect(protectedDocs.split(`### \`<${name}>\``)).toHaveLength(2); + } + expect(protectedDocs.match(/\(protected component\)/g)).toHaveLength(5); + expect(protectedDocs).not.toContain(fixture.first); + const sessionResult = yield* openReplSession({ + execution: { id: "include-profile", stream: new InMemoryStream() }, + includes: profile.includes, + installations: profile.installations, + generated: profile.generated, + }); + if (!sessionResult.ok) { + throw sessionResult.error; + } + const root = { + ...rootReads(sessionResult.value, profile), + *fill(): Operation { + throw new Error("environment filled a draft"); + }, + }; + yield* ReplHost.around( + { + *environment() { + return []; + }, + }, + { at: "min" }, + ); + const environment = yield* sidekickRequests(root, function* (): Operation { + throw new Error("environment submitted work"); + }).environment(); + if (!environment.ok) { + throw environment.error; + } + expect(environment.value).toContain( + `- component includes: ${relativeFirst}, ${fixture.second}, ${relativeFirst}`, + ); + expect(reached).toEqual([]); + }); + + it("A3: preflight and execution select the same collision in either directory order", function* () { + const fixture = yield* includeWorkspace(); + for (const which of ["First", "Second"] satisfies readonly ("First" | "Second")[]) { + yield* scoped(function* () { + const relativeFirst = relative(yield* cwd(), fixture.first); + const includes = + which === "First" ? [relativeFirst, fixture.second] : [fixture.second, relativeFirst]; + const fake = createFakeAcp(); + const profile = yield* selectedProfile(includes, fixture.root, fake); + const stream = new InMemoryStream(); + const options = { + execution: { id: `include-${which}`, stream }, + includes: profile.includes, + installations: profile.installations, + generated: profile.generated, + }; + const wrong = which === "First" ? "Second" : "First"; + const refused = yield* submitReplEntry({ + ...options, + source: ``, + }); + expect(refused.ok).toBe(false); + expect(yield* stream.readAll()).toEqual([]); + const accepted = yield* submitReplEntry({ + ...options, + source: ``, + }); + if (!accepted.ok) { + throw accepted.error; + } + const session = accepted.value; + const result = yield* session.join(); + expect(result.ok).toBe(true); + expect(session.model.terminal?.output).toContain(`${which} greeting rendered.`); + const selectedPath = join( + which === "First" ? fixture.first : fixture.second, + "Greeting.md", + ); + expect(JSON.stringify(yield* stream.readAll())).toContain(selectedPath); + expect(yield* profile.vocabulary.documentation(["Greeting"])).toContain(selectedPath); + expect(fake.prompts).toEqual([]); + }); + } + }); + + it("A5: packaged Plan repairs against the selected environment and returns the approved source", function* () { + const fixture = yield* includeWorkspace(); + const fake = createFakeAcp(); + fake.script({ reply: "# Unavailable draft\n\n\n" }); + const approved = '# Included draft\n\n\n'; + fake.script({ reply: approved }); + const { terminal, install } = recordingTerminal({ columns: 200, rows: 140 }); + yield* install(); + yield* immediateClock(); + const hostRoot = yield* useTempDirectory("xmd-include-history-"); + yield* useTemporaryHost(hostRoot); + const running = yield* spawn(function* () { + const profile = yield* selectedProfile( + [relative(yield* cwd(), fixture.first), fixture.second], + fixture.root, + fake, + ); + const ran = yield* runReplProgram({ profile }); + if (!ran.ok) { + throw ran.error; + } + }); + yield* untilDrawn(terminal); + yield* typed(terminal, 'Write an included greeting.\n\n{draft}'); + yield* until(() => fake.prompts.length === 2, "Plan never repaired the unavailable component"); + expect(fake.prompts[1]).toContain("Local"); + expect(fake.prompts[0]).toContain("First greeting contract."); + expect(fake.prompts[0]).toContain(`${relative(yield* cwd(), fixture.first)}/Greeting.md`); + expect(fake.prompts[0]).not.toContain("Second greeting contract."); + expect(fake.prompts[0]).not.toContain(""); + expect(fake.prompts[0]).not.toContain(""); + yield* openQuestion(terminal); + yield* click(terminal, "( ) Approve"); + yield* showing(terminal, "Included draft"); + yield* awaiting("approved Plan entry never closed", function* () { + return (yield* journal(hostRoot)).some( + (event) => event.type === "close" && event.coroutineId === "root", + ); + }); + terminal.end(); + yield* running; + const model = projectRepl( + (yield* journal(hostRoot)).map((event) => { + const parsed = parseDurableEvent(JSON.stringify(event)); + if (!parsed.ok) { + throw parsed.error; + } + return parsed.value; + }), + ); + if (!model.ok) { + throw model.error; + } + expect( + model.value.entries[0]?.bindings.find((binding) => binding.name === "draft")?.value, + ).toBe(approved); + expect(model.value.terminal?.output).toBe(`\n\n${approved}`); + expect(model.value.terminal?.output).not.toContain("First greeting rendered."); + expect(fake.prompts).toHaveLength(2); + }); +}); + describe("J3 — durable truth, and a cold process over it", () => { beforeAll(() => useTempFileCompiler()); diff --git a/packages/cli/tests/repl-boundaries.test.ts b/packages/cli/tests/repl-boundaries.test.ts index b532c26e9..76bf74ae5 100644 --- a/packages/cli/tests/repl-boundaries.test.ts +++ b/packages/cli/tests/repl-boundaries.test.ts @@ -480,7 +480,7 @@ describe("REPL documentation: what it says is what the code does", () => { } }); - it("D1: the six options the spec names are the six the parser accepts", function* () { + it("D1: the seven options the spec names are the seven 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]); @@ -490,6 +490,7 @@ describe("REPL documentation: what it says is what the code does", () => { "--approve-reads", "--default-agent", "--deny-all", + "--include", "--sidekick-agent", ]); @@ -498,7 +499,7 @@ describe("REPL documentation: what it says is what the code does", () => { 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", "--sidekick-agent"]) { + for (const option of ["--agent-provider", "--default-agent", "--sidekick-agent", "--include"]) { expect([option, replGrammarError(["repl", option, "acpx"], undefined)]).toEqual([ option, undefined, @@ -510,10 +511,10 @@ describe("REPL documentation: what it says is what the code does", () => { expect(replGrammarError(["repl", option], undefined)).toContain("requires a value"); } - // And there is no seventh: an option the specification does not name is + // And there is no eighth: 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", + expect(replGrammarError(["repl", "--json"], undefined)).toContain( + "unrecognized option for xmd repl: --json", ); }); @@ -764,7 +765,11 @@ const UPGRADE: UpgradeAssembly = { describe("REPL command: what runs before the host is reached", () => { it("S1: help describes the command without assembling a REPL", function* () { - for (const args of [["--help"], ["repl", "--help"]]) { + for (const args of [ + ["--help"], + ["repl", "--help"], + ["repl", "--include", "../first", "--help"], + ]) { const ran = yield* invoke(args); expect(ran.status).toBe(0); // Describing a command is not running one. @@ -796,6 +801,10 @@ describe("REPL command: what runs before the host is reached", () => { for (const args of [ ["repl", "one", "two"], ["repl", "--json"], + ["repl", "--include"], + ["repl", "--include="], + ["repl", "--include", ""], + ["repl", "--include", "--deny-all"], ["repl", "xmd://nope"], ]) { const ran = yield* invoke(args); @@ -803,6 +812,7 @@ describe("REPL command: what runs before the host is reached", () => { // No data directory, no history file, no terminal: the command line was // answered before any of them could be reached for. expect(ran.installed).toBe(0); + expect(ran.selected).toBe(0); } }); @@ -811,7 +821,8 @@ describe("REPL command: what runs before the host is reached", () => { // that could never increment cannot pass the two rows above. This host // assembles nothing, so the command fails once it asks for a data directory — // which is the point: it got that far. - const ran = yield* invoke(["repl"]); + const ran = yield* invoke(["repl", "--include", "../first", "--include=/absolute/second"]); expect(ran.installed).toBe(1); + expect(ran.selected).toBe(1); }); }); diff --git a/packages/cli/tests/repl-execution.test.ts b/packages/cli/tests/repl-execution.test.ts index 60e576ca3..82e7495c3 100644 --- a/packages/cli/tests/repl-execution.test.ts +++ b/packages/cli/tests/repl-execution.test.ts @@ -20,13 +20,14 @@ import { InMemoryStream, serializeDurableEvent } from "@executablemd/durable-str import type { DurableEvent } from "@executablemd/durable-streams"; import { createContext, ensure, race, scoped, sleep, spawn, until } from "effection"; import type { Operation, Result } from "effection"; -import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { ensureDir, readTextFile, rm, writeTextFile } from "@effectionx/fs"; import { API } from "@executablemd/runtime"; import { randomUUID } from "node:crypto"; import { appendFile, open } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { useTempDirectory } from "@executablemd/test-support/temp"; import { ordinaryGeneratedSelection } from "../src/evaluation-profile.ts"; import { selecting } from "../../core/tests/support/generated-selection.ts"; import { @@ -48,7 +49,13 @@ import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ExpansionController } from "../src/repl/expansion.ts"; import type { ReplSession } from "../src/repl/session.ts"; import type { ReplQuestion } from "../src/repl/elicitation.ts"; -import { Component, DocumentOutput, Elicitation, Execution } from "@executablemd/core"; +import { + Component, + DocumentOutput, + Elicitation, + Execution, + installAgentComponents, +} from "@executablemd/core"; import { REFERENCE_DIRECTORY, referenceEvents, @@ -490,12 +497,43 @@ describe("REPL execution: reopening one history", () => { }); it("X2: a settled history reconstructs the same terminal model and runs nothing", function* () { - const settled = yield* referenceEvents(); - const holder = execution(settled); - const session = opened(yield* openReplSession(options(holder))); + const root = yield* useTempDirectory("xmd-settled-include-"); + const component = join(root, "Checklist.md"); + yield* writeTextFile(component, yield* readTextFile(join(REFERENCE_DIRECTORY, "Checklist.md"))); + const holder = execution(); + const selected = { execution: holder, includes: [root], generated: GENERATED }; + const live = yield* scoped(function* () { + const performed = yield* countPerformed(); + const session = opened( + yield* submitReplEntry({ ...selected, source: yield* referenceSource() }), + ); + (yield* nextQuestion(session)).submit({ decision: "approve" }); + yield* session.join(); + expect(performed.reads).toContain(component); + expect(performed.compiles).toBeGreaterThan(0); + return session.model; + }); + const settled = yield* readAll(holder); + yield* rm(component); + const performed = yield* countPerformed(); + let providerCalls = 0; + yield* installAgentComponents({ + rootProvider: { + factory: function* () { + providerCalls += 1; + throw new Error("settled inspection reached an Agent provider"); + }, + options: { defaultAgent: "planner", permissionMode: "deny-all" }, + }, + }); + const session = opened(yield* openReplSession(selected)); yield* session.join(); expect(yield* readAll(holder)).toEqual(settled); + expect(performed.reads.filter((path) => path.startsWith(root))).toEqual([]); + expect(performed.compiles).toBe(0); + expect(providerCalls).toBe(0); + expect(session.model).toEqual(live); expect(session.elicitation.asked).toBe(0); expect(session.model.settled).toBe(true); expect(session.model.entries[0]?.scope.elicitations[0].answer).toEqual({ decision: "approve" }); diff --git a/packages/cli/tests/repl-include-pty.test.ts b/packages/cli/tests/repl-include-pty.test.ts new file mode 100644 index 000000000..24b4fa1d5 --- /dev/null +++ b/packages/cli/tests/repl-include-pty.test.ts @@ -0,0 +1,263 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + cliRuntime, + cliShellCommand, + runShell, + shellQuote, +} from "@executablemd/test-support/launch"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { parseDurableEvent } from "@executablemd/durable-streams"; +import { scoped, sleep, until } from "effection"; +import type { Operation } from "effection"; +import { readdir } from "node:fs/promises"; +import { join, sep } from "node:path"; +import { platform } from "node:os"; +import { platformDataRoot } from "../src/repl-assembly.ts"; +import { projectRepl } from "../src/repl/model.ts"; +import { allocate, showing, showingAcross } from "./support/pty.ts"; +import type { Pty } from "./support/pty.ts"; + +const ACT_MS = 30_000; +const REACH_MS = 180_000; + +function greeting(which: "First" | "Second"): string { + return `---\ndescription: ${which} greeting contract.\nprops:\n type: object\n properties:\n from${which}: { type: string }\n required: [from${which}]\n additionalProperties: false\n---\n\n${which} greeting rendered.\n`; +} + +function* click(pty: Pty, label: string): Operation { + const screen = yield* pty.shows(); + const row = screen.findIndex((line) => line.includes(label)); + if (row === -1) { + throw new Error(`no ${label} control. screen=${JSON.stringify(screen)}`); + } + const column = screen[row].indexOf(label) + 1; + yield* pty.type(`\x1b[<0;${column};${row + 1}M`); + yield* pty.type(`\x1b[<0;${column};${row + 1}m`); + yield* sleep(150); +} + +function* submit(pty: Pty, source: string): Operation { + yield* click(pty, "Draft: "); + yield* pty.type(source); + yield* showing(pty, source.split("\n").at(-1) ?? source, ACT_MS); + yield* pty.type("\r"); +} + +function* historyPath(home: string): Operation { + const directory = join( + platformDataRoot(platform(), home, { XDG_DATA_HOME: join(home, "share") }, sep), + "xmd", + "repl", + ); + const held = yield* until(readdir(directory)); + const single = held.find((name) => name.endsWith(".jsonl")); + if (single !== undefined) { + return join(directory, single); + } + const pairs = join(directory, "v1"); + const executions = yield* until(readdir(pairs)); + expect(executions).toHaveLength(1); + return join(pairs, executions[0], "entries.jsonl"); +} + +function modelOf(bytes: string) { + const events = bytes + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const parsed = parseDurableEvent(line); + if (!parsed.ok) { + throw parsed.error; + } + return parsed.value; + }); + const projected = projectRepl(events); + if (!projected.ok) { + throw projected.error; + } + return projected.value; +} + +function* completed(pty: Pty, history: string, count: number): Operation { + const deadline = Date.now() + ACT_MS; + while (true) { + const bytes = yield* readTextFile(history); + const model = modelOf(bytes); + if ( + model.entries.length === count && + model.entries.every((entry) => entry.terminal?.status === "ok") + ) { + yield* showing(pty, `Ready for Entry ${count + 1}`, ACT_MS); + return bytes; + } + if (Date.now() > deadline) { + throw new Error( + `entries never closed. history=${bytes} screen=${JSON.stringify(yield* pty.shows())}`, + ); + } + yield* sleep(150); + } +} + +function* leave(pty: Pty): Operation { + yield* click(pty, "[exit]"); + const deadline = Date.now() + ACT_MS; + while ((yield* pty.status()) === undefined) { + if (Date.now() > deadline) { + throw new Error(`command never ended. screen=${JSON.stringify(yield* pty.shows())}`); + } + yield* sleep(150); + } + expect(yield* pty.status()).toBe(0); + const screen = (yield* pty.shows()).join("\n"); + const found = screen.match(/xmd repl '(xmd:\/\/repl\/[^']+)'/); + if (found?.[1] === undefined) { + throw new Error(`no printed location. screen=${screen}`); + } + return found[1]; +} + +function* refused(pty: Pty, history: string, name: string): Operation { + const before = yield* readTextFile(history); + yield* submit(pty, `<${name} />`); + yield* showing(pty, "this entry cannot run here", ACT_MS); + expect(yield* readTextFile(history)).toBe(before); + yield* click(pty, "Draft: "); + for (let at = 0; at < `<${name} />`.length; at += 1) { + yield* pty.type("\x7f"); + } +} + +describe("REPL includes through the production source terminal", () => { + it("A3–A7: selected directories replace defaults, keep order and reopen retained work", function* () { + const root = yield* useTempDirectory("xmd-i-"); + const workspace = join(root, "workspace"); + const first = join(root, "first"); + const second = join(root, "second"); + for (const directory of [join(workspace, "components"), join(first, "File"), second]) { + yield* ensureDir(directory); + } + const external = 'first-write\n\nExternal-only rendered.\n'; + yield* writeTextFile(join(first, "Greeting.md"), greeting("First")); + yield* writeTextFile(join(second, "Greeting.md"), greeting("Second")); + yield* writeTextFile(join(first, "ExternalOnly.md"), external); + yield* writeTextFile(join(workspace, "components", "Local.md"), "Local default rendered.\n"); + yield* writeTextFile(join(workspace, "RootLocal.md"), "Root default rendered.\n"); + for (const name of ["Syntax", "Evaluate", "File", "File/Delete", "Dir"]) { + yield* writeTextFile(join(first, `${name}.md`), "POISON protected candidate\n"); + } + let cache = ""; + if (cliRuntime() === "deno") { + const info: unknown = JSON.parse((yield* runShell("deno info --json").expect()).stdout); + if ( + typeof info !== "object" || + info === null || + !("denoDir" in info) || + typeof info.denoDir !== "string" + ) { + throw new Error("deno info supplied no prepared cache path"); + } + cache = `DENO_DIR=${shellQuote(info.denoDir)} `; + } + const line = (args: string[]): string => + `cd ${shellQuote(workspace)} && ${cache}${cliShellCommand(["repl", ...args, "--deny-all"])}`; + const includes = ["--include", "../first", `--include=${second}`]; + const home = join(root, "h1"); + yield* ensureDir(home); + let location = ""; + let retained = ""; + let history = ""; + yield* scoped(function* () { + const pty = yield* allocate(line(includes), home); + yield* showing(pty, "Ready for Entry 1", REACH_MS); + history = yield* historyPath(home); + yield* submit(pty, '\n'); + retained = yield* completed(pty, history, 1); + yield* showing(pty, "First greeting rendered.", ACT_MS); + yield* showing(pty, "External-only rendered.", ACT_MS); + expect(yield* readTextFile(join(workspace, "witness.txt"))).toBe("first-write"); + expect(modelOf(retained).terminal?.output).toContain("First greeting rendered."); + yield* submit( + pty, + '\n', + ); + retained = yield* completed(pty, history, 2); + const syntax = modelOf(retained).terminal?.output ?? ""; + expect(syntax).toContain("../first/Greeting.md"); + expect(syntax).toContain("First greeting contract."); + expect(syntax).not.toContain("Second greeting contract."); + expect(syntax).not.toContain(""); + expect(syntax).not.toContain(""); + expect(syntax).not.toContain("POISON"); + expect(syntax.match(/\(protected component\)/g)).toHaveLength(10); + yield* refused(pty, history, "Local"); + yield* refused(pty, history, "RootLocal"); + location = yield* leave(pty); + expect(yield* readTextFile(history)).toBe(retained); + }); + + yield* rm(first, { recursive: true }); + yield* rm(second, { recursive: true }); + yield* writeTextFile(join(workspace, "witness.txt"), "external edit survives"); + yield* scoped(function* () { + const pty = yield* allocate(line([location, ...includes]), home); + yield* showing(pty, "Ready for Entry 3", REACH_MS); + yield* click(pty, "1. [ok]"); + yield* showing(pty, "First greeting rendered.", ACT_MS); + yield* showing(pty, "External-only rendered.", ACT_MS); + yield* showing(pty, "Source", ACT_MS); + yield* showingAcross(pty, '', ACT_MS); + yield* showingAcross(pty, "", ACT_MS); + yield* leave(pty); + expect(yield* readTextFile(join(workspace, "witness.txt"))).toBe("external edit survives"); + expect(yield* readTextFile(history)).toBe(retained); + }); + + yield* ensureDir(first); + yield* ensureDir(second); + yield* writeTextFile(join(first, "Greeting.md"), greeting("First")); + yield* writeTextFile(join(second, "Greeting.md"), greeting("Second")); + yield* writeTextFile(join(first, "ExternalOnly.md"), external); + for (const reversed of [true, false]) { + yield* scoped(function* () { + const privateHome = join(root, reversed ? "h2" : "h3"); + yield* ensureDir(privateHome); + const pty = yield* allocate( + line(reversed ? [`--include=${second}`, "--include", "../first"] : []), + privateHome, + ); + yield* showing(pty, "Ready for Entry 1", REACH_MS); + const history = yield* historyPath(privateHome); + yield* submit(pty, reversed ? '' : "\n"); + const bytes = yield* completed(pty, history, 1); + const output = modelOf(bytes).terminal?.output ?? ""; + if (reversed) { + expect(output).toContain("Second greeting rendered."); + expect(output).not.toContain("First greeting rendered."); + yield* submit(pty, ''); + const syntax = modelOf(yield* completed(pty, history, 2)).terminal?.output ?? ""; + expect(syntax).toContain(join(second, "Greeting.md")); + expect(syntax).toContain("Second greeting contract."); + expect(syntax).not.toContain("First greeting contract."); + } else { + expect(output).toContain("Local default rendered."); + expect(output).toContain("Root default rendered."); + yield* submit(pty, ''); + const syntax = modelOf(yield* completed(pty, history, 2)).terminal?.output ?? ""; + expect(syntax).toContain("**Origin:** `components/Local.md`"); + expect(syntax).toContain("**Origin:** `RootLocal.md`"); + expect(syntax).not.toContain("ExternalOnly"); + yield* refused(pty, history, "ExternalOnly"); + expect(yield* exists(join(workspace, "witness.txt"))).toBe(true); + expect(yield* readTextFile(join(workspace, "witness.txt"))).toBe( + "external edit survives", + ); + } + yield* leave(pty); + }); + } + }); +}); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index f3208a6a2..bf697ba98 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -10,6 +10,8 @@ xmd repl # a fresh execution with an empty draf xmd repl 'xmd://repl//entries' # reopen exactly that retained history xmd repl --deny-all # the same, answering every agent request with no xmd repl --sidekick-agent claude # the same, with a Sidekick that talks to claude +xmd repl --include ../shared --include=/absolute/other +xmd repl 'xmd://repl//entries' --include ../shared ``` ## The command line, and the order it is read in @@ -34,21 +36,29 @@ runs under grants the Sidekick a tool of its own — its conversations install that same selected provider with constraints of their own rather than inheriting what an entry runs under: no tools, no MCP servers, every native request denied without asking, and no customization of the agent's own system -prompt. There is nothing further: no include, no data directory and no second -provider to select. +prompt. There is no data directory or second provider to select. + +Use `--include` to select existing component directories, as with `xmd run` and +`xmd test`. Both `--include ` and `--include=` accept a +value. Repetition preserves occurrence order, and the first directory defining +an ordinary component wins. Explicit values replace the default search list +`["components", "."]`; omitting the option keeps both defaults. Relative paths +resolve from the invocation's contextual working directory, and absolute paths +stay absolute. These directories select ordinary components and grant no +permission; canonical protected components keep their implementations. 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 six options, +1. **The line.** A token that is not the location or one of those seven options, a switch given a value, a value option given none or an empty one, 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 + command's own scope: a copied, frozen selection of component directories, 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 @@ -346,6 +356,12 @@ unfinished is given back to the engine to continue. A location naming a history this version cannot read, or naming an entry its selected prefix never admitted, refuses whole — with nothing appended and no execution started. +You may supply the include options again when reopening a location. Completed +entries show their retained output and submitted entry source even after included +files change or disappear; inspecting them reads no live component source, +compiles no eval and asks no Agent. Imported component bytes remain retained for +replay. New entries use this command's selected directories. + ## The packaged Plan, in the REPL's own drawer `` is the same packaged Component `xmd plan` runs, declared by this command @@ -1194,6 +1210,10 @@ this screen editing the content it exists to show. ## What is retained, and what is not +Include directories configure the current command. They add no location member, +manifest or profile record; pass them again when reopening if new work needs +them. The location remains route state, and existing events retain past work. + Each execution is one file of serialized ordinary `DurableEvent`s — the same records any other XMD run writes. There is no manifest, no cache, no materialized model, no checkpoint file and no record type of the REPL's own. The file and the