From 44f6372789ac674763d243c15df6335cc2dcdf0f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 13:13:26 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9C=A8=20Report=20live=20Agent=20turns?= =?UTF-8?q?=20and=20permissions=20from=20one=20REPL=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `` returns only once its provider turn has settled and its ordinary `agent_prompt` record has appended. Between those two moments there is a turn nobody could read. This adds the private live owner that reports it — queued, streaming, finished-but-unpublished — and that holds and settles the interactive permission requests a turn makes. The observer wraps `Agent.prompt()`'s cold stream rather than subscribing to it, so `` stays the one subscriber and the one owner of provider cancellation, and each provider event travels back untouched. Correlation is by coroutine, not by resemblance. Two `` children may run identical prompts against identical responses and settle in either order, so nothing about a turn's content identifies it. Expansion inside one coroutine is sequential and a record appends on the coroutine that made it, so the Nth turn observed on a coroutine is the Nth record appended there. A record and the overlay it replaces change in one transition, so no snapshot holds a turn twice or neither time. An interactive request belongs to the live turn on whose coroutine the provider asked. Where there is not exactly one, nothing is published, nothing is denied, and the session owner is terminated — reporting only into the permission operation would leave the REPL running without the identity it needs. A post-admission correlation failure ends the owner the same way: authority is withdrawn, the execution is halted and joined, and `join()` returns that exact failure. Dismissal while the session is live denies once through Core's own rule, which `@executablemd/core` now exports. Whole-session teardown is structured cancellation instead, and claims no denial outcome. Preflight now validates a submitted entry against the vocabulary its execution installs. A host that declares `` to the execution would otherwise have every entry naming one refused before it ran. --- packages/cli/src/agent-stack.ts | 33 +- packages/cli/src/repl/agent.ts | 581 ++++++++ packages/cli/src/repl/session.ts | 188 ++- .../cli/tests/repl-agent-execution.test.ts | 1170 +++++++++++++++++ packages/core/mod.ts | 4 + 5 files changed, 1957 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/repl/agent.ts create mode 100644 packages/cli/tests/repl-agent-execution.test.ts diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 4fdb23ab3..e6e63609c 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -151,15 +151,20 @@ export function hostAcpDependencies(stack: PlanWriterStack): AcpxProviderDepende } /** - * Install the agent stack a document runs under: the registration, the - * components with the resolved root provider, the permission mode, and the - * terminal this command has to give away. + * The half of the stack that answers *who* an agent is and *how* a document + * reaches one: the registered provider, and the components over it. + * + * Separated because two commands need exactly this much and disagree about the + * rest. `xmd run` owns a terminal and a readline, so it adds the permission + * mode and the foreground launcher below. The REPL owns neither — it presents + * requests in its own surface and has no terminal to give away — so it installs + * this and its own private policy instead of inheriting one that would ask + * through a readline nobody is looking at. * - * Nothing starts an agent — the provider validates availability on first use, - * and an embedded adapter reaches the disk at that same point. A document that - * asks for no agent installs no adapter. + * Nothing here starts an agent. The provider validates availability on first + * use, and an embedded adapter reaches the disk at that same point. */ -export function* installRunAgentStack(stack: AgentStack): Operation { +export function* installAgentProviderStack(stack: AgentStack): Operation { const acpx = createAcpxProvider(hostAcpDependencies(stack)); yield* registerAgentProvider("acpx", acpx); @@ -174,7 +179,19 @@ export function* installRunAgentStack(stack: AgentStack): Operation { permissionMode, rootProvider: { factory, options: { defaultAgent, permissionMode } }, }); - yield* installPermissionMode(permissionMode); +} + +/** + * Install the agent stack a document runs under: the registration, the + * components with the resolved root provider, the permission mode, and the + * terminal this command has to give away. + * + * A document that asks for no agent still installs no adapter, for the reason + * above: nothing here starts one. + */ +export function* installRunAgentStack(stack: AgentStack): Operation { + yield* installAgentProviderStack(stack); + yield* installPermissionMode(stack.permissionMode); // `xmd run` is the one command that has a terminal to give away. Help, // document inspection and `xmd test` install no launcher, so a document that // reaches under any of them refuses instead of spawning. diff --git a/packages/cli/src/repl/agent.ts b/packages/cli/src/repl/agent.ts new file mode 100644 index 000000000..6ab31a8f1 --- /dev/null +++ b/packages/cli/src/repl/agent.ts @@ -0,0 +1,581 @@ +/** + * What this process knows about Agent turns that the Journal does not yet. + * + * A `` is durable work: it returns only once its provider turn has + * settled and its ordinary `agent_prompt` record has appended, and that record + * is the only retained truth about it. Between those two moments there is a + * turn nobody can read — queued, then streaming, then finished but unpublished + * — and a REPL that showed nothing until the append would show nothing for the + * whole of the interesting part. + * + * So this observes. It installs ordinary middleware around `Agent.prompt()` and + * `Agent.requestPermission()` inside the execution, publishes an immutable + * reading of each turn as the provider's own events go past, and removes that + * reading at the exact moment the durable record replaces it. Nothing here is + * retained, nothing here is a Core Api member, and nothing here is visible to a + * document. + * + * ## The observer must not become the consumer + * + * `Agent.prompt()` hands back a *cold* stream, and whoever subscribes owns the + * turn. This wraps that stream rather than subscribing to it: `` stays + * the one subscriber, the one owner of provider cancellation, and the one + * reader of the final value. Each event object travels through untouched and + * unfrozen — what is frozen is the separate reading copied out of it — and an + * observation failure is never allowed to become a Prompt failure. + * + * ## Correlation is by coroutine, not by resemblance + * + * Two `` children may run identical prompts against identical responses + * and settle in either order, so nothing about a turn's *content* identifies + * it: not its text, its display name, its agent, its session key, or the order + * it finished in. What does identify it is where it ran. Each spawned child is + * its own durable coroutine, expansion inside one coroutine is strictly + * sequential, and an `agent_prompt` record appends on the coroutine that made + * it — so the Nth prompt this execution observed on coroutine X is the Nth + * `agent_prompt` appended on coroutine X. That is a queue per coroutine, and it + * is exact. + * + * A turn replay restored never reaches this middleware and never appends, so it + * produces no reading and consumes no queue entry. + * + * ## A request without one owner is an invariant failure + * + * An interactive permission request belongs to the live turn on whose coroutine + * the provider asked. Where there is not exactly one, this publishes nothing, + * denies nothing, and fails the session — reporting to the session owner as + * well as to the permission operation, because an ordinary `` turns a + * provider failure into its own durable failed result and carries on, which + * would leave the REPL running without the identity it needs to present and + * answer requests correctly. + */ + +import { action, createSignal, useScope } from "effection"; +import type { Operation, Stream } from "effection"; +import { Agent, denyPermission } from "@executablemd/core"; +import type { + AgentPromptEvent, + PermissionMode, + PermissionOption, + PermissionOutcome, + PermissionRequest, +} from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; +import { DurableContext } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; + +/** The durable record one observed turn is waiting to be replaced by. */ +const AGENT_PROMPT = "agent_prompt"; + +/** How far one live turn has got, as this process saw it. */ +export type ReplLiveTurnState = "queued" | "active" | "terminal"; + +/** One option the provider offered for a pending request, detached from it. */ +export interface ReplLiveChoice { + readonly optionId: string; + readonly name: string; + readonly kind: PermissionOption["kind"]; +} + +/** + * One Agent turn this process is running, as the application reads it. + * + * Everything here arrived from the provider's own events. A member is absent + * until the event carrying it has gone past, so an empty `sessionKey` means + * "not started yet" rather than "no conversation" — a queued turn has no + * conversation to select, and inferring one from the authored options would + * name a session the provider never issued. + */ +export interface ReplLiveTurn { + /** This process's own opaque handle. Never a location, model or Journal value. */ + readonly key: string; + /** The text the Prompt asked. */ + readonly prompt: string; + readonly state: ReplLiveTurnState; + /** What the provider has streamed so far. */ + readonly text: string; + readonly agent: string | undefined; + readonly sessionKey: string | undefined; + readonly agentSessionId: string | undefined; + readonly status: "completed" | "failed" | "cancelled" | undefined; + readonly stopReason: string | undefined; + /** What the provider said went wrong, as one line. */ + readonly failure: string | undefined; +} + +/** One permission request waiting on a person, detached from the request itself. */ +export interface ReplLivePermission { + /** This process's own opaque handle for the request. */ + readonly key: string; + /** The live turn that owns it. */ + readonly turn: string; + readonly toolCallId: string; + readonly title: string | undefined; + readonly kind: string | undefined; + readonly choices: readonly ReplLiveChoice[]; +} + +/** Everything this process knows about live Agent work right now. */ +export interface ReplAgentReading { + /** Live turns in the order their Prompts were observed. */ + readonly turns: readonly ReplLiveTurn[]; + readonly requests: readonly ReplLivePermission[]; +} + +/** + * Settling a pending request, separated from reading it. + * + * A surface that draws requests receives the reading; only the owner of this + * settles one. Both take an opaque key, so an unknown, stale or already + * answered key acts on nothing rather than on whatever is pending now. + */ +export interface ReplAgentAuthority { + /** Answer with one offered option. */ + choose(request: string, option: string): boolean; + /** Dismiss while the session is live, which denies. */ + dismiss(request: string): boolean; +} + +/** The private live Agent owner one session installs into its execution. */ +export interface ReplAgentKernel { + readonly reading: ReplAgentReading; + readonly changes: Stream; + readonly authority: ReplAgentAuthority; + /** What this owner installs inside the execution. */ + readonly installation: ExecutionInstallation; + /** + * Account for one appended event, without announcing. + * + * The caller owns the transition: it projects the newly acknowledged history, + * calls this, and announces once — so no observable snapshot holds a turn + * twice or neither time. + */ + consume(event: DurableEvent): void; + /** Announce the reading the caller's transition arrived at. */ + announce(): void; + /** + * The first failure that must terminate this session's owner. + * + * Resolves once. A later failure cannot replace it, because the owner is + * already withdrawing the authority the later one would have described. + */ + readonly failed: Operation; +} + +/** An unowned or ambiguously owned interactive permission request. */ +export class ReplPermissionOwnerError extends Error { + constructor(message: string) { + super(message); + this.name = "ReplPermissionOwnerError"; + } +} + +/** An append this process cannot match to the work it completed. */ +export class ReplAgentCorrelationError extends Error { + constructor(message: string) { + super(message); + this.name = "ReplAgentCorrelationError"; + } +} + +/** Mutable bookkeeping for one observed turn. */ +interface LiveTurn { + readonly key: string; + readonly coroutine: string; + readonly prompt: string; + state: ReplLiveTurnState; + text: string; + agent: string | undefined; + sessionKey: string | undefined; + agentSessionId: string | undefined; + status: "completed" | "failed" | "cancelled" | undefined; + stopReason: string | undefined; + failure: string | undefined; +} + +interface LiveRequest { + readonly key: string; + readonly turn: string; + readonly toolCallId: string; + readonly title: string | undefined; + readonly kind: string | undefined; + readonly choices: readonly ReplLiveChoice[]; + /** The one wait this answers, and the request only it may see. */ + settle(outcome: PermissionOutcome): void; + readonly request: PermissionRequest; +} + +function frozenTurn(turn: LiveTurn): ReplLiveTurn { + return Object.freeze({ + key: turn.key, + prompt: turn.prompt, + state: turn.state, + text: turn.text, + agent: turn.agent, + sessionKey: turn.sessionKey, + agentSessionId: turn.agentSessionId, + status: turn.status, + stopReason: turn.stopReason, + failure: turn.failure, + }); +} + +function frozenRequest(request: LiveRequest): ReplLivePermission { + return Object.freeze({ + key: request.key, + turn: request.turn, + toolCallId: request.toolCallId, + title: request.title, + kind: request.kind, + choices: request.choices, + }); +} + +/** The choices a provider offered, copied out of the request it owns. */ +function offeredChoices(request: PermissionRequest): readonly ReplLiveChoice[] { + return Object.freeze( + request.options.map((option) => + Object.freeze({ optionId: option.optionId, name: option.name, kind: option.kind }), + ), + ); +} + +/** The approval a mode would select, or none when the provider offered neither. */ +function approval(request: PermissionRequest): PermissionOutcome | undefined { + const allowed = + request.options.find((option) => option.kind === "allow_once") ?? + request.options.find((option) => option.kind === "allow_always"); + return allowed === undefined ? undefined : { outcome: "selected", optionId: allowed.optionId }; +} + +/** Whether this tool call is one `approve-reads` decides without asking. */ +function isRead(request: PermissionRequest): boolean { + const kind = request.toolCall.kind; + return kind === "read" || kind === "search"; +} + +/** The coroutine this operation is running on, or `""` outside a journal. */ +function* currentCoroutine(): Operation { + const scope = yield* useScope(); + return scope.get(DurableContext)?.coroutineId ?? ""; +} + +/** + * Create the private live Agent owner for one session. + * + * The owner is created here and installed into the execution through + * `installation`, so everything it holds belongs to the scope that created it + * and dies with that scope — including the middleware, which an execution + * elsewhere would otherwise inherit. + */ +export function useReplAgent(mode: PermissionMode): ReplAgentKernel { + const changes = createSignal(); + const turns: LiveTurn[] = []; + const requests: LiveRequest[] = []; + /** Turns observed on one coroutine and not yet replaced by their record. */ + const awaiting = new Map(); + let reading: ReplAgentReading = Object.freeze({ + turns: Object.freeze([]), + requests: Object.freeze([]), + }); + let keys = 0; + let failure: Error | undefined; + const failures: Array<(error: Error) => void> = []; + + function allocate(prefix: string): string { + keys += 1; + return `${prefix}-${keys}`; + } + + function project(): void { + reading = Object.freeze({ + turns: Object.freeze(turns.map(frozenTurn)), + requests: Object.freeze(requests.map(frozenRequest)), + }); + } + + function announce(): void { + project(); + changes.send(reading); + } + + /** + * Record the first failure that must end this session, and report it. + * + * Reported to whoever is waiting rather than thrown here: the caller also has + * to raise it into the operation that caused it, and the two are different + * deliveries of one failure. + */ + function fail(error: Error): Error { + if (failure === undefined) { + failure = error; + for (const waiting of failures) { + waiting(error); + } + failures.length = 0; + } + return failure; + } + + function queued(coroutine: string, prompt: string): LiveTurn { + const turn: LiveTurn = { + key: allocate("turn"), + coroutine, + prompt, + state: "queued", + text: "", + agent: undefined, + sessionKey: undefined, + agentSessionId: undefined, + status: undefined, + stopReason: undefined, + failure: undefined, + }; + turns.push(turn); + const pending = awaiting.get(coroutine); + if (pending === undefined) { + awaiting.set(coroutine, [turn.key]); + } else { + pending.push(turn.key); + } + announce(); + return turn; + } + + /** Copy one provider event's facts into the reading, changing nothing else. */ + function observed(turn: LiveTurn, event: AgentPromptEvent): void { + if (event.type === "started") { + turn.state = "active"; + turn.agent = event.agent; + turn.sessionKey = event.session.sessionKey; + turn.agentSessionId = event.session.agentSessionId; + } else if (event.type === "text_delta") { + turn.state = "active"; + turn.text += event.text; + } else { + turn.state = "terminal"; + turn.status = event.status; + turn.stopReason = event.stopReason; + turn.failure = event.error?.message; + } + announce(); + } + + /** + * Wrap the provider's cold stream so subscribing still starts exactly one + * turn, owned by ``. + * + * Nothing is collected, pre-read or replaced: each event travels back the + * moment it arrives, as the same object the provider produced, and the final + * value is the provider's own. + */ + function watch( + turn: LiveTurn, + stream: Stream, + ): Stream { + return { + *[Symbol.iterator]() { + const subscription = yield* stream; + return { + *next() { + const next = yield* subscription.next(); + if (!next.done) { + observed(turn, next.value); + } + return next; + }, + }; + }, + }; + } + + /** The live turn a permission request on this coroutine belongs to. */ + function owner(coroutine: string): LiveTurn { + const candidates = turns.filter( + (turn) => turn.coroutine === coroutine && turn.state !== "terminal", + ); + const only = candidates[0]; + if (only === undefined || candidates.length > 1) { + throw fail( + new ReplPermissionOwnerError( + candidates.length > 1 + ? "an interactive permission request arrived where more than one live Prompt turn " + + "could own it, so the session cannot say which conversation is asking." + : "an interactive permission request arrived with no live Prompt turn to own it, so " + + "the session cannot present or answer it.", + ), + ); + } + return only; + } + + function remove(request: LiveRequest): void { + const at = requests.indexOf(request); + if (at >= 0) { + requests.splice(at, 1); + } + } + + function* interactive(request: PermissionRequest): Operation { + // Decided before anything is published: an unowned request publishes no + // reading and no key, and never becomes a denial. + const held = owner(yield* currentCoroutine()); + return yield* action(function (resolve) { + let settled = false; + const live: LiveRequest = { + key: allocate("request"), + turn: held.key, + toolCallId: request.toolCall.toolCallId, + title: request.toolCall.title, + kind: request.toolCall.kind, + choices: offeredChoices(request), + request, + settle(outcome: PermissionOutcome): void { + if (settled) { + return; + } + settled = true; + remove(live); + announce(); + resolve(outcome); + }, + }; + // Whatever ends this — an answer, a dismissal, teardown, a failure + // upstream — the reading disappears exactly when the wait does. Returned + // as `action`'s own cleanup, which is registered before this body's + // caller can suspend, and the wait is never resolved synchronously. + const dispose = (): void => { + if (!settled) { + settled = true; + remove(live); + announce(); + } + }; + requests.push(live); + announce(); + return dispose; + }); + } + + /** + * The selected mode, applied exactly, with every other kind asked. + * + * A mode that cannot approve denies through Core's own `denyPermission` + * rather than a rule spelled again here, so an automatic denial is the same + * decision the base handler would have reached. + */ + function* decide(request: PermissionRequest): Operation { + if (mode === "approve-all") { + return approval(request) ?? denyPermission(request); + } + if (mode === "deny-all") { + return denyPermission(request); + } + if (isRead(request)) { + return approval(request) ?? denyPermission(request); + } + return yield* interactive(request); + } + + const authority: ReplAgentAuthority = { + choose(request: string, option: string): boolean { + const live = requests.find((candidate) => candidate.key === request); + if (live === undefined) { + return false; + } + // Only what the provider offered: a stray identifier settles nothing + // rather than selecting an option this turn was never given. + if (!live.choices.some((choice) => choice.optionId === option)) { + return false; + } + live.settle({ outcome: "selected", optionId: option }); + return true; + }, + dismiss(request: string): boolean { + const live = requests.find((candidate) => candidate.key === request); + if (live === undefined) { + return false; + } + // A dismissal while the session continues is a denial the provider turn + // resumes with, decided by the one authoritative rule. + live.settle(denyPermission(live.request)); + return true; + }, + }; + + const installation: ExecutionInstallation = { + *install(): Operation { + // At the ordinary position, not `min`. A provider installs its own + // handlers innermost and answers without delegating, so an observer + // installed there would never see the call it exists to wrap — and a + // policy installed there would be decided for, by whatever the provider + // brought with it. Outermost is where this session's own authority goes: + // it wraps the provider's stream, and it decides permission before + // anything inherited can. + yield* Agent.around({ + *prompt([text, options], next) { + const turn = queued(yield* currentCoroutine(), text); + // Published first, delegated second: the reading exists before the + // provider is asked for anything at all. + const stream = yield* next(text, options); + return watch(turn, stream); + }, + *requestPermission([request]) { + return yield* decide(request); + }, + }); + }, + }; + + return { + get reading() { + return reading; + }, + changes, + authority, + installation, + consume(event: DurableEvent): void { + if (event.type !== "yield" || event.description.type !== AGENT_PROMPT) { + return; + } + const pending = awaiting.get(event.coroutineId); + const key = pending?.shift(); + if (key === undefined) { + // An `agent_prompt` appended where this process observed no turn. The + // session has already been admitted, so there is nothing left to refuse + // atomically: the owner is terminated instead of guessing which reading + // this record replaced. + throw fail( + new ReplAgentCorrelationError( + "an agent turn was recorded that this session never observed, so its live view " + + "cannot be reconciled with the journal.", + ), + ); + } + const at = turns.findIndex((turn) => turn.key === key); + if (at >= 0) { + turns.splice(at, 1); + } + project(); + }, + announce, + get failed(): Operation { + return { + *[Symbol.iterator]() { + if (failure !== undefined) { + return failure; + } + return yield* action(function (resolve) { + failures.push(resolve); + return () => { + const at = failures.indexOf(resolve); + if (at >= 0) { + failures.splice(at, 1); + } + }; + }); + }, + }; + }, + }; +} diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index e637287c1..f9ab13be2 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -45,6 +45,7 @@ import { createScope, createSignal, ensure, + scoped, spawn, suspend, until, @@ -53,9 +54,13 @@ import { } from "effection"; import type { Operation, Result, Stream, Task } from "effection"; import { INLINE_SOURCE_PATH, inlineSource, validateDocument } from "@executablemd/core"; -import type { DocumentValidationDiagnostic } from "@executablemd/core"; +import type { DocumentValidationDiagnostic, PermissionMode } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; -import type { ExecutionInstallation } from "@executablemd/core/host"; +import type { + ExecutionDeclaration, + ExecutionInstallation, + IdentityComponent, +} from "@executablemd/core/host"; import { ContinuePastCloseDivergenceError, DivergenceError, @@ -66,6 +71,8 @@ import { } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; +import { useReplAgent } from "./agent.ts"; +import type { ReplAgentAuthority, ReplAgentReading } from "./agent.ts"; import { useReplElicitation } from "./elicitation.ts"; import type { ReplElicitations, ReplQuestion } from "./elicitation.ts"; import { useExpansionController } from "./expansion.ts"; @@ -143,6 +150,23 @@ export interface ReplSession { */ readonly controller: ExpansionController | undefined; readonly elicitation: ReplElicitations; + /** + * What this process knows about Agent turns the Journal has not settled. + * + * Empty for an idle execution, a full replay and a document with no Agent + * work — a live reading describes work this process is doing, and replay does + * none. + */ + readonly agent: ReplAgentReading; + /** Every change to that reading, as it changes. */ + readonly agentChanges: Stream; + /** + * Settling a pending permission request. + * + * Separate from reading one, so a surface that draws requests does not + * thereby hold the capability that answers them. + */ + readonly permissions: ReplAgentAuthority; /** Whether an execution is still running in this process. */ readonly live: boolean; /** Each reprojection, as the history grows under it. */ @@ -170,6 +194,14 @@ export interface ReplSessionOptions { readonly installations?: readonly ExecutionInstallation[]; /** The history position a location selected, or none for the head. */ readonly selection?: string; + /** + * How this session answers Agent permission requests. + * + * The REPL presents interactive requests in its own surface, so it installs + * this policy rather than Core's readline one. Absent means `deny-all`, which + * is what an execution with no configured mode already does. + */ + readonly permissionMode?: PermissionMode; } /** Submit one entry into an execution whose history is empty. */ @@ -188,7 +220,7 @@ function* start( options: ReplSessionOptions, submitted: string | undefined, ): Operation> { - const { execution, includes, installations = [], selection } = options; + const { execution, includes, installations = [], selection, permissionMode } = options; const stream = execution.stream; const projection = projectRepl(yield* stream.readAll(), selection); @@ -220,9 +252,28 @@ function* start( // entry. There is no document to run, so there is nothing to admit either. return Ok(idle(execution.id, model)); } + // Named again after the guard, because the execution below runs from a + // hoisted body and the narrowing does not reach it. + const entry: string = source; if (submitted !== undefined) { - const validation = yield* validateDocument({ ...inlineSource(submitted), includes }); + // Validated against the vocabulary the execution will actually install, not + // against the bare registry: a host that declares `` to the + // execution would otherwise have every entry naming one refused here and + // run perfectly if it got past. Preflight and the run answer to one + // environment or preflight is describing a different document. + // In a scope of its own, because admitting a declaration in order to ask + // about it mints the durable identity domain that name answers under. + // Leaving that behind would have the execution resolve `` through + // preflight's admission while issuing its invocations under its own. + const validation = yield* scoped(function* () { + return yield* validateDocument({ + ...inlineSource(submitted), + includes, + components: declaredComponents(installations), + declarations: declaredMarkdown(installations), + }); + }); if (validation.outcome === "invalid") { return Err(new ReplPreflightError(validation.diagnostics)); } @@ -257,6 +308,11 @@ function* start( provisional.run(function* () { const expansion = yield* useExpansionController(); const elicitation = yield* useReplElicitation(); + // Created here and installed into the execution below, so the middleware it + // owns belongs to this session's scope and dies with it. An execution + // elsewhere would otherwise inherit an observer watching for a session that + // is gone. + const agent = useReplAgent(permissionMode ?? "deny-all"); function reproject(): void { const next = projectRepl(retained, selection); @@ -295,6 +351,11 @@ function* start( return live ? expansion : undefined; }, elicitation, + get agent() { + return agent.reading; + }, + agentChanges: agent.changes, + permissions: agent.authority, get live() { return live; }, @@ -318,7 +379,20 @@ function* start( const retained = yield* stream.readAll(); const observe = (event: DurableEvent): void => { retained.push(event); + // One transition, and nothing suspends inside it: the record joins the + // history, the live overlay it completed is removed, and only then does + // anything announce. No observable snapshot holds one turn twice, and + // none holds it neither way. + try { + agent.consume(event); + } catch { + // A correlation failure after admission is not a stale view to carry + // on with. The owner below is already being told; refusing here as + // well would report one failure as two. + return; + } reproject(); + agent.announce(); admit(); }; @@ -334,15 +408,55 @@ function* start( }); stream.onAppend = observe; - const task: Task> = yield* spawn(function* () { + /** + * How this session finished: the execution's own outcome, or the first + * failure that withdrew its authority. + * + * Settled once, by whichever happened first. A withdrawn session may not be + * left waiting on the work it withdrew authority from, so the watcher below + * halts the execution and waits for it before answering — which is what + * makes the provider turn, the held permission wait and the execution task + * all gone by the time `join()` returns. A later failure cannot replace the + * first one, because by then there is nothing left for it to describe. + */ + const finished = withResolvers>(); + let answered = false; + function settle(outcome: Result): void { + if (!answered) { + answered = true; + finished.resolve(outcome); + } + } + + const document: Task> = yield* spawn(function* () { + const outcome = yield* runExecution(); + settle(outcome); + return outcome; + }); + + yield* spawn(function* () { + const error = yield* agent.failed; + live = false; + admit(); + // Halted and joined before the failure is answered, in that order. + yield* document.halt(); + settle(Err(error)); + }); + + const task: Operation> = finished.operation; + + function* runExecution(): Operation> { try { const running = yield* executeInstalled( { - ...inlineSource(source), + ...inlineSource(entry), stream, ...(includes === undefined ? {} : { includes: [...includes] }), }, - installations, + // Installed into this exact execution, and nowhere else: the live + // observer and the permission policy are this session's, not the + // process's. + [...installations, agent.installation], ); // Consumed as it arrives, inside this session's scope. Collecting until // the stream closed would leave the overlay empty for the whole of the @@ -379,6 +493,20 @@ function* start( } return Err(raised); } + } + + // A queued Agent turn is work beyond the retained prefix, exactly as a new + // record or a question is: replay that reached one is past what the history + // held, so the session is admitted rather than still provisional. + yield* spawn(function* () { + const readings = yield* agent.changes; + let next = yield* readings.next(); + while (!next.done) { + if (next.value.turns.length > 0) { + admit(); + } + next = yield* readings.next(); + } }); yield* spawn(function* () { @@ -398,15 +526,49 @@ function* start( yield* suspend(); }); - const entry = yield* admission.operation; - if (!entry.ok) { + const opened = yield* admission.operation; + if (!opened.ok) { // Before the refusal is returned, not after: the caller is about to be told // there is no session, and everything this one started has to be gone by // the time it hears that. yield* until(dispose()); - return entry; + return opened; } - return entry; + return opened; +} + +const EMPTY_AGENT_READING: ReplAgentReading = Object.freeze({ + turns: Object.freeze([]), + requests: Object.freeze([]), +}); + +/** No live request exists, so no key settles one. */ +const IDLE_PERMISSIONS: ReplAgentAuthority = Object.freeze({ + choose: () => false, + dismiss: () => false, +}); + +/** Every identity component these installations declare, in installation order. */ +function declaredComponents( + installations: readonly ExecutionInstallation[], +): readonly IdentityComponent[] { + // Copied, never the host's own values. Admitting a declaration mints the + // durable identity domain its implementation answers under, and preflight + // admits in this scope while the execution admits in its own — so handing + // both the same object would leave the run resolving an implementation + // minted for a domain its invocations were never issued under. + return installations.flatMap((installation) => + (installation.components ?? []).map((component) => ({ ...component })), + ); +} + +/** Every exact Markdown component these installations declare, in order. */ +function declaredMarkdown( + installations: readonly ExecutionInstallation[], +): readonly ExecutionDeclaration[] { + return installations.flatMap((installation) => + (installation.declarations ?? []).map((declaration) => ({ ...declaration })), + ); } /** An execution with no entry yet: a draft surface and nothing running. */ @@ -425,6 +587,10 @@ function idle(execution: string, model: ReplModel): ReplSession { // Nothing is expanding, so there is nothing to pause or continue. controller: undefined, elicitation, + // Nothing is running, so there is no live Agent work and nothing to settle. + agent: EMPTY_AGENT_READING, + agentChanges: createSignal(), + permissions: IDLE_PERMISSIONS, live: false, changes, // Nothing is running, so nothing will ever write. diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts new file mode 100644 index 000000000..50bcc2867 --- /dev/null +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -0,0 +1,1170 @@ +/** + * The live Agent and permission session kernel (#854 L1–L4, P1–P4). + * + * Every row drives a real `` through real core execution over a real + * `DurableStream`, with the provider installed at the seam a host installs one + * at. Concurrency is authored — `` with two `` children, the + * ordinary language the prerequisite added — rather than simulated by calling + * the kernel's own callbacks, because what these prove is that two ordinary + * Prompt turns can be live at once and still correlate to their own records + * exactly. + * + * The concurrent rows write the ordinary `` path + * in each ``, which is the shape the Story promises and the one the + * product needs: two conversations, each with its own authentic identity, live + * at once. Nothing here substitutes a bare `` for that. + */ + +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createScope, race, scoped, sleep, spawn, until, useScope, withResolvers } from "effection"; +import type { Operation, Result, Stream } from "effection"; +import { DurableContext, InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { + Agent, + agentIdentityComponents, + installAgentComponents, + registerComponents, + useTempFileCompiler, +} from "@executablemd/core"; +import type { + AgentPromptEvent, + PermissionMode, + PermissionOption, + PermissionOutcome, + PermissionRequest, + PromptOptions, + Session, +} from "@executablemd/core"; +import type { AgentProviderFactory } from "@executablemd/core"; +import type { ExecutionInstallation } from "@executablemd/core/host"; + +import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; +import type { ReplSession } from "../src/repl/session.ts"; +import type { ReplAgentReading } from "../src/repl/agent.ts"; +import type { ReplExecution } from "../src/repl/journal.ts"; + +/** + * How long a signal a correct kernel publishes immediately may go unpublished + * before the wait is called a deadlock. + * + * Never reached by a passing run: every wait below is opened by the kernel, the + * provider or the journal. It bounds only the failure mode, so a defect that + * stops publishing says what it stopped publishing instead of hanging. + */ +const DEADLOCK_MS = 10_000; + +interface Signal { + publish(): void; + readonly published: Operation; +} + +function signal(): Signal { + const resolvers = withResolvers(); + let settled = false; + return { + publish() { + if (!settled) { + settled = true; + resolvers.resolve(true); + } + }, + get published() { + return resolvers.operation; + }, + }; +} + +function* awaiting(what: string, waited: Operation): Operation { + const reached = yield* race([ + waited, + (function* (): Operation { + yield* sleep(DEADLOCK_MS); + return false; + })(), + ]); + if (!reached) { + throw new Error(`${what} never happened`); + } +} + +/** What one stubbed turn does when its gate opens. */ +interface Scripted { + readonly deltas?: readonly string[]; + readonly permission?: { + readonly toolCallId: string; + readonly kind?: string; + readonly title?: string; + readonly options?: readonly PermissionOption[]; + }; + readonly status?: "completed" | "failed" | "cancelled"; + /** + * Whether this turn waits before it produces anything: `true` for one gate + * shared by every turn with this text, `"each"` for one gate per turn. + */ + readonly gated?: boolean | "each"; + /** + * Whether this turn waits *after* its terminal event and before returning the + * stream's final value. + * + * The one hold that separates finishing from recording. `` writes its + * record only once the stream returns, so a turn held here has produced every + * event it ever will — it is terminal as far as anything watching can see — + * while its sibling can still settle and append first. + */ + readonly settle?: "each"; +} + +const ALL_KINDS: readonly PermissionOption[] = [ + { optionId: "once", name: "Allow once", kind: "allow_once" }, + { optionId: "always", name: "Allow always", kind: "allow_always" }, + { optionId: "no", name: "Reject once", kind: "reject_once" }, + { optionId: "never", name: "Reject always", kind: "reject_always" }, +]; + +interface Stub { + readonly factory: AgentProviderFactory; + /** Every prompt the provider was asked for, in the order it was asked. */ + readonly asked: string[]; + /** The live reading as it stood the instant each prompt reached the provider. */ + readonly seenWhenAsked: ReplAgentReading[]; + /** The outcome each permission request settled with, by tool call id. */ + readonly outcomes: Map; + /** The exact event objects handed to the subscriber, by prompt text. */ + readonly produced: Map; + /** How many times a provider factory was materialized. */ + activations: number; + /** How many times a turn's cold stream was subscribed. */ + subscriptions: number; + /** Where to read the live reading from, at the moment the provider is asked. */ + watch(session: () => ReplAgentReading): void; + arrival(prompt: string): Operation; + release(prompt: string): void; + /** Wait for the turn running on this child coroutine to reach the provider. */ + reached(coroutine: string): Operation; + /** Release the turn running on this child coroutine, and only that one. */ + let_(coroutine: string): void; + /** Wait for the turn on this child coroutine to emit its terminal event. */ + finished(coroutine: string): Operation; + /** Let the turn held after its terminal event return its final value. */ + settle(coroutine: string): void; +} + +function createStub(script: Record = {}): Stub { + const arrivals = new Map(); + const releases = new Map(); + const slot = (map: Map, name: string): Signal => { + const existing = map.get(name); + if (existing !== undefined) { + return existing; + } + const created = signal(); + map.set(name, created); + return created; + }; + let reading: (() => ReplAgentReading) | undefined; + let issued = 0; + + const stub: Stub = { + asked: [], + seenWhenAsked: [], + outcomes: new Map(), + produced: new Map(), + activations: 0, + subscriptions: 0, + watch(session) { + reading = session; + }, + arrival(prompt: string) { + return awaiting( + ` reaching the provider`, + slot(arrivals, prompt).published, + ); + }, + release(prompt: string) { + slot(releases, prompt).publish(); + }, + reached(coroutine: string) { + return awaiting( + `the turn on ${coroutine} reaching the provider`, + slot(arrivals, `@${coroutine}`).published, + ); + }, + let_(coroutine: string) { + slot(releases, `@${coroutine}`).publish(); + }, + finished(coroutine: string) { + return awaiting( + `the turn on ${coroutine} reaching its terminal event`, + slot(arrivals, `~${coroutine}`).published, + ); + }, + settle(coroutine: string) { + slot(releases, `!${coroutine}`).publish(); + }, + factory: function* (options) { + stub.activations++; + yield* Agent.around( + { + // deno-lint-ignore require-yield + *agent([name]) { + return name ?? options.defaultAgent ?? "stub-agent"; + }, + // deno-lint-ignore require-yield + *session([routed]) { + const name = typeof routed === "string" ? routed : routed?.name; + return { sessionKey: `stub:${name ?? "default"}`, cwd: "/stub" }; + }, + // deno-lint-ignore require-yield + *prompt([content, promptOptions]) { + stub.asked.push(content); + // What the session reports at the exact moment the provider is + // asked. A kernel that published after delegating is caught here + // rather than at the end. + if (reading !== undefined) { + stub.seenWhenAsked.push(reading()); + } + issued += 1; + return turn( + stub, + script, + slot, + arrivals, + releases, + options.defaultAgent ?? "stub-agent", + content, + promptOptions, + issued, + ); + }, + }, + { at: "min" }, + ); + }, + }; + return stub; +} + +function turn( + stub: Stub, + script: Record, + slot: (map: Map, name: string) => Signal, + arrivals: Map, + releases: Map, + defaultAgent: string, + content: string, + options: PromptOptions | undefined, + issued: number, +): Stream { + return { + *[Symbol.iterator]() { + stub.subscriptions += 1; + const scripted = script[content] ?? {}; + // The conversation this turn actually belongs to: the one the authored + // `` routed, which is what makes two turns separate when + // everything they say is identical. A prompt written outside a + // `` falls back to one of this turn's own. + const routed = options?.session; + const session: Session = + typeof routed === "object" && routed !== null && "sessionKey" in routed + ? routed + : { sessionKey: `stub:${issued}`, cwd: "/stub" }; + const agent = typeof options?.agent === "string" ? options.agent : defaultAgent; + const deltas = scripted.deltas ?? ["delta"]; + const produced: AgentPromptEvent[] = []; + stub.produced.set(content, produced); + let stage = 0; + let announced = false; + // Where this turn is running. Two spawns may be written identically and + // may reach the provider in either order, so the only stable way to name + // one of them is the child coroutine its `` was given in source + // order. Held across the whole subscription, because the hold after the + // terminal event needs it too. + let where = ""; + let released = false; + return { + *next() { + if (!announced) { + announced = true; + where = (yield* useScope()).get(DurableContext)?.coroutineId ?? ""; + slot(arrivals, content).publish(); + slot(arrivals, `@${where}`).publish(); + if (scripted.gated === true) { + // Two gates, because two turns may be written identically: one + // keyed by the text a row can name, and one by which turn this + // is, which is the only way to release them in a chosen order. + yield* awaiting( + ` being released`, + slot(releases, content).published, + ); + } + if (scripted.gated === "each") { + yield* awaiting( + `the turn on ${where} being released`, + slot(releases, `@${where}`).published, + ); + } + const wanted = scripted.permission; + if (wanted !== undefined) { + const request: PermissionRequest = { + session, + toolCall: { + toolCallId: wanted.toolCallId, + ...(wanted.title === undefined ? {} : { title: wanted.title }), + ...(wanted.kind === undefined ? {} : { kind: wanted.kind }), + }, + options: wanted.options ?? ALL_KINDS, + }; + stub.outcomes.set( + wanted.toolCallId, + yield* Agent.operations.requestPermission(request), + ); + } + } + if (stage === 0) { + stage = 1; + const event: AgentPromptEvent = { type: "started", agent, session }; + produced.push(event); + return { done: false, value: event }; + } + if (stage <= deltas.length) { + const event: AgentPromptEvent = { type: "text_delta", text: deltas[stage - 1]! }; + stage += 1; + produced.push(event); + return { done: false, value: event }; + } + if (stage === deltas.length + 1) { + stage += 1; + const event: AgentPromptEvent = { + type: "terminal", + status: scripted.status ?? "completed", + }; + produced.push(event); + slot(arrivals, `~${where}`).publish(); + return { done: false, value: event }; + } + if (scripted.settle === "each" && !released) { + released = true; + // Finished, but not yet recorded: everything this turn will ever + // produce has gone past, and `` cannot write its record + // until this returns. + yield* awaiting( + `the turn on ${where} being allowed to finish`, + slot(releases, `!${where}`).published, + ); + } + return { done: true, value: deltas.join("") }; + }, + }; + }, + }; +} + +function execution(events: readonly DurableEvent[] = []): ReplExecution { + return { id: "agent-kernel", stream: new InMemoryStream([...events]) }; +} + +function installations(): readonly ExecutionInstallation[] { + return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; +} + +function opened(result: Result): ReplSession { + if (!result.ok) { + throw result.error; + } + return result.value; +} + +function refusal(result: Result): Error { + if (result.ok) { + throw new Error("this session was handed back, and it must be refused"); + } + return result.error; +} + +/** Install the provider on this scope, the way a host installs one. */ +function* useStub(stub: Stub): Operation { + yield* installAgentComponents({ + defaultAgent: "stub-agent", + rootProvider: { + factory: stub.factory, + options: { defaultAgent: "stub-agent", permissionMode: "deny-all" }, + }, + }); +} + +function start( + holder: ReplExecution, + source: string, + permissionMode: PermissionMode = "deny-all", +): Operation> { + return submitReplEntry({ + execution: holder, + installations: installations(), + permissionMode, + source, + }); +} + +/** Wait until the live reading satisfies `holds`, or say it never did. */ +function* reported( + session: ReplSession, + what: string, + holds: (reading: ReplAgentReading) => boolean, +): Operation { + const readings = yield* session.agentChanges; + if (holds(session.agent)) { + return; + } + const reached = yield* race([ + (function* (): Operation { + let next = yield* readings.next(); + while (!next.done) { + if (holds(next.value)) { + return true; + } + next = yield* readings.next(); + } + return false; + })(), + (function* (): Operation { + yield* sleep(DEADLOCK_MS); + return false; + })(), + ]); + if (!reached) { + throw new Error(`the live reading never reported ${what}`); + } +} + +/** + * Wait on the journal rather than on an announcement. + * + * A change signal delivers to whoever is pulling at that moment, so a state a + * turn passes through can be missed by a subscriber that was between reads. + * Records cannot: they only accumulate, and the append callback fires for every + * one. Installed after the session's own observer and chained to it, so the + * signal arrives with the session already reprojected. + */ +function watchAppends(holder: ReplExecution): (count: number) => Operation { + const inner = holder.stream.onAppend; + const signals = new Map(); + const slot = (count: number): Signal => { + const existing = signals.get(count); + if (existing !== undefined) { + return existing; + } + const created = signal(); + signals.set(count, created); + return created; + }; + let seen = 0; + holder.stream.onAppend = (event) => { + inner?.(event); + if (event.type === "yield" && event.description.type === "agent_prompt") { + seen += 1; + slot(seen).publish(); + } + }; + return (count: number) => { + if (seen >= count) { + return (function* () {})(); + } + return awaiting(`${count} agent turn(s) recorded`, slot(count).published); + }; +} + +/** Every appended `agent_prompt`, in journal order, with its coroutine. */ +function appends(events: readonly DurableEvent[]): Array<{ name: string; coroutineId: string }> { + return events + .filter((event) => event.type === "yield" && event.description.type === "agent_prompt") + .map((event) => ({ + name: event.type === "yield" ? event.description.name : "", + coroutineId: event.coroutineId, + })); +} + +const ONE_PROMPT = '\n'; +const TWO_SPAWNS = [ + "", + '', + '', + "", +].join("\n"); + +describe("L1 — transparent queued/start/delta/terminal observation", () => { + beforeAll(() => useTempFileCompiler()); + + it("L1: queued before the provider is asked, then the turn's own facts", function* () { + const stub = createStub({ one: { deltas: ["first", "second"] } }); + yield* useStub(stub); + const holder = execution(); + const seen: ReplAgentReading[] = []; + const session = opened(yield* start(holder, ONE_PROMPT)); + stub.watch(() => session.agent); + yield* spawn(function* () { + const readings = yield* session.agentChanges; + let next = yield* readings.next(); + while (!next.done) { + seen.push(next.value); + next = yield* readings.next(); + } + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + + // Queued before delegation: what the provider saw when it was asked + // already held this turn, and held it as queued. + expect(stub.seenWhenAsked).toHaveLength(1); + const atDelegation = stub.seenWhenAsked[0]!; + expect(atDelegation.turns).toHaveLength(1); + expect(atDelegation.turns[0]!.state).toBe("queued"); + expect(atDelegation.turns[0]!.prompt).toBe("one"); + // A queued turn has no conversation to select yet. + expect(atDelegation.turns[0]!.sessionKey).toBe(undefined); + + // One logical turn under one key, advanced by immutable replacements. + const key = atDelegation.turns[0]!.key; + const mine = seen.map((reading) => reading.turns[0]).filter((one) => one !== undefined); + expect(new Set(mine.map((one) => one.key))).toEqual(new Set([key])); + const states = mine.map((one) => one.state); + expect(states).toContain("active"); + expect(states).toContain("terminal"); + const settled = mine.filter((one) => one.state === "terminal").at(-1)!; + expect(settled.text).toBe("firstsecond"); + expect(settled.agent).toBe("stub-agent"); + expect(settled.sessionKey).toBe("stub:1"); + expect(settled.status).toBe("completed"); + + // Deltas in order, and neither dropped. + const texts = mine.map((one) => one.text); + expect(texts).toContain("first"); + expect(texts).toContain("firstsecond"); + expect(texts.indexOf("first")).toBeLessThan(texts.indexOf("firstsecond")); + + // Every exposed value is detached and frozen. + for (const reading of seen) { + expect(Object.isFrozen(reading)).toBe(true); + expect(Object.isFrozen(reading.turns)).toBe(true); + for (const one of reading.turns) { + expect(Object.isFrozen(one)).toBe(true); + } + } + // `` is the stream's only subscriber: the observer wrapped the + // cold stream rather than consuming it and handing on a second turn. + expect(stub.subscriptions).toBe(1); + // The provider's own event objects reached `` in order, unchanged + // and unfrozen, and its final value is what the document rendered. + const produced = stub.produced.get("one")!; + expect(produced.map((event) => event.type)).toEqual([ + "started", + "text_delta", + "text_delta", + "terminal", + ]); + for (const event of produced) { + expect(Object.isFrozen(event)).toBe(false); + } + expect(session.overlay.output).toContain("firstsecond"); + }); +}); + +describe("L2 — exact atomic live-to-durable replacement", () => { + beforeAll(() => useTempFileCompiler()); + + it("L2: identical concurrent turns replace their own overlays, in reverse order", function* () { + const stub = createStub({ same: { deltas: ["reply"], gated: "each" } }); + yield* useStub(stub); + const holder = execution(); + const snapshots: Array<{ live: number; durable: number }> = []; + const session = opened(yield* start(holder, TWO_SPAWNS)); + stub.watch(() => session.agent); + const recorded = watchAppends(holder); + + // Every announced snapshot, so "no duplicate and no disappearance" is + // checked against all of them rather than against the end state. + yield* spawn(function* () { + const readings = yield* session.agentChanges; + let next = yield* readings.next(); + while (!next.done) { + snapshots.push({ live: next.value.turns.length, durable: session.model.turns.length }); + next = yield* readings.next(); + } + }); + + yield* spawn(function* () { + // Both ordinary Prompt paths reach the provider before either is let go. + yield* stub.reached("root.0"); + yield* stub.reached("root.1"); + yield* reported(session, "both turns live", (reading) => reading.turns.length === 2); + const keys = session.agent.turns.map((one) => one.key); + expect(new Set(keys).size).toBe(2); + expect(session.agent.turns.map((one) => one.prompt)).toEqual(["same", "same"]); + // The second spawn is released first, so its record appends first. + stub.let_("root.1"); + yield* recorded(1); + // Only its own overlay went. Which one survived is named by what it is + // rather than by where it sat: the other spawn is still held before its + // first event, so it is the one that has reached no conversation. + expect(session.agent.turns).toHaveLength(1); + const survivor = session.agent.turns[0]!; + expect(keys).toContain(survivor.key); + expect(survivor.state).toBe("queued"); + expect(survivor.sessionKey).toBe(undefined); + // And the record that replaced the other one describes the turn that + // actually ran, not this one. + expect(session.model.turns).toHaveLength(1); + expect(session.model.turns[0]!.sessionKey).not.toBe(survivor.sessionKey); + stub.let_("root.0"); + }); + + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + + const written = appends(yield* holder.stream.readAll()); + expect(written).toHaveLength(2); + // Completion order in the journal: the second spawn's child coroutine + // appended first, and the document still holds both turns. + expect(written.map((entry) => entry.coroutineId)).toEqual(["root.1", "root.0"]); + expect(session.model.turns).toHaveLength(2); + expect(session.agent.turns).toHaveLength(0); + // Distinct conversations, identical text: nothing correlated by content. + // The two conversations the authored `` elements named, each + // carrying its own retained turn although the text is identical. + expect(new Set(session.model.turns.map((one) => one.sessionKey))).toEqual( + new Set(["stub:planner", "stub:reviewer"]), + ); + expect(new Set(session.model.turns.map((one) => one.text))).toEqual(new Set(["reply"])); + + // No announced snapshot held one turn twice or neither time: from the + // moment both were live, live plus durable is exactly two. + const from = snapshots.findIndex((one) => one.live === 2); + expect(from).toBeGreaterThanOrEqual(0); + for (const snapshot of snapshots.slice(from)) { + expect(snapshot.live + snapshot.durable).toBe(2); + } + }); + + it("L2: the turn that finished first is not the turn the first record replaced", function* () { + // The one arrangement in which finishing and recording come apart. Both + // turns produce every event they ever will, so both are terminal to + // anything watching; only then is one of them allowed to return and write + // its record. A view that matched a record to "the turn that finished + // first" would replace the wrong overlay here, and nowhere else. + const stub = createStub({ same: { deltas: ["reply"], gated: "each", settle: "each" } }); + yield* useStub(stub); + const holder = execution(); + const session = opened(yield* start(holder, TWO_SPAWNS)); + stub.watch(() => session.agent); + const recorded = watchAppends(holder); + + yield* spawn(function* () { + // Both live before either streams anything. + yield* stub.reached("root.0"); + yield* stub.reached("root.1"); + // Then one at a time, so which turn finishes first is decided by this + // row rather than by scheduling: the first spawn reaches its terminal + // event and is held there, and only then does the second start. + stub.let_("root.0"); + yield* stub.finished("root.0"); + stub.let_("root.1"); + yield* stub.finished("root.1"); + yield* reported( + session, + "both turns terminal", + (reading) => reading.turns.filter((one) => one.state === "terminal").length === 2, + ); + expect(session.model.turns).toHaveLength(0); + + // The second spawn finishes second and records first. + stub.settle("root.1"); + yield* recorded(1); + // The record replaced its own overlay and no other: the turn still live + // is the one that reached terminal *first*, and the durable turn is the + // one that got there second. + expect(session.agent.turns.map((one) => one.sessionKey)).toEqual(["stub:planner"]); + expect(session.agent.turns.map((one) => one.state)).toEqual(["terminal"]); + expect(session.model.turns.map((one) => one.sessionKey)).toEqual(["stub:reviewer"]); + + stub.settle("root.0"); + }); + + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + // And the second record replaced only the turn that was left. + expect(session.agent.turns).toEqual([]); + // Both conversations retained, each exactly once. Compared as a set: a + // retained turn is ordered by its Prompt sequence, and two concurrent + // children allocate that sequence in whichever order they reach it — so + // source order is what the *rendering* preserves, not what this list does. + expect(new Set(session.model.turns.map((one) => one.sessionKey))).toEqual( + new Set(["stub:planner", "stub:reviewer"]), + ); + expect(session.model.turns).toHaveLength(2); + expect(appends(yield* holder.stream.readAll()).map((entry) => entry.coroutineId)).toEqual([ + "root.1", + "root.0", + ]); + }); + + it("L2: a replayed turn consumes its record without creating a live overlay", function* () { + const golden = execution(); + yield* scoped(function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + const session = opened(yield* start(golden, ONE_PROMPT)); + yield* session.join(); + expect(stub.asked).toHaveLength(1); + }); + + yield* scoped(function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + const replayed = opened( + yield* openReplSession({ + execution: execution(yield* golden.stream.readAll()), + installations: installations(), + }), + ); + yield* replayed.join(); + // Restored rather than re-run: the provider was never asked, and the + // retained turn produced no reading of its own. + expect(stub.asked).toEqual([]); + expect(replayed.agent.turns).toEqual([]); + expect(replayed.model.turns).toHaveLength(1); + }); + }); +}); + +describe("L3 — execution owns live work, presentation does not", () => { + beforeAll(() => useTempFileCompiler()); + + it("L3: a document with no Agent work publishes nothing and materializes nothing", function* () { + const stub = createStub(); + yield* useStub(stub); + const session = opened(yield* start(execution(), "just text\n")); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + // The provider was never asked for anything, so no adapter was ever + // materialized: installing the factory only installs middleware. + expect(stub.asked).toEqual([]); + expect(session.agent.turns).toEqual([]); + expect(session.agent.requests).toEqual([]); + }); + + it("L3: a turn completes and publishes with nobody subscribed to changes", function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + // Nothing subscribes to `agentChanges` anywhere in this row. + const session = opened(yield* start(execution(), ONE_PROMPT)); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + expect(session.model.turns).toHaveLength(1); + expect(session.agent.turns).toEqual([]); + }); + + it("L3: cancelling the session owner cancels and joins a held turn", function* () { + const holder = execution(); + const stub = createStub({ one: { gated: true } }); + const [owner, dispose] = createScope(yield* useScope()); + const held = withResolvers(); + owner.run(function* () { + yield* useStub(stub); + held.resolve(opened(yield* start(holder, ONE_PROMPT))); + yield* sleep(DEADLOCK_MS); + }); + const session = yield* held.operation; + yield* stub.arrival("one"); + yield* reported(session, "a live turn", (reading) => reading.turns.length === 1); + + yield* until(dispose()); + + // Nothing the provider had not finished was recorded, and releasing it + // afterwards moves nothing: the work is gone with its owner. + expect(appends(yield* holder.stream.readAll())).toEqual([]); + stub.release("one"); + expect(appends(yield* holder.stream.readAll())).toEqual([]); + }); + + it("L3: a divergence before admission returns no session and leaves nothing alive", function* () { + const golden = execution(); + yield* scoped(function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + yield* opened(yield* start(golden, ONE_PROMPT)).join(); + }); + + yield* scoped(function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + const events = yield* golden.stream.readAll(); + // The very first recorded effect names something this document does not + // do, so replay meets the divergence before it has run any new work — + // and therefore before a queued turn, a question or an append could have + // admitted the session. + const [first, ...rest] = events; + const doctored = + first !== undefined && first.type === "yield" + ? [{ ...first, description: { ...first.description, name: "__elsewhere__" } }, ...rest] + : events; + const refused = refusal( + yield* openReplSession({ + execution: execution(doctored), + installations: installations(), + }), + ); + expect(refused).toBeInstanceOf(Error); + // Refused before the provider was reached. + expect(stub.asked).toEqual([]); + }); + }); +}); + +describe("L4 — an admitted session failure terminates its owner", () => { + beforeAll(() => useTempFileCompiler()); + + it("L4: a post-admission correlation failure withdraws authority and join returns it", function* () { + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["reply"] }, + }); + yield* useStub(stub); + const holder = execution(); + const session = opened(yield* start(holder, ONE_PROMPT, "approve-reads")); + // Admitted on a real queued turn and held at an interactive request. + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + expect(session.live).toBe(true); + const pending = session.agent.requests[0]!; + + // An `agent_prompt` on a coroutine this session never observed a turn + // on. Correlation cannot be exact, and the session is already admitted, + // so there is nothing left to refuse atomically. + yield* holder.stream.append({ + type: "yield", + coroutineId: "root.7", + description: { type: "agent_prompt", name: "prompt:elsewhere#0" }, + result: { + status: "ok", + value: { sequence: 9, agent: "x", sessionKey: "y", status: "completed", text: "" }, + }, + }); + + const outcome = yield* session.join(); + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error.name).toBe("ReplAgentCorrelationError"); + // The held request and its authority are gone, and a late choice cannot + // replace the first failure. + expect(session.agent.requests).toEqual([]); + expect(session.permissions.choose(pending.key, "once")).toBe(false); + expect(stub.outcomes.has("call-1")).toBe(false); + }); +}); + +describe("P1 — exact policy and per-turn suspension", () => { + beforeAll(() => useTempFileCompiler()); + + const cases: Array<{ + readonly title: string; + readonly mode: PermissionMode; + readonly kind: string; + readonly options: readonly PermissionOption[]; + readonly expected: PermissionOutcome; + }> = [ + { + title: "approve-all selects allow_once first", + mode: "approve-all", + kind: "execute", + options: ALL_KINDS, + expected: { outcome: "selected", optionId: "once" }, + }, + { + title: "approve-all falls back to allow_always", + mode: "approve-all", + kind: "execute", + options: [ALL_KINDS[1]!, ALL_KINDS[2]!], + expected: { outcome: "selected", optionId: "always" }, + }, + { + title: "approve-all denies when neither allow kind is offered", + mode: "approve-all", + kind: "execute", + options: [ALL_KINDS[2]!], + expected: { outcome: "selected", optionId: "no" }, + }, + { + title: "deny-all selects reject_once first", + mode: "deny-all", + kind: "read", + options: ALL_KINDS, + expected: { outcome: "selected", optionId: "no" }, + }, + { + title: "deny-all falls back to reject_always", + mode: "deny-all", + kind: "read", + options: [ALL_KINDS[0]!, ALL_KINDS[3]!], + expected: { outcome: "selected", optionId: "never" }, + }, + { + title: "deny-all cancels when no rejection is offered", + mode: "deny-all", + kind: "read", + options: [ALL_KINDS[0]!], + expected: { outcome: "cancelled" }, + }, + { + title: "approve-reads approves a read", + mode: "approve-reads", + kind: "read", + options: ALL_KINDS, + expected: { outcome: "selected", optionId: "once" }, + }, + { + title: "approve-reads approves a search with allow_always", + mode: "approve-reads", + kind: "search", + options: [ALL_KINDS[1]!], + expected: { outcome: "selected", optionId: "always" }, + }, + { + title: "approve-reads denies a read with no allow kind", + mode: "approve-reads", + kind: "read", + options: [ALL_KINDS[3]!], + expected: { outcome: "selected", optionId: "never" }, + }, + ]; + + for (const decided of cases) { + it(`P1: ${decided.title}`, function* () { + const stub = createStub({ + one: { + permission: { toolCallId: "call-1", kind: decided.kind, options: decided.options }, + }, + }); + yield* useStub(stub); + const session = opened(yield* start(execution(), ONE_PROMPT, decided.mode)); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + expect(stub.outcomes.get("call-1")).toEqual(decided.expected); + // An automatic decision publishes no pending request, and asks nobody. + expect(session.agent.requests).toEqual([]); + }); + } + + it("P1: one held request does not suspend its sibling spawn", function* () { + const stub = createStub({ + blocked: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["held"] }, + continue: { deltas: ["free"] }, + }); + yield* useStub(stub); + const holder = execution(); + const session = opened( + yield* start( + holder, + [ + "", + '', + '', + "", + ].join("\n"), + "approve-reads", + ), + ); + const recorded = watchAppends(holder); + + yield* spawn(function* () { + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + const pending = session.agent.requests[0]!; + // The request identifies only its own turn. + const owner = session.agent.turns.find((one) => one.key === pending.turn); + expect(owner?.prompt).toBe("blocked"); + expect(pending.toolCallId).toBe("call-1"); + // While it waits, the sibling starts, streams, settles and appends + // durably — one held request does not suspend the whole ``. + yield* recorded(1); + expect(session.model.turns.map((one) => one.input)).toEqual(["continue"]); + expect(session.agent.requests).toHaveLength(1); + // Answering releases only its own turn. + expect(session.permissions.choose(pending.key, "once")).toBe(true); + }); + + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "once" }); + expect(appends(yield* holder.stream.readAll())).toHaveLength(2); + expect(session.agent.requests).toEqual([]); + }); +}); + +describe("P2 — one live request, one direct settlement", () => { + beforeAll(() => useTempFileCompiler()); + + it("P2: an unknown option and a stale key settle nothing; the offered one settles once", function* () { + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["reply"] }, + }); + yield* useStub(stub); + const session = opened(yield* start(execution(), ONE_PROMPT, "approve-reads")); + yield* spawn(function* () { + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + const pending = session.agent.requests[0]!; + // Never an option the provider did not offer, and never a key this + // process did not issue. + expect(session.permissions.choose(pending.key, "invented")).toBe(false); + expect(session.permissions.choose("request-999", "once")).toBe(false); + expect(session.agent.requests).toHaveLength(1); + expect(session.permissions.choose(pending.key, "always")).toBe(true); + // Already settled: the same key acts on nothing a second time. + expect(session.permissions.choose(pending.key, "once")).toBe(false); + expect(session.permissions.dismiss(pending.key)).toBe(false); + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "always" }); + }); + + it("P2: dismissal while live denies once and the turn resumes", function* () { + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["resumed"] }, + }); + yield* useStub(stub); + const session = opened(yield* start(execution(), ONE_PROMPT, "approve-reads")); + yield* spawn(function* () { + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + expect(session.permissions.dismiss(session.agent.requests[0]!.key)).toBe(true); + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + // The established denial rule, and the turn carried on with it. + expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "no" }); + expect(session.agent.requests).toEqual([]); + expect(session.model.turns).toHaveLength(1); + expect(session.model.turns[0]!.text).toBe("resumed"); + }); + + it("P2: a cold retained audit has no pending reading and no authority", function* () { + const golden = execution(); + yield* scoped(function* () { + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "read" }, deltas: ["reply"] }, + }); + yield* useStub(stub); + yield* opened(yield* start(golden, ONE_PROMPT, "approve-reads")).join(); + expect(stub.outcomes.has("call-1")).toBe(true); + }); + + yield* scoped(function* () { + const stub = createStub(); + yield* useStub(stub); + const reopened = opened( + yield* openReplSession({ + execution: execution(yield* golden.stream.readAll()), + installations: installations(), + }), + ); + yield* reopened.join(); + // The audit is retained model data; replay recreates no wait from it. + expect(reopened.agent.requests).toEqual([]); + expect(reopened.permissions.choose("request-1", "once")).toBe(false); + expect(stub.asked).toEqual([]); + }); + }); +}); + +describe("P3 — whole-session teardown is structured cancellation", () => { + beforeAll(() => useTempFileCompiler()); + + it("P3: teardown cancels and joins the held request without claiming a denial", function* () { + const holder = execution(); + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["reply"] }, + }); + const [owner, dispose] = createScope(yield* useScope()); + const held = withResolvers(); + owner.run(function* () { + yield* useStub(stub); + held.resolve(opened(yield* start(holder, ONE_PROMPT, "approve-reads"))); + yield* sleep(DEADLOCK_MS); + }); + const session = yield* held.operation; + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + const pending = session.agent.requests[0]!; + + yield* until(dispose()); + + // Absence only. The permission operation was cancelled with its owner, so + // nothing here asserts that it observed a denial or any other outcome. + expect(session.agent.requests).toEqual([]); + expect(stub.outcomes.has("call-1")).toBe(false); + expect(appends(yield* holder.stream.readAll())).toEqual([]); + // A late choice acts on nothing, and settles nothing afterwards either. + expect(session.permissions.choose(pending.key, "once")).toBe(false); + expect(stub.outcomes.has("call-1")).toBe(false); + }); +}); + +describe("P4 — a request without one live owner fails the session", () => { + beforeAll(() => useTempFileCompiler()); + + it("P4: an unowned request publishes nothing, denies nothing and fails the session", function* () { + const stub = createStub(); + yield* useStub(stub); + const answered: PermissionOutcome[] = []; + const raised: string[] = []; + // An authored component, outside any live Prompt, reaching the public + // operation exactly as a provider would. + yield* registerComponents([ + { + name: "AskOutside", + origin: "tier-854", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + try { + answered.push( + yield* Agent.operations.requestPermission({ + session: { sessionKey: "stub:none", cwd: "/stub" }, + toolCall: { toolCallId: "orphan", kind: "execute" }, + options: ALL_KINDS, + }), + ); + } catch (error) { + // Swallowed and carried on, the way an ordinary `` turns a + // provider failure into its own durable result and continues. If + // the failure reached only this operation, the session would still + // be live — which is exactly what it may not be. + raised.push(error instanceof Error ? error.name : String(error)); + } + return "continued"; + }, + }, + ]); + const holder = execution(); + const session = opened(yield* start(holder, "\n", "approve-reads")); + // Bounded, because the failure mode of not failing the session is that it + // waits forever on a request nobody can answer. A correct kernel never + // reaches the deadline. + const outcome = yield* race([ + session.join(), + (function* (): Operation> { + yield* sleep(DEADLOCK_MS); + throw new Error( + "the session never ended: an unowned request was published, denied, or left waiting", + ); + })(), + ]); + + // The document did not fail on its own — the component carried on — and + // the session failed anyway, because the owner was told too. + expect(outcome.ok).toBe(false); + expect(outcome.ok === false && outcome.error.name).toBe("ReplPermissionOwnerError"); + // Reported to the permission operation as well as to the session owner. + expect(raised).toEqual(["ReplPermissionOwnerError"]); + // Never published, and never converted into a denial. + expect(session.agent.requests).toEqual([]); + expect(answered).toEqual([]); + // No invented audit: the journal holds only what actually happened. + expect(appends(yield* holder.stream.readAll())).toEqual([]); + }); +}); diff --git a/packages/core/mod.ts b/packages/core/mod.ts index fa11acf66..b08e21491 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -323,6 +323,10 @@ export { } from "./src/agent/components.ts"; export type { AgentComponentsOptions } from "./src/agent/components.ts"; export { Agent } from "./src/agent/agent-api.ts"; +// The one deny decision, so a host writing its own permission policy reaches +// the same rule the base handler and every built-in policy reach rather than +// spelling "reject_once, then reject_always, otherwise cancel" again. +export { denyPermission } from "./src/agent/agent-api.ts"; export type { AgentApi, AgentOption, From 7a96b3e112915b385b2e0fcdd8846b59ef8e4686 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 28 Sep 2026 21:58:19 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=90=9B=20Record=20the=20permission=20?= =?UTF-8?q?decision=20the=20REPL's=20own=20authority=20makes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn the REPL ran retained no permission audit at all. The observation was installed per Prompt, inside the turn; the REPL's authority is installed with the session's execution installations, outside it. A policy decides every request inside its scope without deferring outward — that is what deciding means — so the observer never saw the call it exists to record, and `PromptRecord.permissions` was empty for every REPL turn while Slice A's contract held everywhere else. One observer now sits with the Agent installation itself, outside every policy installed after it. Each Prompt places a private ledger where its own requests will find it, and a request raised while that Prompt's provider stream is being consumed inherits that ledger through its own scope — so two turns asking at once keep their audits apart with nothing correlating by recency, prompt text, agent, conversation or the order decisions settled in. The observer reserves the request's place on the way in, delegates exactly once, and copies the returned outcome into that same place, so two overlapping requests from one turn keep the order they were asked in however their answers interleave. A place nobody completed is published as nothing: a policy that raised, and a wait cancelled with its session, leave no member rather than an outcome nobody reached. The ledger and its context are private to the journal module. Nothing new is exported, no authority is added, and the safe fields are still copied one by one — `rawInput`, the Session and the provider's own objects reach neither the bytes nor the model. `{ at: "max" }` is not outside a later default install: it *is* the default, and an outer scope's middleware ends up outermost. That is why this is installed in the Agent installation rather than once per execution. --- architecture.md | 15 + .../cli/tests/repl-agent-execution.test.ts | 375 +++++++++++++++++- packages/core/src/agent/components.ts | 15 +- .../core/src/agent/function-components.ts | 8 +- packages/core/src/agent/journal.ts | 119 ++++-- specs/acp-client-spec.md | 22 +- 6 files changed, 516 insertions(+), 38 deletions(-) diff --git a/architecture.md b/architecture.md index 12e45b4bc..3dda5958c 100644 --- a/architecture.md +++ b/architecture.md @@ -2152,6 +2152,21 @@ public permission chain. This is the V1 permission ceiling; the portable proof that every provider exposes no ambient tool is tracked separately and does not widen it. `xmd run` keeps its caller-selected Agent permission behavior. +What a turn was allowed to do is retained beside what it said, and the two are +recorded by different things. A permission policy decides every request inside +its scope without deferring outward, so nothing installed inside one — and +nothing installed inside the turn, below a policy a host put around the execution +— can see the decision it makes. One observer sits with the Agent installation +itself, outside every policy installed after it; each Prompt places a private +ledger where its own requests will find it, and a request inherits that ledger +through the scope its provider stream is consumed in. The observer reserves a +place, delegates once, and copies the outcome that comes back into that place, so +concurrent turns keep their own audits and two overlapping requests keep the +order they were asked in. A place nobody completed — a policy that raised, a turn +torn down while a request waited — is published as nothing rather than as a +denial somebody invented. The ledger names a destination and confers no authority +to answer anything. + The only thing a workflow Agent receives is the rendered content of an authored ``. It proposes an information request, or a change, by returning XMD. The trusted workflow host passes that exact response to the constrained diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index 50bcc2867..09d0bf520 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -17,9 +17,23 @@ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { createScope, race, scoped, sleep, spawn, until, useScope, withResolvers } from "effection"; +import { + all, + createScope, + race, + scoped, + sleep, + spawn, + until, + useScope, + withResolvers, +} from "effection"; import type { Operation, Result, Stream } from "effection"; -import { DurableContext, InMemoryStream } from "@executablemd/durable-streams"; +import { + DurableContext, + InMemoryStream, + serializeDurableEvent, +} from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { Agent, @@ -40,10 +54,14 @@ import type { import type { AgentProviderFactory } from "@executablemd/core"; import type { ExecutionInstallation } from "@executablemd/core/host"; +import { API } from "@executablemd/runtime"; + import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; +import { REFERENCE_DIRECTORY } from "./fixtures/repl/reference.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; import type { ReplAgentReading } from "../src/repl/agent.ts"; +import type { ReplAgentPermission } from "../src/repl/model.ts"; import type { ReplExecution } from "../src/repl/journal.ts"; /** @@ -98,6 +116,31 @@ interface Scripted { readonly kind?: string; readonly title?: string; readonly options?: readonly PermissionOption[]; + /** Provider-owned input nothing durable may hold, for the canary row. */ + readonly rawInput?: unknown; + }; + /** + * Two requests from one turn, both raised before either is answered. + * + * Concurrent on purpose: the order they are *answered* in is then the test's + * to choose, which is what proves a retained audit keeps the order they + * arrived in instead. + */ + readonly permissions?: readonly { + readonly toolCallId: string; + readonly kind?: string; + readonly title?: string; + }[]; + /** + * One more request, raised after this turn's first delta. + * + * By then a sibling turn has placed its own ledger, so a record correlated by + * whichever ledger was placed most recently puts this audit on the wrong turn. + */ + readonly late?: { + readonly toolCallId: string; + readonly kind?: string; + readonly title?: string; }; readonly status?: "completed" | "failed" | "cancelled"; /** @@ -284,6 +327,7 @@ function turn( // terminal event needs it too. let where = ""; let released = false; + let lateAsked = false; return { *next() { if (!announced) { @@ -314,6 +358,7 @@ function turn( toolCallId: wanted.toolCallId, ...(wanted.title === undefined ? {} : { title: wanted.title }), ...(wanted.kind === undefined ? {} : { kind: wanted.kind }), + ...(wanted.rawInput === undefined ? {} : { rawInput: wanted.rawInput }), }, options: wanted.options ?? ALL_KINDS, }; @@ -322,6 +367,27 @@ function turn( yield* Agent.operations.requestPermission(request), ); } + const pair = scripted.permissions; + if (pair !== undefined) { + // Both raised before either is answered, so the turn is holding two + // decisions at once. + yield* all( + pair.map((one) => + (function* (): Operation { + const outcome = yield* Agent.operations.requestPermission({ + session, + toolCall: { + toolCallId: one.toolCallId, + ...(one.title === undefined ? {} : { title: one.title }), + ...(one.kind === undefined ? {} : { kind: one.kind }), + }, + options: ALL_KINDS, + }); + stub.outcomes.set(one.toolCallId, outcome); + })(), + ), + ); + } } if (stage === 0) { stage = 1; @@ -335,6 +401,22 @@ function turn( produced.push(event); return { done: false, value: event }; } + if (stage === deltas.length + 1 && scripted.late !== undefined && !lateAsked) { + lateAsked = true; + const one = scripted.late; + stub.outcomes.set( + one.toolCallId, + yield* Agent.operations.requestPermission({ + session, + toolCall: { + toolCallId: one.toolCallId, + ...(one.title === undefined ? {} : { title: one.title }), + ...(one.kind === undefined ? {} : { kind: one.kind }), + }, + options: ALL_KINDS, + }), + ); + } if (stage === deltas.length + 1) { stage += 1; const event: AgentPromptEvent = { @@ -399,15 +481,65 @@ function start( holder: ReplExecution, source: string, permissionMode: PermissionMode = "deny-all", + includes?: readonly string[], ): Operation> { return submitReplEntry({ execution: holder, installations: installations(), permissionMode, source, + ...(includes === undefined ? {} : { includes }), }); } +/** + * What a run actually performed, counted where the work happens. + * + * Outside the engine's own handlers, so what these count is the read and the + * compilation themselves rather than the records of them: a replay that restored + * a completed effect reads no source and compiles nothing, and that is the + * difference between restoring and doing again. + */ +interface Performed { + /** Component sources actually read from disk. */ + readonly reads: string[]; + /** Eval blocks actually compiled, which is where a block really runs. */ + compiles: number; +} + +function* countPerformed(): Operation { + const performed: Performed = { reads: [], compiles: 0 }; + yield* API.Fs.around({ + *readTextFile([path], next) { + performed.reads.push(path); + return yield* next(path); + }, + }); + yield* API.Env.around({ + *compile([source, options], next) { + performed.compiles++; + return yield* next(source, options); + }, + }); + return performed; +} + +/** One document that really reads a component and really compiles an eval block. */ +const READS_AND_COMPILES = [ + "```js eval", + 'const plan = { title: "Ship the audit", steps: 2 };', + "```", + "", + "", + "", + '', +].join("\n"); + +/** The exact bytes a journal holds, for a comparison that is about bytes. */ +function serialized(events: readonly DurableEvent[]): string[] { + return events.map((event) => serializeDurableEvent(event)); +} + /** Wait until the live reading satisfies `holds`, or say it never did. */ function* reported( session: ReplSession, @@ -476,6 +608,17 @@ function watchAppends(holder: ReplExecution): (count: number) => Operation }; } +/** + * Every permission this session's retained turns hold, turn by turn. + * + * Read from the projected model rather than from the ledger: what a row about a + * durable audit is entitled to is what the Journal says, after core parsed it + * back. + */ +function audited(session: ReplSession): readonly ReplAgentPermission[] { + return session.model.turns.flatMap((turn) => turn.permissions); +} + /** Every appended `agent_prompt`, in journal order, with its coroutine. */ function appends(events: readonly DurableEvent[]): Array<{ name: string; coroutineId: string }> { return events @@ -1023,6 +1166,18 @@ describe("P2 — one live request, one direct settlement", () => { const outcome = yield* session.join(); expect(outcome.ok).toBe(true); expect(stub.outcomes.get("call-1")).toEqual({ outcome: "selected", optionId: "always" }); + // And the turn that asked retains exactly that decision, once: the outcome + // the REPL's own authority returned, copied into the place the request + // reserved on its way in. + expect(audited(session)).toEqual([ + { + toolCallId: "call-1", + kind: "execute", + options: ALL_KINDS, + outcome: "selected", + selected: "always", + }, + ]); }); it("P2: dismissal while live denies once and the turn resumes", function* () { @@ -1042,33 +1197,82 @@ describe("P2 — one live request, one direct settlement", () => { expect(session.agent.requests).toEqual([]); expect(session.model.turns).toHaveLength(1); expect(session.model.turns[0]!.text).toBe("resumed"); + // Retained once, as the denial it was — not as a cancellation, and not twice. + expect(audited(session)).toEqual([ + { + toolCallId: "call-1", + kind: "execute", + options: ALL_KINDS, + outcome: "selected", + selected: "no", + }, + ]); }); it("P2: a cold retained audit has no pending reading and no authority", function* () { + const AUDIT = [ + { + toolCallId: "call-1", + kind: "read", + options: ALL_KINDS, + outcome: "selected", + selected: "once", + }, + ]; const golden = execution(); + let written: string[] = []; yield* scoped(function* () { const stub = createStub({ one: { permission: { toolCallId: "call-1", kind: "read" }, deltas: ["reply"] }, }); yield* useStub(stub); - yield* opened(yield* start(golden, ONE_PROMPT, "approve-reads")).join(); + // Counted where the work happens: this run really reads a component's + // source and really compiles an eval block, so "nothing again" has + // something to be measured against. + const performed = yield* countPerformed(); + const live = opened( + yield* start(golden, READS_AND_COMPILES, "approve-reads", [REFERENCE_DIRECTORY]), + ); + yield* live.join(); expect(stub.outcomes.has("call-1")).toBe(true); + // The policy answered this one itself, and the record says exactly what it + // answered: the safe fields and the outcome, and nothing of the request. + expect(audited(live)).toEqual(AUDIT); + expect( + performed.reads.filter((path) => path.endsWith("Checklist.md")).length, + ).toBeGreaterThan(0); + expect(performed.compiles).toBeGreaterThan(0); + written = serialized(yield* golden.stream.readAll()); }); yield* scoped(function* () { const stub = createStub(); yield* useStub(stub); + const performed = yield* countPerformed(); + // The cold process's own execution, held so the bytes it ends with can be + // compared with the bytes the live run left. + const cold = execution(yield* golden.stream.readAll()); const reopened = opened( yield* openReplSession({ - execution: execution(yield* golden.stream.readAll()), + execution: cold, + includes: [REFERENCE_DIRECTORY], installations: installations(), }), ); yield* reopened.join(); + // The same audit, read from the history rather than observed again. + expect(audited(reopened)).toEqual(AUDIT); // The audit is retained model data; replay recreates no wait from it. expect(reopened.agent.requests).toEqual([]); expect(reopened.permissions.choose("request-1", "once")).toBe(false); expect(stub.asked).toEqual([]); + // Nothing was performed again: the component's source was not read and the + // eval block was not compiled, which is what "restored" has to mean. + expect(performed.reads.filter((path) => path.endsWith("Checklist.md"))).toEqual([]); + expect(performed.compiles).toBe(0); + // And reconstructing wrote nothing: this execution ends with the exact + // bytes the live run left, event for event. + expect(serialized(yield* cold.stream.readAll())).toEqual(written); }); }); }); @@ -1102,6 +1306,10 @@ describe("P3 — whole-session teardown is structured cancellation", () => { // A late choice acts on nothing, and settles nothing afterwards either. expect(session.permissions.choose(pending.key, "once")).toBe(false); expect(stub.outcomes.has("call-1")).toBe(false); + // And no audit was published for a decision nobody made: the place the + // request reserved was never completed, so nothing names an outcome. + expect(session.model.turns).toEqual([]); + expect(audited(session)).toEqual([]); }); }); @@ -1168,3 +1376,162 @@ describe("P4 — a request without one live owner fails the session", () => { expect(appends(yield* holder.stream.readAll())).toEqual([]); }); }); + +/** Two spawned turns, each asking its own question. */ +const TWO_ASKS = [ + "", + '', + '', + "", +].join("\n"); + +describe("P5 — the durable audit of a live REPL turn", () => { + beforeAll(() => useTempFileCompiler()); + + it("P5: two live turns settle in reverse order, and each record holds only its own", function* () { + const stub = createStub({ + first: { permission: { toolCallId: "call-first", kind: "execute" }, deltas: ["a"] }, + second: { permission: { toolCallId: "call-second", kind: "execute" }, deltas: ["b"] }, + }); + yield* useStub(stub); + const holder = execution(); + const session = opened(yield* start(holder, TWO_ASKS, "approve-reads")); + yield* spawn(function* () { + // Both waiting at once, which is what makes ownership a question at all. + yield* reported(session, "two pending requests", (reading) => reading.requests.length === 2); + const asked = session.agent.requests; + const first = asked.find((request) => request.toolCallId === "call-first"); + const second = asked.find((request) => request.toolCallId === "call-second"); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + // Different turns, so neither decision could belong to the other. + expect(first?.turn).not.toBe(second?.turn); + // Answered in the opposite order to the one they arrived in. + expect(session.permissions.choose(second?.key ?? "", "always")).toBe(true); + expect(session.permissions.choose(first?.key ?? "", "once")).toBe(true); + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + + // One audit each, and each on the turn that asked it: nothing correlated by + // which turn was newest or which decision settled first. + const turns = session.model.turns; + expect(turns).toHaveLength(2); + const asking = (text: string): readonly ReplAgentPermission[] => + turns.find((turn) => turn.input === text)?.permissions ?? []; + expect(asking("first").map((one) => one.toolCallId)).toEqual(["call-first"]); + expect(asking("second").map((one) => one.toolCallId)).toEqual(["call-second"]); + expect(asking("first")[0]?.selected).toBe("once"); + expect(asking("second")[0]?.selected).toBe("always"); + }); + + it("P5: a later request from an earlier turn is still that turn's", function* () { + const stub = createStub({ + first: { + // Held until its sibling has asked, so the sibling's ledger is the most + // recently placed one when this turn finally asks anything at all. + gated: "each", + permission: { toolCallId: "call-first", kind: "execute" }, + // And asked again once it is running, with the sibling still waiting. + late: { toolCallId: "call-later", kind: "execute" }, + deltas: ["a"], + }, + second: { permission: { toolCallId: "call-second", kind: "execute" }, deltas: ["b"] }, + }); + yield* useStub(stub); + const session = opened(yield* start(execution(), TWO_ASKS, "approve-reads")); + yield* spawn(function* () { + yield* reported(session, "the sibling's request", (reading) => + reading.requests.some((request) => request.toolCallId === "call-second"), + ); + stub.let_("root.0"); + yield* reported(session, "two pending requests", (reading) => reading.requests.length === 2); + const held = (id: string): string => + session.agent.requests.find((request) => request.toolCallId === id)?.key ?? ""; + // The first turn is released and asks again while the second still waits. + expect(session.permissions.choose(held("call-first"), "once")).toBe(true); + yield* reported(session, "the later request", (reading) => + reading.requests.some((request) => request.toolCallId === "call-later"), + ); + expect(session.permissions.choose(held("call-later"), "always")).toBe(true); + expect(session.permissions.choose(held("call-second"), "no")).toBe(true); + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + const asking = (text: string): string[] => + session.model.turns + .find((turn) => turn.input === text) + ?.permissions.map((one) => one.toolCallId) ?? []; + // Both of the first turn's requests are the first turn's, including the one + // it asked after its sibling had placed a ledger of its own. + expect(asking("first")).toEqual(["call-first", "call-later"]); + expect(asking("second")).toEqual(["call-second"]); + }); + + it("P5: two requests from one turn keep the order they were asked, not the order they settled", function* () { + const stub = createStub({ + one: { + permissions: [ + { toolCallId: "call-a", title: "Asked first", kind: "execute" }, + { toolCallId: "call-b", title: "Asked second", kind: "execute" }, + ], + deltas: ["reply"], + }, + }); + yield* useStub(stub); + const session = opened(yield* start(execution(), ONE_PROMPT, "approve-reads")); + yield* spawn(function* () { + yield* reported(session, "two pending requests", (reading) => reading.requests.length === 2); + const asked = session.agent.requests; + const a = asked.find((request) => request.toolCallId === "call-a"); + const b = asked.find((request) => request.toolCallId === "call-b"); + // One turn, both places already reserved, and the second one answered + // first — which is exactly what a record sorted by completion would show + // the wrong way round. + expect(a?.turn).toBe(b?.turn); + expect(session.permissions.choose(b?.key ?? "", "always")).toBe(true); + expect(session.permissions.choose(a?.key ?? "", "once")).toBe(true); + }); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + expect(audited(session).map((one) => one.toolCallId)).toEqual(["call-a", "call-b"]); + expect(audited(session).map((one) => one.selected)).toEqual(["once", "always"]); + }); + + it("P5: provider-owned input reaches neither the journal nor the model", function* () { + const canary = "canary-9f3b7c1e-only-in-rawInput"; + const stub = createStub({ + one: { + // A read, so the policy answers it without anybody being asked — and the + // request carries something no record may ever hold. + permission: { + toolCallId: "call-1", + kind: "read", + title: "Read a file", + rawInput: { path: "/etc/passwd", secret: canary }, + }, + deltas: ["reply"], + }, + }); + yield* useStub(stub); + const holder = execution(); + const session = opened(yield* start(holder, ONE_PROMPT, "approve-reads")); + const outcome = yield* session.join(); + expect(outcome.ok).toBe(true); + // The decision was made and retained. + expect(audited(session)).toHaveLength(1); + // And what the provider owned stayed the provider's: not in the bytes, not + // in the model, not under any name. + const written = yield* holder.stream.readAll(); + expect(written.map((event) => serializeDurableEvent(event)).join("\n")).not.toContain(canary); + expect(JSON.stringify(session.model)).not.toContain(canary); + expect(Object.keys(audited(session)[0] ?? {}).sort()).toEqual([ + "kind", + "options", + "outcome", + "selected", + "title", + "toolCallId", + ]); + }); +}); diff --git a/packages/core/src/agent/components.ts b/packages/core/src/agent/components.ts index e23b2a332..0f444bd09 100644 --- a/packages/core/src/agent/components.ts +++ b/packages/core/src/agent/components.ts @@ -54,7 +54,11 @@ import { SESSION_PROPS, SessionLaunch, } from "./function-components.ts"; -import { promptFailureFromRecord, readCompletedPrompts } from "./journal.ts"; +import { + observePermissionDecisions, + promptFailureFromRecord, + readCompletedPrompts, +} from "./journal.ts"; export interface AgentComponentsOptions { /** Default agent seeded for `` inheritance. */ @@ -229,6 +233,15 @@ export function* installAgentComponents(options?: AgentComponentsOptions): Opera yield* useAgentComponents(); + // One observer for this installation, outside every policy installed after it. + // A policy decides without delegating — that is what deciding means — so an + // observer anywhere inside one never sees the decision it makes. Installed + // here, in the Agent installation itself, it wraps the host's own policy and + // the REPL's authority alike. Each Prompt puts its own ledger where its + // requests will find it, and this copies the outcome into the place the + // request reserved on the way in. + yield* observePermissionDecisions(); + const rootProvider = options?.rootProvider; yield* Execution.around({ diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index f7b6b3884..4531e6270 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -405,9 +405,11 @@ function* runPrompt( // document teardown. consumed = yield* scoped(function* (): Operation { const result: ConsumedTurn = { text: "" }; - // Installed before the turn is asked for, so a provider that requests - // permission the moment it is subscribed is already being watched. - yield* permissions.observe(); + // Placed before the turn is asked for, so a provider that requests + // permission the moment it is subscribed already finds this turn's ledger. + // What observes the decision is installed for the whole execution, outside + // every policy; this says only which turn a decision it sees belongs to. + yield* permissions.place(); const stream = yield* Agent.operations.prompt(text, options); const subscription = yield* stream; let next = yield* subscription.next(); diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index 76d910d28..ab03fd991 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -55,6 +55,7 @@ import type { Result as DurableResult, Workflow, } from "@executablemd/durable-streams"; +import { createContext } from "effection"; import type { Operation } from "effection"; import { Agent } from "./agent-api.ts"; import type { @@ -151,47 +152,109 @@ interface PermissionDraft { outcome?: PermissionOutcome; } +/** + * Where one Prompt's audit goes, for whoever answers a request it made. + * + * A destination and nothing else: holding it says which turn a decision belongs + * to, and grants no authority to make one. Private to this module — no caller + * outside it can reach the context, the ledger or a reserved place. + * + * Scope is the correlation. A permission request raised while a Prompt's + * provider stream is being consumed runs inside that Prompt's own scope, so it + * inherits that Prompt's ledger and no other — never the newest turn, the turn + * with matching text, or whichever decision settled first. + */ +const PromptAudit = createContext("xmd.agent.prompt-permission-audit"); + +/** One Prompt's ledger: reserve a place, then complete that same place. */ +interface PromptAuditLedger { + /** + * Take this request's place in the order, and hand back the one way to + * complete it. + * + * None when the request's safe fields do not read as the closed shape a record + * holds: retaining a half-read audit would make the record unparseable, which + * would fail a turn over how it was watched. + */ + reserve(request: PermissionRequest): ((outcome: PermissionOutcome) => void) | undefined; +} + /** What one turn observed of the permission requests made while it ran. */ export interface PromptPermissionAudit { - /** Observe permission requests for as long as the installing scope lives. */ - observe(): Operation; + /** + * Put this ledger where a request made by this Prompt will find it. + * + * For the lifetime of the scope that calls it, which is the scope the provider + * stream is consumed in. + */ + place(): Operation; /** The requests that were decided, in the order they arrived. */ completed(): readonly PromptPermission[]; } /** - * Observe the permission requests one prompt turn answers. + * Observe every permission decision, from outside every policy that makes one. * - * Ordinary middleware around the existing `Agent.requestPermission()`, so it - * sits outside every installed policy and inside whatever a document composed: - * it sees the request on its way to being decided and the decision on its way - * back, and it delegates both unchanged. It decides nothing, substitutes - * nothing, and swallows nothing — a policy that raises raises through here, and - * the request it was answering is simply never completed. + * At `max`, which is the outermost position there is: a policy decides without + * delegating — that is what deciding means — so an observer anywhere inside one + * never sees the call it exists to record. The REPL's own authority is installed + * at the ordinary position and is therefore inside this, which is the whole + * point: its outcome is the one this copies. * - * The subject is copied when the call begins rather than when it ends, because - * a caller is free to reuse or rewrite the request object it passed once the - * answer is in hand. A request whose safe fields do not read as this closed - * shape is observed as nothing at all: retaining a half-read audit would make - * the record unparseable, which would fail a turn over how it was watched. + * It decides nothing, substitutes nothing and swallows nothing. A policy that + * raises raises through here, and the place it was answering is simply never + * completed. + */ +export function* observePermissionDecisions(): Operation { + yield* Agent.around( + { + *requestPermission([request], next) { + const ledger = yield* PromptAudit.get(); + // Reserved on the way in, because a caller is free to reuse or rewrite the + // request object it passed once the answer is in hand. + const complete = ledger?.reserve(request); + const outcome = yield* next(request); + complete?.(outcome); + return outcome; + }, + }, + { at: "max" }, + ); +} + +/** + * The audit one prompt turn keeps of the permission requests made while it ran. + * + * The turn owns the ledger and the observer is somewhere else entirely — outside + * every policy, installed once for the execution. What connects them is scope: + * this ledger is placed where the provider's stream is consumed, and a request + * raised from in there finds it. + * + * Only completed places are published. A decision still being made, and one + * whose policy was cancelled before it answered, are both absent — an audit + * naming an outcome nobody reached would be a record of something that did not + * happen. */ export function promptPermissionAudit(): PromptPermissionAudit { const drafts: PermissionDraft[] = []; + const ledger: PromptAuditLedger = { + reserve(request: PermissionRequest) { + const subject = permissionSubject(request); + if (subject === undefined) { + return undefined; + } + // The place is taken now and completed later, so two requests from one + // turn keep the order they arrived in however their answers interleave. + const draft: PermissionDraft = { subject }; + drafts.push(draft); + return (outcome: PermissionOutcome) => { + draft.outcome = permissionDecision(outcome); + }; + }, + }; return { - observe() { - return Agent.around({ - *requestPermission([request], next) { - const subject = permissionSubject(request); - if (subject === undefined) { - return yield* next(request); - } - const draft: PermissionDraft = { subject }; - drafts.push(draft); - const outcome = yield* next(request); - draft.outcome = permissionDecision(outcome); - return outcome; - }, - }); + *place() { + yield* PromptAudit.set(ledger); }, completed() { return drafts.flatMap((draft) => { diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index fe1b966f4..b27578a5f 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -536,8 +536,26 @@ omits the member entirely, which is also how every record written before this member existed reads. Parsing an older record is unchanged, and nothing rewrites one. -Observing a request changes no decision. The installed `PermissionMode` policy -still answers it, and these records describe decisions rather than making them. +Observing a request changes no decision, and what observes one is deliberately +somewhere else from what answers it. A policy decides every request inside its +scope and never defers outward, so an observer installed inside a policy — or +inside the turn, below a policy a host installed around the execution — sees +nothing of the decision it exists to record. One observer is therefore installed +with the Agent installation itself, outside every policy installed after it, and +each turn puts its own private ledger where a request it makes will find it. A +request raised while that turn's provider stream is being consumed inherits that +turn's ledger through its own scope, so two turns asking at the same time keep +their audits apart without anything correlating by recency, prompt text, agent, +conversation or the order decisions settled in. + +The ledger is a destination and grants no authority: holding it says which turn a +decision belongs to, never what the decision is. The observer reserves the +request's place on the way in, delegates exactly once to whatever answers it, and +copies the returned outcome into that same place — which is why two overlapping +requests from one turn keep the order they arrived in however their answers +interleave. A place nobody completed is published as nothing: a policy that +raised, and one cancelled with its turn during teardown, both leave no member +rather than a fabricated outcome. On a **full replay** (the journal already holds the root `Close`), completed records are restored from the journal without contacting any provider — From 799a69645b751fe2502084b19f3900e26752b652 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 20:41:50 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=90=9B=20Subscribe=20to=20a=20session?= =?UTF-8?q?'s=20change=20streams=20before=20its=20document=20can=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spawn()` returns before its child body runs, and a Signal drops whatever it sends while no subscription is active. Both admission watchers created their subscription inside the spawned child, so an announcement the execution made immediately — the queued Agent turn or the elicitation that is the only evidence admitting a cold session — could be sent into no subscriber at all, leaving `start()` waiting for an admission that had already happened. Effection's contract is explicit: create the subscription in the enclosing scope, iterate it in the child. Spawning the consumer earlier is not equivalent, because what must precede the document is the subscription, not the task that reads it. The elicitation watcher had the same lossy ordering and predates this slice. Both belong to one admission boundary, so both are corrected rather than leaving one known race beside the fixed one. --- packages/cli/src/repl/session.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index f9ab13be2..4984cac71 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -428,6 +428,19 @@ function* start( } } + // Both subscriptions belong to this scope, and both exist before the + // document can publish anything. `spawn()` returns before its child body + // has run, and a Signal drops what it sends while no subscription is + // active, so subscribing inside a watcher loses an announcement the + // execution makes immediately — which is the announcement that admits a + // cold session. Effection's contract is explicit that the subscription is + // created in the enclosing scope and only iterated in the child + // (`docs/agents.md`, "Subscription readiness across `spawn()`"). Spawning + // the consumer earlier would not be equivalent: what must precede the + // document is the subscription, not the task that reads it. + const agentReadings = yield* agent.changes; + const questions = yield* elicitation.changes; + const document: Task> = yield* spawn(function* () { const outcome = yield* runExecution(); settle(outcome); @@ -499,18 +512,16 @@ function* start( // record or a question is: replay that reached one is past what the history // held, so the session is admitted rather than still provisional. yield* spawn(function* () { - const readings = yield* agent.changes; - let next = yield* readings.next(); + let next = yield* agentReadings.next(); while (!next.done) { if (next.value.turns.length > 0) { admit(); } - next = yield* readings.next(); + next = yield* agentReadings.next(); } }); yield* spawn(function* () { - const questions = yield* elicitation.changes; let next = yield* questions.next(); while (!next.done) { if (next.value !== undefined) { From 1c884bbe3f750549cfa65652c13fcca653f94ca4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 20:42:07 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=90=9B=20Correlate=20a=20live=20REPL?= =?UTF-8?q?=20turn=20through=20its=20canonical=20Prompt=20publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REPL observed every public `Agent.prompt()` call and drained a per-coroutine queue whenever an `agent_prompt` record appended. `Agent` is exported, so a registered component may call it directly: that call was observed, journalled nothing, and left its queue entry behind — and the next canonical record then retired the direct call's entry instead of its own. The canonical turn stayed in the live overlay while also being durable, and a permission it was granted was audited against the wrong turn. A queue cannot establish that identity, because it holds calls that can never produce a record. The journal boundary already knows which calls are its own, so it now says so: core calls the host publisher's `begin()` at the one moment that is both canonical and live — in the turn's own scope, as its private audit ledger is placed — and hands the same value back on the publication that ends it. `append()` remains the single durable handoff, and the live turn it began is removed inside that same transition, so no announced snapshot holds a turn both ways or neither. The handle is opaque to core, ephemeral, process-local and never journalled. `PromptAudit` stays private, `AgentApi` is untouched, and no parallel host API is introduced: `begin()` is an optional addition to the existing `AgentPromptPublisher`, so a publisher that declares none behaves exactly as before. A direct `Agent.prompt()` begins nothing, so it gets no reading and can neither claim a record nor be claimed by one. A replayed turn asks no provider, so it begins nothing either. P6 covers it: a direct public prompt completing before a canonical `` on the same coroutine, and the same exact handoff for failed and cancelled turns, where no `association` exists and only the handle identifies the turn. --- packages/cli/src/repl/agent.ts | 115 ++++++++++++++---- .../cli/tests/repl-agent-execution.test.ts | 68 +++++++++++ packages/core/host.ts | 1 + .../core/src/agent/function-components.ts | 25 +++- packages/core/src/agent/journal.ts | 9 +- packages/core/src/agent/publication.ts | 38 ++++++ 6 files changed, 224 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/repl/agent.ts b/packages/cli/src/repl/agent.ts index 6ab31a8f1..5dc4100ea 100644 --- a/packages/cli/src/repl/agent.ts +++ b/packages/cli/src/repl/agent.ts @@ -24,20 +24,25 @@ * unfrozen — what is frozen is the separate reading copied out of it — and an * observation failure is never allowed to become a Prompt failure. * - * ## Correlation is by coroutine, not by resemblance + * ## Correlation is the canonical publication, not a resemblance or a queue * * Two `` children may run identical prompts against identical responses * and settle in either order, so nothing about a turn's *content* identifies * it: not its text, its display name, its agent, its session key, or the order - * it finished in. What does identify it is where it ran. Each spawned child is - * its own durable coroutine, expansion inside one coroutine is strictly - * sequential, and an `agent_prompt` record appends on the coroutine that made - * it — so the Nth prompt this execution observed on coroutine X is the Nth - * `agent_prompt` appended on coroutine X. That is a queue per coroutine, and it - * is exact. + * it finished in. Nor does where it ran: a queue per coroutine would also hold + * calls that never become records, because the public `Agent.prompt()` is + * reachable by any registered component and journals nothing. * - * A turn replay restored never reaches this middleware and never appends, so it - * produces no reading and consumes no queue entry. + * So the journal boundary itself says which turn is which. Core calls this + * owner's `begin()` at the one moment that is both canonical and live — in the + * turn's own scope, as its private audit ledger is placed — and hands back the + * very same value on the publication that ends it. `append()` is the single + * durable handoff, and the live turn it began is removed inside that same + * transition, so no announced snapshot holds a turn both ways or neither. + * + * A direct `Agent.prompt()` call begins nothing. It gets no reading, claims no + * record, and cannot be claimed by one. A turn replay restored asks no + * provider, so it begins nothing either: no association, and no live turn. * * ## A request without one owner is an invariant failure * @@ -51,7 +56,7 @@ */ import { action, createSignal, useScope } from "effection"; -import type { Operation, Stream } from "effection"; +import type { Operation, Scope, Stream } from "effection"; import { Agent, denyPermission } from "@executablemd/core"; import type { AgentPromptEvent, @@ -60,7 +65,13 @@ import type { PermissionOutcome, PermissionRequest, } from "@executablemd/core"; -import type { ExecutionInstallation } from "@executablemd/core/host"; +import { useAgentPromptPublisher } from "@executablemd/core/host"; +import type { + AgentPromptHandle, + AgentPromptPublication, + AgentPromptPublisher, + ExecutionInstallation, +} from "@executablemd/core/host"; import { DurableContext } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; @@ -143,6 +154,13 @@ export interface ReplAgentKernel { readonly authority: ReplAgentAuthority; /** What this owner installs inside the execution. */ readonly installation: ExecutionInstallation; + /** + * The publisher that ties each canonical `` to its live turn. + * + * Installed by `installation`; exposed so a caller can see the one seam this + * owner correlates through. + */ + readonly publisher: AgentPromptPublisher; /** * Account for one appended event, without announcing. * @@ -272,8 +290,20 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { const changes = createSignal(); const turns: LiveTurn[] = []; const requests: LiveRequest[] = []; - /** Turns observed on one coroutine and not yet replaced by their record. */ - const awaiting = new Map(); + /** The live turn core began in a scope, until that scope's prompt claims it. */ + const begun = new Map(); + /** Every live turn this owner made, so a handle from elsewhere is not one. */ + const ours = new WeakSet(); + /** + * The canonical publication appending right now on each coroutine. + * + * The handle is what identifies the turn; this only says which of several + * concurrent publications an append belongs to. At most one canonical + * publication is ever in flight per coroutine, because expansion inside one + * coroutine is strictly sequential — so this is an index, never a queue, and + * it holds nothing between transitions. + */ + const publishing = new Map(); let reading: ReplAgentReading = Object.freeze({ turns: Object.freeze([]), requests: Object.freeze([]), @@ -332,12 +362,7 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { failure: undefined, }; turns.push(turn); - const pending = awaiting.get(coroutine); - if (pending === undefined) { - awaiting.set(coroutine, [turn.key]); - } else { - pending.push(turn.key); - } + ours.add(turn); announce(); return turn; } @@ -503,6 +528,37 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { }, }; + const publisher: AgentPromptPublisher = { + *begin(input: string): Operation { + // Created before the provider is asked for anything at all, and handed + // back on this turn's own publication — the only thing that will say + // which live turn that record ended. + const turn = queued(yield* currentCoroutine(), input); + begun.set(yield* useScope(), turn); + return turn; + }, + *publish(publication: AgentPromptPublication): Operation { + const handle = publication.begun; + // A handle this owner did not make identifies nothing here: another + // host's publisher, or a turn from an execution this session never ran. + const turn = + handle !== undefined && ours.has(handle as LiveTurn) ? (handle as LiveTurn) : undefined; + const where = turn?.coroutine; + if (turn !== undefined && where !== undefined) { + publishing.set(where, turn); + } + try { + // The single durable handoff. `consume()` runs inside this append, in + // the caller's one transition, and removes exactly this turn. + yield* publication.append(); + } finally { + if (where !== undefined) { + publishing.delete(where); + } + } + }, + }; + const installation: ExecutionInstallation = { *install(): Operation { // At the ordinary position, not `min`. A provider installs its own @@ -512,13 +568,18 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { // brought with it. Outermost is where this session's own authority goes: // it wraps the provider's stream, and it decides permission before // anything inherited can. + yield* useAgentPromptPublisher(publisher); yield* Agent.around({ *prompt([text, options], next) { - const turn = queued(yield* currentCoroutine(), text); - // Published first, delegated second: the reading exists before the - // provider is asked for anything at all. + // Only the turn core began in this exact scope is journal-owned work. + // A registered component calling the public `Agent.prompt()` arrives + // here having begun nothing: it is delegated untouched, shown in no + // reading, and left unable to claim any record. + const scope = yield* useScope(); + const turn = begun.get(scope); + begun.delete(scope); const stream = yield* next(text, options); - return watch(turn, stream); + return turn === undefined ? stream : watch(turn, stream); }, *requestPermission([request]) { return yield* decide(request); @@ -534,13 +595,13 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { changes, authority, installation, + publisher, consume(event: DurableEvent): void { if (event.type !== "yield" || event.description.type !== AGENT_PROMPT) { return; } - const pending = awaiting.get(event.coroutineId); - const key = pending?.shift(); - if (key === undefined) { + const turn = publishing.get(event.coroutineId); + if (turn === undefined) { // An `agent_prompt` appended where this process observed no turn. The // session has already been admitted, so there is nothing left to refuse // atomically: the owner is terminated instead of guessing which reading @@ -552,7 +613,7 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { ), ); } - const at = turns.findIndex((turn) => turn.key === key); + const at = turns.indexOf(turn); if (at >= 0) { turns.splice(at, 1); } diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index 09d0bf520..6ce8aa909 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -1535,3 +1535,71 @@ describe("P5 — the durable audit of a live REPL turn", () => { ]); }); }); + +describe("P6 — only a canonical Prompt claims a record", () => { + beforeAll(() => useTempFileCompiler()); + + it("P6: a direct public prompt neither claims the canonical record nor lingers", function* () { + const stub = createStub({ + direct: { deltas: ["off-books"] }, + one: { deltas: ["reply"] }, + }); + yield* useStub(stub); + // A registered component reaching the public operation itself. This is + // ordinary — the Api is exported — and it is not journal-owned work: no + // `agent_prompt` record will ever describe it. + yield* registerComponents([ + { + name: "DirectPrompt", + origin: "tier-854", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const stream = yield* Agent.operations.prompt("direct", {}); + const subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return "direct"; + }, + }, + ]); + const holder = execution(); + // Both on the root coroutine, the direct call first: a queue drained on + // append would hand the canonical record the direct call's entry. + const session = opened(yield* start(holder, '\n\n\n')); + const outcome = yield* session.join(); + + expect(outcome.ok).toBe(true); + // The provider answered both, so the direct call really did happen. + expect(stub.asked).toEqual(["direct", "one"]); + // Exactly one record, and it is the canonical Prompt's. + expect(session.model.turns).toHaveLength(1); + expect(session.model.turns[0]?.text).toBe("reply"); + // Nothing live is left over: the canonical publication removed its own + // turn, and the direct call never had one to leave behind. + expect(session.agent.turns).toEqual([]); + expect(session.live).toBe(false); + }); + + for (const ended of ["failed", "cancelled"] as const) { + it(`P6: a ${ended} canonical Prompt hands off exactly as a completed one does`, function* () { + // `association` is absent for every unsuccessful turn, so this is the + // case where nothing but the handle can say which live turn ended. + const stub = createStub({ one: { deltas: ["partial"], status: ended } }); + yield* useStub(stub); + const holder = execution(); + const session = opened(yield* start(holder, ONE_PROMPT)); + const outcome = yield* session.join(); + + // Whether an unsuccessful turn also fails the document is the Prompt + // failure policy's business and not this row's. What this row holds is + // the handoff: the record was appended, and the live turn it began is + // gone — with no `association` to identify it by, only the handle. + expect(outcome).toBeDefined(); + expect(session.model.turns).toHaveLength(1); + expect(session.model.turns[0]?.status).toBe(ended); + expect(session.agent.turns).toEqual([]); + }); + } +}); diff --git a/packages/core/host.ts b/packages/core/host.ts index 7aa337320..cda04dd30 100644 --- a/packages/core/host.ts +++ b/packages/core/host.ts @@ -349,6 +349,7 @@ export type { export { useAgentPromptPublisher } from "./src/agent/publication.ts"; export type { AgentPromptAssociation, + AgentPromptHandle, AgentPromptPublication, AgentPromptPublisher, } from "./src/agent/publication.ts"; diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 4531e6270..1504a8d41 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -58,7 +58,7 @@ import { persistPrompt, promptFailureFromRecord, promptPermissionAudit } from ". import type { PromptRecord } from "./journal.ts"; import { checkpointOf } from "./checkpoint.ts"; import type { AgentPromptCheckpoint } from "./checkpoint.ts"; -import type { AgentPromptAssociation } from "./publication.ts"; +import type { AgentPromptAssociation, AgentPromptHandle } from "./publication.ts"; export const AGENT_PROVIDER_PROPS: PropsSchema = { type: "object", @@ -330,11 +330,12 @@ export function* Prompt(props: Record): Operation { const sequence = yield* AgentInternal.operations.nextPromptSequence(); // Held here rather than on the record: what the journal keeps about a prompt // is unchanged, and this is what a host may retain beside it. - const carried: { association?: AgentPromptAssociation } = {}; + const carried: Carried = {}; const record = yield* persistPrompt( { name: `prompt:${location}#${ordinal}`, input: text, position: expansion.position }, () => runPrompt(text, options, sequence, throwOnError, carried), () => carried.association, + () => carried.begun, ); const failure = promptFailureFromRecord(record); @@ -384,12 +385,21 @@ interface ConsumedTurn { checkpoint?: AgentPromptCheckpoint; } +/** + * What this turn hands the publication beside the record: the association a + * host may retain, and the handle that host recognised this exact live turn by. + */ +interface Carried { + association?: AgentPromptAssociation; + begun?: AgentPromptHandle; +} + function* runPrompt( text: string, options: PromptOptions, sequence: number, throwOnError: boolean, - carried: { association?: AgentPromptAssociation }, + carried: Carried, ): Operation { let consumed: ConsumedTurn = { text: "" }; // Held out here because the audit outlives the turn's own scope: a turn that @@ -410,6 +420,15 @@ function* runPrompt( // What observes the decision is installed for the whole execution, outside // every policy; this says only which turn a decision it sees belongs to. yield* permissions.place(); + // Begun where the ledger is placed, and in this turn's own scope: this is + // the one moment that is both canonical and live, so it is the only place + // a host can be handed something that identifies this turn and nothing + // else. A component calling the public `Agent.prompt()` never reaches + // here, which is exactly why it can claim no publication later. + const publisher = yield* AgentInternal.operations.promptPublisher; + if (publisher?.begin !== undefined) { + carried.begun = yield* publisher.begin(text); + } const stream = yield* Agent.operations.prompt(text, options); const subscription = yield* stream; let next = yield* subscription.next(); diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index ab03fd991..abd637c2d 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -70,7 +70,7 @@ import type { AgentPromptCheckpoint } from "./checkpoint.ts"; import { AgentPromptError, parsePromptFailure } from "./errors.ts"; import type { SerializedPromptFailure } from "./errors.ts"; import { AgentInternal } from "./internal.ts"; -import type { AgentPromptAssociation } from "./publication.ts"; +import type { AgentPromptAssociation, AgentPromptHandle } from "./publication.ts"; import { sourceDescription } from "../source-position.ts"; import type { SourcePosition } from "../types.ts"; @@ -345,6 +345,7 @@ export function* persistPrompt( identity: { name: string; input: string; position?: Readonly }, live: () => Operation, association: () => AgentPromptAssociation | undefined = () => undefined, + begun: () => AgentPromptHandle = () => undefined, ): Workflow { const stored = yield createDurableOperation( { @@ -356,7 +357,7 @@ export function* persistPrompt( function* (): Operation { return serializePromptRecord(yield* live()); }, - { coordinator: promptPublication(association) }, + { coordinator: promptPublication(association, begun) }, ); const parsed = parsePromptRecord(stored); if (!parsed) { @@ -387,6 +388,7 @@ class AgentPromptPublicationError extends Error { */ function promptPublication( association: () => AgentPromptAssociation | undefined, + begun: () => AgentPromptHandle, ): LiveDurableOperationCoordinator { return { *run( @@ -412,6 +414,9 @@ function promptPublication( let appended = false; try { yield* publisher.publish({ + // Carried whatever this turn did: a failed or cancelled Prompt still + // ends the live turn that began, so the handoff is the same one. + begun: begun(), association: published.status === "ok" ? association() : undefined, *append(): Operation { if (appended) { diff --git a/packages/core/src/agent/publication.ts b/packages/core/src/agent/publication.ts index 0884fea2b..da5f5039e 100644 --- a/packages/core/src/agent/publication.ts +++ b/packages/core/src/agent/publication.ts @@ -40,8 +40,30 @@ export interface AgentPromptAssociation { readonly sessionKey: string; } +/** + * What a host recognised one canonical turn by, exactly as its own `begin()` + * returned it. + * + * Opaque to core, which never reads it, compares it or writes it anywhere: it + * is carried from the turn that began to the publication that ends it and + * nowhere else. Ephemeral and process-local by construction — it is whatever + * object the host made, so it cannot outlive the process and cannot be + * journaled. + */ +export type AgentPromptHandle = unknown; + /** One Prompt, ready to publish. */ export interface AgentPromptPublication { + /** + * What this host's own `begin()` returned for this exact turn, or nothing + * when it declared no `begin()`. + * + * This is the only thing that says which live turn this publication ends. A + * prompt that never began canonically — a direct `Agent.prompt()` call from + * a component, which core does not journal — reaches no publication at all, + * so it can neither claim this one nor be claimed by it. + */ + readonly begun: AgentPromptHandle; /** * What this completion carries, or nothing. * @@ -60,6 +82,22 @@ export interface AgentPromptPublication { } export interface AgentPromptPublisher { + /** + * A canonical Prompt is about to ask its provider; nothing is durable yet. + * + * Called once per journal-owned turn, in that turn's own scope and at the + * moment its private audit ledger is placed — so a host that shows live work + * can create it here and be handed the same value back in `publish()`. What + * it returns is the host's own, and core only carries it. + * + * `input` is the rendered prompt this turn is about to ask, exactly as the + * record will hold it. + * + * Only the canonical `` boundary calls this. A component that calls + * the public `Agent.prompt()` itself is not journal-owned work: it begins + * nothing, publishes nothing, and appends no record. + */ + begin?(input: string): Operation; /** * Publish one completed Prompt, and whatever this host keeps beside it. * From 85cec987e04c06e82dc8664bec9687cf6b12ee08 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 20:50:30 -0400 Subject: [PATCH 5/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Bracket=20a=20Prompt's?= =?UTF-8?q?=20permission=20ledger=20around=20the=20work=20it=20governs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `place()` installed this Prompt's ledger and then trusted its caller to have opened a scope of exactly the right size first. Two things that must be inseparable were separate: installing the ledger, and running this Prompt's provider stream beneath it. Called one scope too high it would have leaked silently into sibling work, and nothing in the signature said so. `within(body)` is a lexical bracket instead. The ledger exists for exactly as long as `body` runs, descendants inherit it, and it is restored when `body` returns, raises or is cancelled. No caller can install it without naming the work it governs, so the invariant is structural rather than remembered. The canonical turn is begun inside that same bracket, which is the one moment that is both canonical and live. No behavior changes: the audit rows that prove two requests from one turn keep their arrival order, and that a later request from an earlier turn is still that turn's, pass unchanged. --- .../core/src/agent/function-components.ts | 95 ++++++++++--------- packages/core/src/agent/journal.ts | 15 +-- 2 files changed, 58 insertions(+), 52 deletions(-) diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 1504a8d41..c04906f33 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -414,54 +414,57 @@ function* runPrompt( // permission routing, session lock) run when this prompt finishes — not at // document teardown. consumed = yield* scoped(function* (): Operation { - const result: ConsumedTurn = { text: "" }; - // Placed before the turn is asked for, so a provider that requests - // permission the moment it is subscribed already finds this turn's ledger. - // What observes the decision is installed for the whole execution, outside - // every policy; this says only which turn a decision it sees belongs to. - yield* permissions.place(); - // Begun where the ledger is placed, and in this turn's own scope: this is - // the one moment that is both canonical and live, so it is the only place - // a host can be handed something that identifies this turn and nothing - // else. A component calling the public `Agent.prompt()` never reaches - // here, which is exactly why it can claim no publication later. - const publisher = yield* AgentInternal.operations.promptPublisher; - if (publisher?.begin !== undefined) { - carried.begun = yield* publisher.begin(text); - } - const stream = yield* Agent.operations.prompt(text, options); - const subscription = yield* stream; - let next = yield* subscription.next(); - while (!next.done) { - const event = next.value; - if (event.type === "started") { - result.started = true; - result.agent = event.agent; - result.sessionKey = event.session.sessionKey; - // The value the provider named as the conversation this turn ran in. - // For a configured turn that is the authentic use it was verified - // under, which is what the record is written from. - result.ranIn = event.session; - if (event.session.agentSessionId !== undefined) { - result.agentSessionId = event.session.agentSessionId; - } - } else if (event.type === "terminal") { - result.status = event.status; - if (event.stopReason !== undefined) { - result.stopReason = event.stopReason; - } - if (event.error) { - result.failure = serializePromptFailure(event.error); - } - const checkpoint = checkpointOf(event); - if (checkpoint !== undefined) { - result.checkpoint = checkpoint; + // Everything this turn asks its provider happens inside the audit + // bracket, so a provider that requests permission the moment it is + // subscribed already finds this turn's ledger, and nothing outside can + // find it at all. What observes the decision is installed for the whole + // execution, outside every policy; the ledger says only which turn a + // decision it sees belongs to. + return yield* permissions.within(function* (): Operation { + const result: ConsumedTurn = { text: "" }; + // Begun inside that same bracket: this is the one moment that is both + // canonical and live, so it is the only place a host can be handed + // something that identifies this turn and nothing else. A component + // calling the public `Agent.prompt()` never reaches here, which is + // exactly why it can claim no publication later. + const publisher = yield* AgentInternal.operations.promptPublisher; + if (publisher?.begin !== undefined) { + carried.begun = yield* publisher.begin(text); + } + const stream = yield* Agent.operations.prompt(text, options); + const subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + const event = next.value; + if (event.type === "started") { + result.started = true; + result.agent = event.agent; + result.sessionKey = event.session.sessionKey; + // The value the provider named as the conversation this turn ran in. + // For a configured turn that is the authentic use it was verified + // under, which is what the record is written from. + result.ranIn = event.session; + if (event.session.agentSessionId !== undefined) { + result.agentSessionId = event.session.agentSessionId; + } + } else if (event.type === "terminal") { + result.status = event.status; + if (event.stopReason !== undefined) { + result.stopReason = event.stopReason; + } + if (event.error) { + result.failure = serializePromptFailure(event.error); + } + const checkpoint = checkpointOf(event); + if (checkpoint !== undefined) { + result.checkpoint = checkpoint; + } } + next = yield* subscription.next(); } - next = yield* subscription.next(); - } - result.text = next.value; - return result; + result.text = next.value; + return result; + }); }); if (consumed.status === undefined) { consumed.failure = { message: "agent prompt stream closed without a terminal event" }; diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index abd637c2d..fcf257b20 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -182,12 +182,15 @@ interface PromptAuditLedger { /** What one turn observed of the permission requests made while it ran. */ export interface PromptPermissionAudit { /** - * Put this ledger where a request made by this Prompt will find it. + * Run this Prompt's provider work with this ledger in place, and only it. * - * For the lifetime of the scope that calls it, which is the scope the provider - * stream is consumed in. + * A bracket rather than a marker: the ledger exists for exactly as long as + * `body` runs, descendants inherit it, and it is restored when `body` + * finishes however it finishes — returning, raising or being cancelled. There + * is no way to install the ledger without naming the work it governs, so it + * cannot be left behind for sibling work to inherit. */ - place(): Operation; + within(body: () => Operation): Operation; /** The requests that were decided, in the order they arrived. */ completed(): readonly PromptPermission[]; } @@ -253,8 +256,8 @@ export function promptPermissionAudit(): PromptPermissionAudit { }, }; return { - *place() { - yield* PromptAudit.set(ledger); + within(body: () => Operation): Operation { + return PromptAudit.with(ledger, () => body()); }, completed() { return drafts.flatMap((draft) => { From 9b305c0f158d0f259dcbeadaaa08c13537d599a6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 21:05:33 -0400 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=90=9B=20Let=20the=20Agent=20middlewa?= =?UTF-8?q?re=20own=20auditing,=20and=20admission=20own=20its=20subscripti?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing was something the canonical `` had to remember to install. It now belongs to Core's outer Agent middleware, which owns both sides of it. The `prompt` handler makes one private ledger for that exact invocation and wraps the cold stream it returns. Subscribing and every `next()` run beneath `PromptAudit.with(ledger, …)`, so a provider that asks for permission the moment it is subscribed already finds the right ledger, and the context is restored whichever way each step finishes. The `requestPermission` handler reads the inherited ledger, reserves the request before delegating, delegates once, and records what came back. `` no longer creates, places or brackets anything. It reads its audit from the exact stream it consumed, through a private association held in a WeakMap that belongs to the installation rather than the module. Holding the stream is the only way to name an entry, so a bystander cannot read one. `PromptPermissionAudit.within()` is gone from the component-facing protocol. `scoped()` stays: it owns provider cancellation, permission routing and the session lock, which have nothing to do with audit correlation. Admission's ordering is now structural rather than remembered. `consumeAdmissions()` takes a `Subscription`, not a `Stream`, so the losing shape — a consumer that subscribes as its first act, a turn too late — cannot be written through the signature at all. Both session subscriptions are created in the enclosing scope before the document spawns. Evidence, with the controls that make each row mean something: - AD1/AD2 hold the admission ordering: AD2 shows the replaced shape dropping a value announced before its consumer began, which is what AD1 proves survives. - P6 covers a direct public `Agent.prompt()` beside a canonical `` on one coroutine and across concurrent spawns, and the same handoff for failed and cancelled turns where no association exists. - AFP0/AFP1/AFP2 hold publication failure: a publisher that raises and one that returns without appending both fail through the durability path and leave no `agent_prompt`, while the same harness with a publisher that appends succeeds and records exactly one. --- packages/cli/src/repl/admission.ts | 42 +++++++ packages/cli/src/repl/session.ts | 21 +--- packages/cli/tests/repl-admission.test.ts | 62 ++++++++++ .../cli/tests/repl-agent-execution.test.ts | 48 ++++++++ .../core/src/agent/function-components.ts | 111 +++++++++--------- packages/core/src/agent/journal.ts | 85 +++++++++++--- .../tests/agent-function-components.test.ts | 62 +++++++++- 7 files changed, 344 insertions(+), 87 deletions(-) create mode 100644 packages/cli/src/repl/admission.ts create mode 100644 packages/cli/tests/repl-admission.test.ts diff --git a/packages/cli/src/repl/admission.ts b/packages/cli/src/repl/admission.ts new file mode 100644 index 000000000..7da624b0a --- /dev/null +++ b/packages/cli/src/repl/admission.ts @@ -0,0 +1,42 @@ +/** + * What admits a provisional session: work that went beyond the retained prefix. + * + * A session reconstructing from history is provisional until something proves + * the run reached new work — a queued Agent turn, a question, a fresh record. + * The announcement that proves it is sent on a `Signal`, and a Signal delivers + * only to subscriptions that are already active: whatever it sends before one + * exists is dropped, not buffered. + * + * `spawn()` returns before its child body has run, so a consumer that + * subscribes as its first act subscribes a turn too late. The subscription + * therefore belongs to the caller, which creates it before starting the work + * that can announce; this consumer only iterates what it was handed. Values + * sent after `yield* stream` returns are queued for that active subscription + * even while the consumer has not begun reading, which is exactly the window + * this closes. + */ + +import type { Operation, Subscription } from "effection"; + +/** + * Iterate an already-active subscription, admitting on every value that counts. + * + * Takes the subscription rather than the stream, so there is no way to write + * the consumer that creates its own: the race this exists to prevent cannot be + * reintroduced without changing the signature. + */ +export function consumeAdmissions( + subscription: Subscription, + admits: (value: T) => boolean, + admit: () => void, +): () => Operation { + return function* (): Operation { + let next = yield* subscription.next(); + while (!next.done) { + if (admits(next.value)) { + admit(); + } + next = yield* subscription.next(); + } + }; +} diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index 4984cac71..f951ecc29 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -72,6 +72,7 @@ import { import type { DurableEvent } from "@executablemd/durable-streams"; import { useReplAgent } from "./agent.ts"; +import { consumeAdmissions } from "./admission.ts"; import type { ReplAgentAuthority, ReplAgentReading } from "./agent.ts"; import { useReplElicitation } from "./elicitation.ts"; import type { ReplElicitations, ReplQuestion } from "./elicitation.ts"; @@ -511,25 +512,9 @@ function* start( // A queued Agent turn is work beyond the retained prefix, exactly as a new // record or a question is: replay that reached one is past what the history // held, so the session is admitted rather than still provisional. - yield* spawn(function* () { - let next = yield* agentReadings.next(); - while (!next.done) { - if (next.value.turns.length > 0) { - admit(); - } - next = yield* agentReadings.next(); - } - }); + yield* spawn(consumeAdmissions(agentReadings, (reading) => reading.turns.length > 0, admit)); - yield* spawn(function* () { - let next = yield* questions.next(); - while (!next.done) { - if (next.value !== undefined) { - admit(); - } - next = yield* questions.next(); - } - }); + yield* spawn(consumeAdmissions(questions, (question) => question !== undefined, admit)); // Held open deliberately. This body owns the observer and both tasks, and // finishing it would halt them — so it lasts as long as the scope does, and diff --git a/packages/cli/tests/repl-admission.test.ts b/packages/cli/tests/repl-admission.test.ts new file mode 100644 index 000000000..2e188dc45 --- /dev/null +++ b/packages/cli/tests/repl-admission.test.ts @@ -0,0 +1,62 @@ +/** + * Admission consumes a subscription its caller already created. + * + * The session's provisional-to-admitted transition hangs on a `Signal`, and a + * Signal drops whatever it sends while no subscription is active. These rows + * hold the ordering that makes the announcement survive. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createSignal, sleep, spawn } from "effection"; +import { consumeAdmissions } from "../src/repl/admission.ts"; + +describe("AD — admission survives an announcement made before the consumer runs", () => { + it("AD1: a value announced before the consumer begins is still admitted", function* () { + const changes = createSignal(); + let admitted = 0; + + // The caller subscribes first, exactly as the session does before it + // spawns the document that can announce. + const subscription = yield* changes; + // Announced while the consumer does not exist yet, let alone read. + changes.send(1); + + yield* spawn( + consumeAdmissions( + subscription, + (value) => value > 0, + () => { + admitted += 1; + }, + ), + ); + // One turn is all it takes to reach what was already queued for this + // active subscription. + yield* sleep(0); + + expect(admitted).toBe(1); + }); + + it("AD2: the shape AD1 replaced drops that value — the control for AD1", function* () { + const changes = createSignal(); + let admitted = 0; + + // A consumer that subscribes as its first act, which is what `spawn()` + // makes a turn too late. + yield* spawn(function* () { + const subscription = yield* changes; + let next = yield* subscription.next(); + while (!next.done) { + admitted += 1; + next = yield* subscription.next(); + } + }); + changes.send(1); + yield* sleep(0); + + // Dropped. This is why `consumeAdmissions` takes a subscription and not a + // stream: the losing shape cannot be written through that signature. + expect(admitted).toBe(0); + }); +}); diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index 6ce8aa909..b7c4949ca 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -1582,6 +1582,54 @@ describe("P6 — only a canonical Prompt claims a record", () => { expect(session.live).toBe(false); }); + it("P6: concurrent spawns with a direct call each retire only their own turn", function* () { + // Two canonical turns running at once, one of them on a coroutine that + // also made a direct public call. Reversed *completion* is held by the L2 + // and P5 rows above, which run the same mechanism; what this adds is a + // direct call in the middle of concurrent work. + const stub = createStub({ + direct: { deltas: ["off-books"] }, + same: { deltas: ["reply"] }, + }); + yield* useStub(stub); + yield* registerComponents([ + { + name: "DirectPrompt", + origin: "tier-854", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const stream = yield* Agent.operations.prompt("direct", {}); + const subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return "direct"; + }, + }, + ]); + const holder = execution(); + const source = [ + "", + '', + '', + "", + ].join("\n"); + const session = opened(yield* start(holder, source)); + const outcome = yield* session.join(); + + expect(outcome.ok).toBe(true); + // The direct call really happened, beside both canonical turns. + expect(stub.asked.filter((asked) => asked === "direct")).toHaveLength(1); + expect(stub.asked.filter((asked) => asked === "same")).toHaveLength(2); + // Two canonical records, and the direct call is in neither. + expect(session.model.turns).toHaveLength(2); + expect(session.model.turns.map((turn) => turn.text)).toEqual(["reply", "reply"]); + // Every live turn retired, including on the coroutine that also made a + // direct call — which had no live turn to leave behind. + expect(session.agent.turns).toEqual([]); + }); + for (const ended of ["failed", "cancelled"] as const) { it(`P6: a ${ended} canonical Prompt hands off exactly as a completed one does`, function* () { // `association` is absent for every unsuccessful turn, so this is the diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index c04906f33..367e52540 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -54,11 +54,13 @@ import { installApproveAll, installAskPermission } from "./permission.ts"; import { AgentInternal, formatLocation } from "./internal.ts"; import { serializePromptFailure } from "./errors.ts"; import type { SerializedPromptFailure } from "./errors.ts"; -import { persistPrompt, promptFailureFromRecord, promptPermissionAudit } from "./journal.ts"; +import { completedPromptAudit, persistPrompt, promptFailureFromRecord } from "./journal.ts"; import type { PromptRecord } from "./journal.ts"; import { checkpointOf } from "./checkpoint.ts"; import type { AgentPromptCheckpoint } from "./checkpoint.ts"; import type { AgentPromptAssociation, AgentPromptHandle } from "./publication.ts"; +import type { Stream } from "effection"; +import type { AgentPromptEvent } from "./agent-api.ts"; export const AGENT_PROVIDER_PROPS: PropsSchema = { type: "object", @@ -402,9 +404,11 @@ function* runPrompt( carried: Carried, ): Operation { let consumed: ConsumedTurn = { text: "" }; - // Held out here because the audit outlives the turn's own scope: a turn that - // failed still answered whatever it was asked before it did. - const permissions = promptPermissionAudit(); + // The exact stream this turn consumed, held out here because the audit + // outlives the turn's own scope: a turn that failed still answered whatever + // it was asked before it did. Holding the stream is what makes its audit + // readable — nothing else can name it. + let consumedStream: Stream | undefined; // What this turn was authored to run under, and — once the provider has been // reached — the exact value it was reached with. The record below is written // from the second, so the journal describes the turn that actually ran. @@ -414,57 +418,53 @@ function* runPrompt( // permission routing, session lock) run when this prompt finishes — not at // document teardown. consumed = yield* scoped(function* (): Operation { - // Everything this turn asks its provider happens inside the audit - // bracket, so a provider that requests permission the moment it is - // subscribed already finds this turn's ledger, and nothing outside can - // find it at all. What observes the decision is installed for the whole - // execution, outside every policy; the ledger says only which turn a - // decision it sees belongs to. - return yield* permissions.within(function* (): Operation { - const result: ConsumedTurn = { text: "" }; - // Begun inside that same bracket: this is the one moment that is both - // canonical and live, so it is the only place a host can be handed - // something that identifies this turn and nothing else. A component - // calling the public `Agent.prompt()` never reaches here, which is - // exactly why it can claim no publication later. - const publisher = yield* AgentInternal.operations.promptPublisher; - if (publisher?.begin !== undefined) { - carried.begun = yield* publisher.begin(text); - } - const stream = yield* Agent.operations.prompt(text, options); - const subscription = yield* stream; - let next = yield* subscription.next(); - while (!next.done) { - const event = next.value; - if (event.type === "started") { - result.started = true; - result.agent = event.agent; - result.sessionKey = event.session.sessionKey; - // The value the provider named as the conversation this turn ran in. - // For a configured turn that is the authentic use it was verified - // under, which is what the record is written from. - result.ranIn = event.session; - if (event.session.agentSessionId !== undefined) { - result.agentSessionId = event.session.agentSessionId; - } - } else if (event.type === "terminal") { - result.status = event.status; - if (event.stopReason !== undefined) { - result.stopReason = event.stopReason; - } - if (event.error) { - result.failure = serializePromptFailure(event.error); - } - const checkpoint = checkpointOf(event); - if (checkpoint !== undefined) { - result.checkpoint = checkpoint; - } + const result: ConsumedTurn = { text: "" }; + // Begun immediately before the provider is asked, in this turn's own + // scope: the one moment that is both canonical and live, so it is the + // only place a host can be handed something identifying this turn and + // nothing else. A component calling the public `Agent.prompt()` never + // reaches here, which is exactly why it can claim no publication later. + const publisher = yield* AgentInternal.operations.promptPublisher; + if (publisher?.begin !== undefined) { + carried.begun = yield* publisher.begin(text); + } + // Auditing is installed by the Agent middleware around this call, not + // here: the stream that comes back already carries its own ledger, and + // holding it is how this turn reads what it was allowed to do. + const stream = yield* Agent.operations.prompt(text, options); + consumedStream = stream; + const subscription = yield* stream; + let next = yield* subscription.next(); + while (!next.done) { + const event = next.value; + if (event.type === "started") { + result.started = true; + result.agent = event.agent; + result.sessionKey = event.session.sessionKey; + // The value the provider named as the conversation this turn ran in. + // For a configured turn that is the authentic use it was verified + // under, which is what the record is written from. + result.ranIn = event.session; + if (event.session.agentSessionId !== undefined) { + result.agentSessionId = event.session.agentSessionId; + } + } else if (event.type === "terminal") { + result.status = event.status; + if (event.stopReason !== undefined) { + result.stopReason = event.stopReason; + } + if (event.error) { + result.failure = serializePromptFailure(event.error); + } + const checkpoint = checkpointOf(event); + if (checkpoint !== undefined) { + result.checkpoint = checkpoint; } - next = yield* subscription.next(); } - result.text = next.value; - return result; - }); + next = yield* subscription.next(); + } + result.text = next.value; + return result; }); if (consumed.status === undefined) { consumed.failure = { message: "agent prompt stream closed without a terminal event" }; @@ -516,7 +516,10 @@ function* runPrompt( } // Written once the turn is over, with the rest of the result: a decision is // part of the account of this turn, not an event of its own. - const answered = permissions.completed(); + // Read from the exact stream this turn consumed. A turn that never got one + // was allowed nothing, because it never asked. + const answered = + (consumedStream === undefined ? undefined : yield* completedPromptAudit(consumedStream)) ?? []; if (answered.length > 0) { record.permissions = answered; } diff --git a/packages/core/src/agent/journal.ts b/packages/core/src/agent/journal.ts index fcf257b20..44aed8831 100644 --- a/packages/core/src/agent/journal.ts +++ b/packages/core/src/agent/journal.ts @@ -56,9 +56,10 @@ import type { Workflow, } from "@executablemd/durable-streams"; import { createContext } from "effection"; -import type { Operation } from "effection"; +import type { Operation, Stream } from "effection"; import { Agent } from "./agent-api.ts"; import type { + AgentPromptEvent, PermissionOption, PermissionOutcome, PermissionRequest, @@ -181,16 +182,8 @@ interface PromptAuditLedger { /** What one turn observed of the permission requests made while it ran. */ export interface PromptPermissionAudit { - /** - * Run this Prompt's provider work with this ledger in place, and only it. - * - * A bracket rather than a marker: the ledger exists for exactly as long as - * `body` runs, descendants inherit it, and it is restored when `body` - * finishes however it finishes — returning, raising or being cancelled. There - * is no way to install the ledger without naming the work it governs, so it - * cannot be left behind for sibling work to inherit. - */ - within(body: () => Operation): Operation; + /** Where a request made beneath this turn's stream records itself. */ + readonly ledger: PromptAuditLedger; /** The requests that were decided, in the order they arrived. */ completed(): readonly PromptPermission[]; } @@ -209,8 +202,23 @@ export interface PromptPermissionAudit { * completed. */ export function* observePermissionDecisions(): Operation { + // Owned by this installation, not by the module: one execution's audits are + // not another's, and a process-lifetime registry would outlive every run. + yield* PromptAudits.set(new WeakMap()); yield* Agent.around( { + *prompt([text, options], next) { + // One ledger for this exact invocation, made here rather than by + // whoever called: nothing outside this middleware creates, places or + // brackets an audit, so no caller can get the lifetime wrong. + const audit = promptPermissionAudit(); + const wrapped = audited(yield* next(text, options), audit.ledger); + // The association is the wrapped stream itself. Only whoever holds the + // exact stream this invocation returned can read its audit, which is + // what makes a canonical `` able to and a bystander not. + (yield* PromptAudits.get())?.set(wrapped, audit); + return wrapped; + }, *requestPermission([request], next) { const ledger = yield* PromptAudit.get(); // Reserved on the way in, because a caller is free to reuse or rewrite the @@ -225,6 +233,57 @@ export function* observePermissionDecisions(): Operation { ); } +/** + * Each wrapped stream's own audit, readable only by whoever holds the stream. + * + * Private to this module and scoped to one Agent installation. Nothing outside + * can reach the map, and holding a stream is the only way to name an entry. + */ +const PromptAudits = + createContext, PromptPermissionAudit>>( + "xmd.agent.prompt-audits", + ); + +/** + * What the turn consumed through this exact stream was allowed to do. + * + * Undefined for a stream this middleware did not wrap, which is every stream a + * caller made itself. + */ +export function* completedPromptAudit( + stream: Stream, +): Operation { + const audits = yield* PromptAudits.get(); + return audits?.get(stream)?.completed(); +} + +/** + * Wrap a cold provider stream so the ledger is in place for every event. + * + * The stream is cold, so subscribing is where the turn actually starts, and a + * provider may raise its first request the moment it is subscribed. Both the + * subscription and every `next()` therefore run beneath the context, and it is + * restored whichever way each of them finishes — returning, raising, or being + * cancelled — because that is what `with` does. + */ +function audited( + stream: Stream, + ledger: PromptAuditLedger, +): Stream { + return { + *[Symbol.iterator]() { + // A `Stream` is itself the operation that subscribes, so this is that + // subscription run beneath the ledger — not a copy of it. + const subscription = yield* PromptAudit.with(ledger, () => stream); + return { + *next() { + return yield* PromptAudit.with(ledger, () => subscription.next()); + }, + }; + }, + }; +} + /** * The audit one prompt turn keeps of the permission requests made while it ran. * @@ -256,9 +315,7 @@ export function promptPermissionAudit(): PromptPermissionAudit { }, }; return { - within(body: () => Operation): Operation { - return PromptAudit.with(ledger, () => body()); - }, + ledger, completed() { return drafts.flatMap((draft) => { if (draft.outcome === undefined) { diff --git a/packages/core/tests/agent-function-components.test.ts b/packages/core/tests/agent-function-components.test.ts index 5c45dbdd8..44c533211 100644 --- a/packages/core/tests/agent-function-components.test.ts +++ b/packages/core/tests/agent-function-components.test.ts @@ -34,7 +34,7 @@ import type { SessionConfiguration, } from "../src/agent/agent-api.ts"; import { AgentPromptError } from "../src/agent/errors.ts"; -import { executeInstalled } from "../host.ts"; +import { executeInstalled, useAgentPromptPublisher } from "../host.ts"; import { agentIdentityComponents } from "../src/agent/components.ts"; import { installAgentComponents } from "../src/agent/components.ts"; import { registerComponents } from "../src/components/registration.ts"; @@ -1701,3 +1701,63 @@ describe("Tier AF — the permission audit one turn retains", () => { }); }); }); + +describe("AFP — a publisher that never appends retains nothing", () => { + beforeAll(() => useTempFileCompiler()); + + it("AFP0: the same harness with a publisher that appends succeeds and records", function* () { + // The other side of the comparison. Without it, AFP1 and AFP2 would pass + // just as well if installing any publisher at all broke the run. + const { result, events } = yield* runDoc('\n', { + *handler() { + yield* useAgentPromptPublisher({ + *publish(publication) { + yield* publication.append(); + }, + }); + }, + }); + + expect(result.ok).toBe(true); + expect( + events.filter((event) => event.type === "yield" && event.description.type === "agent_prompt"), + ).toHaveLength(1); + }); + + it("AFP1: a raising publisher leaves no agent_prompt and fails the execution", function* () { + const { result, events } = yield* runDoc('\n', { + *handler() { + yield* useAgentPromptPublisher({ + *publish() { + throw new Error("this publisher could not commit"); + }, + }); + }, + }); + + // Failed through the durability path rather than carrying on from a result + // no journal holds. + expect(result.ok).toBe(false); + // And nothing durable describes the turn, so nothing can be presented as + // retained. + expect( + events.filter((event) => event.type === "yield" && event.description.type === "agent_prompt"), + ).toEqual([]); + }); + + it("AFP2: a publisher that returns without appending is the same failure", function* () { + const { result, events } = yield* runDoc('\n', { + *handler() { + yield* useAgentPromptPublisher({ + // Returns cleanly, appends nothing. Silence is not publication. + *publish() {}, + }); + }, + }); + + expect(result.ok).toBe(false); + expect( + events.filter((event) => event.type === "yield" && event.description.type === "agent_prompt"), + ).toEqual([]); + }); +}); From 084397e1dbca25cbbd35993f237184b8d30534d0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Tue, 29 Sep 2026 21:30:47 -0400 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=90=9B=20Take=20a=20live=20turn=20dow?= =?UTF-8?q?n=20when=20its=20publication=20fails,=20and=20read=20handles=20?= =?UTF-8?q?by=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections to the canonical-correlation work. A publication that fails retained nothing, but the live turn it began stayed mounted — a terminal overlay waiting for a record that was never going to arrive. The failure path now retires exactly that turn and announces the removal before the failure travels on. The handle is read back by lookup instead of by assertion. `begin()` mints an opaque object token and remembers what it stood for; `publish()` narrows at runtime and asks the map. A value this owner did not mint is simply not a key, so nothing has to claim it is a turn. The two `as const` assertions in the new rows are gone too. Admission filtering composes with the stream API. `filter()` is applied in the enclosing scope before either subscription exists, so `consumeAdmissions()` is a generic drain of an already-active subscription and cannot know or care what admits. `@executablemd/cli` declares `@effectionx/stream-helpers`, which the lockfile already held; the delta is one line. The published contracts now describe what is actually there: `publication.ts` documents a publisher seen at two moments rather than only at completion, `components.ts` and both specs describe middleware that owns each invocation's ledger and wraps its stream, and the Prompt-owned-ledger and FIFO-correlation wording is gone. Evidence: the refused-publication row is REPL-level and drives a journal that refuses the record, proving the turn is neither retained nor left mounted. It reddens when the failure path stops retiring the turn. --- architecture.md | 10 ++-- deno.lock | 1 + packages/cli/deno.json | 1 + packages/cli/src/repl/admission.ts | 11 +++-- packages/cli/src/repl/agent.ts | 46 ++++++++++++++----- packages/cli/src/repl/session.ts | 17 +++++-- packages/cli/tests/repl-admission.test.ts | 22 +++++---- .../cli/tests/repl-agent-execution.test.ts | 40 +++++++++++++++- packages/core/src/agent/components.ts | 11 +++-- packages/core/src/agent/publication.ts | 17 +++++-- specs/acp-client-spec.md | 16 ++++--- 11 files changed, 142 insertions(+), 50 deletions(-) diff --git a/architecture.md b/architecture.md index 3dda5958c..e21e294fd 100644 --- a/architecture.md +++ b/architecture.md @@ -2156,10 +2156,12 @@ What a turn was allowed to do is retained beside what it said, and the two are recorded by different things. A permission policy decides every request inside its scope without deferring outward, so nothing installed inside one — and nothing installed inside the turn, below a policy a host put around the execution -— can see the decision it makes. One observer sits with the Agent installation -itself, outside every policy installed after it; each Prompt places a private -ledger where its own requests will find it, and a request inherits that ledger -through the scope its provider stream is consumed in. The observer reserves a +— can see the decision it makes. The Agent middleware installed with the +Agent itself, outside every policy installed after it, owns both sides of this. +Each `Agent.prompt()` call is given a private ledger and the cold stream it +returns is wrapped, so every event that call produces is read with that ledger +in place and a request raised while it is read inherits that ledger and no +other. A component installs nothing to be audited. The observer reserves a place, delegates once, and copies the outcome that comes back into that place, so concurrent turns keep their own audits and two overlapping requests keep the order they were asked in. A place nobody completed — a policy that raised, a turn diff --git a/deno.lock b/deno.lock index da4b608c9..0135003e9 100644 --- a/deno.lock +++ b/deno.lock @@ -4052,6 +4052,7 @@ "packages/cli": { "dependencies": [ "npm:@bomb.sh/tty@0.9.0", + "npm:@effectionx/stream-helpers@0.8.3", "npm:@standard-schema/spec@1", "npm:zod@^4.3.6" ], diff --git a/packages/cli/deno.json b/packages/cli/deno.json index 5594c615c..cade63473 100644 --- a/packages/cli/deno.json +++ b/packages/cli/deno.json @@ -5,6 +5,7 @@ "exports": "./src/deno.ts", "imports": { "@bomb.sh/tty": "npm:@bomb.sh/tty@0.9.0", + "@effectionx/stream-helpers": "npm:@effectionx/stream-helpers@0.8.3", "@standard-schema/spec": "npm:@standard-schema/spec@^1.0.0", "zod": "npm:zod@^4.3.6" } diff --git a/packages/cli/src/repl/admission.ts b/packages/cli/src/repl/admission.ts index 7da624b0a..89b78ee92 100644 --- a/packages/cli/src/repl/admission.ts +++ b/packages/cli/src/repl/admission.ts @@ -19,7 +19,11 @@ import type { Operation, Subscription } from "effection"; /** - * Iterate an already-active subscription, admitting on every value that counts. + * Drain an already-active subscription, admitting once per value it carries. + * + * Generic on purpose: what counts as admitting work is decided by filtering + * the stream before it is subscribed, which is the caller's business and + * happens in the caller's scope. This only reads what it was handed. * * Takes the subscription rather than the stream, so there is no way to write * the consumer that creates its own: the race this exists to prevent cannot be @@ -27,15 +31,12 @@ import type { Operation, Subscription } from "effection"; */ export function consumeAdmissions( subscription: Subscription, - admits: (value: T) => boolean, admit: () => void, ): () => Operation { return function* (): Operation { let next = yield* subscription.next(); while (!next.done) { - if (admits(next.value)) { - admit(); - } + admit(); next = yield* subscription.next(); } }; diff --git a/packages/cli/src/repl/agent.ts b/packages/cli/src/repl/agent.ts index 5dc4100ea..0f9a3110a 100644 --- a/packages/cli/src/repl/agent.ts +++ b/packages/cli/src/repl/agent.ts @@ -292,8 +292,14 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { const requests: LiveRequest[] = []; /** The live turn core began in a scope, until that scope's prompt claims it. */ const begun = new Map(); - /** Every live turn this owner made, so a handle from elsewhere is not one. */ - const ours = new WeakSet(); + /** + * The live turn each handle this owner minted stands for. + * + * The handle is an object of this owner's own making, so a value from + * anywhere else simply is not a key here — which is how a handle is read + * back without asserting anything about what it is. + */ + const minted = new WeakMap(); /** * The canonical publication appending right now on each coroutine. * @@ -347,6 +353,14 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { return failure; } + /** Take one live turn down, leaving the announcement to the caller. */ + function retire(turn: LiveTurn): void { + const at = turns.indexOf(turn); + if (at >= 0) { + turns.splice(at, 1); + } + } + function queued(coroutine: string, prompt: string): LiveTurn { const turn: LiveTurn = { key: allocate("turn"), @@ -362,7 +376,6 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { failure: undefined, }; turns.push(turn); - ours.add(turn); announce(); return turn; } @@ -535,14 +548,18 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { // which live turn that record ended. const turn = queued(yield* currentCoroutine(), input); begun.set(yield* useScope(), turn); - return turn; + // An opaque token rather than the turn itself: core carries it back + // untouched, and only this map can say what it stood for. + const handle: object = {}; + minted.set(handle, turn); + return handle; }, *publish(publication: AgentPromptPublication): Operation { const handle = publication.begun; - // A handle this owner did not make identifies nothing here: another + // A handle this owner did not mint is not a key in this map: another // host's publisher, or a turn from an execution this session never ran. - const turn = - handle !== undefined && ours.has(handle as LiveTurn) ? (handle as LiveTurn) : undefined; + // Read back by lookup, never by asserting what the value is. + const turn = typeof handle === "object" && handle !== null ? minted.get(handle) : undefined; const where = turn?.coroutine; if (turn !== undefined && where !== undefined) { publishing.set(where, turn); @@ -551,6 +568,16 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { // The single durable handoff. `consume()` runs inside this append, in // the caller's one transition, and removes exactly this turn. yield* publication.append(); + } catch (error) { + // Nothing was retained, so nothing may still be shown as though it is + // about to be. The turn this publication began is taken down and the + // removal announced before the failure travels on — otherwise a + // terminal overlay outlives the record it was waiting for. + if (turn !== undefined) { + retire(turn); + announce(); + } + throw error; } finally { if (where !== undefined) { publishing.delete(where); @@ -613,10 +640,7 @@ export function useReplAgent(mode: PermissionMode): ReplAgentKernel { ), ); } - const at = turns.indexOf(turn); - if (at >= 0) { - turns.splice(at, 1); - } + retire(turn); project(); }, announce, diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index f951ecc29..fb2c35b09 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -72,6 +72,7 @@ import { import type { DurableEvent } from "@executablemd/durable-streams"; import { useReplAgent } from "./agent.ts"; +import { filter } from "@effectionx/stream-helpers"; import { consumeAdmissions } from "./admission.ts"; import type { ReplAgentAuthority, ReplAgentReading } from "./agent.ts"; import { useReplElicitation } from "./elicitation.ts"; @@ -439,8 +440,15 @@ function* start( // (`docs/agents.md`, "Subscription readiness across `spawn()`"). Spawning // the consumer earlier would not be equivalent: what must precede the // document is the subscription, not the task that reads it. - const agentReadings = yield* agent.changes; - const questions = yield* elicitation.changes; + // Filtered before either is subscribed, so what reaches the drain is + // already only the announcements that admit. The composition belongs here, + // in the scope that owns the subscription. + const agentReadings = yield* filter(function* (reading: ReplAgentReading) { + return reading.turns.length > 0; + })(agent.changes); + const questions = yield* filter(function* (question: ReplQuestion | undefined) { + return question !== undefined; + })(elicitation.changes); const document: Task> = yield* spawn(function* () { const outcome = yield* runExecution(); @@ -512,9 +520,8 @@ function* start( // A queued Agent turn is work beyond the retained prefix, exactly as a new // record or a question is: replay that reached one is past what the history // held, so the session is admitted rather than still provisional. - yield* spawn(consumeAdmissions(agentReadings, (reading) => reading.turns.length > 0, admit)); - - yield* spawn(consumeAdmissions(questions, (question) => question !== undefined, admit)); + yield* spawn(consumeAdmissions(agentReadings, admit)); + yield* spawn(consumeAdmissions(questions, admit)); // Held open deliberately. This body owns the observer and both tasks, and // finishing it would halt them — so it lasts as long as the scope does, and diff --git a/packages/cli/tests/repl-admission.test.ts b/packages/cli/tests/repl-admission.test.ts index 2e188dc45..68c98ba96 100644 --- a/packages/cli/tests/repl-admission.test.ts +++ b/packages/cli/tests/repl-admission.test.ts @@ -9,6 +9,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { createSignal, sleep, spawn } from "effection"; +import { filter } from "@effectionx/stream-helpers"; import { consumeAdmissions } from "../src/repl/admission.ts"; describe("AD — admission survives an announcement made before the consumer runs", () => { @@ -18,24 +19,29 @@ describe("AD — admission survives an announcement made before the consumer run // The caller subscribes first, exactly as the session does before it // spawns the document that can announce. - const subscription = yield* changes; + // Filtered before subscribing, exactly as the session composes it, so the + // drain itself stays generic. + const subscription = yield* filter(function* (value: number) { + return value > 0; + })(changes); // Announced while the consumer does not exist yet, let alone read. changes.send(1); yield* spawn( - consumeAdmissions( - subscription, - (value) => value > 0, - () => { - admitted += 1; - }, - ), + consumeAdmissions(subscription, () => { + admitted += 1; + }), ); // One turn is all it takes to reach what was already queued for this // active subscription. yield* sleep(0); expect(admitted).toBe(1); + + // And a value the filter rejects never reaches the drain at all. + changes.send(0); + yield* sleep(0); + expect(admitted).toBe(1); }); it("AD2: the shape AD1 replaced drops that value — the control for AD1", function* () { diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index b7c4949ca..3b9a47532 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -448,6 +448,25 @@ function execution(events: readonly DurableEvent[] = []): ReplExecution { return { id: "agent-kernel", stream: new InMemoryStream([...events]) }; } +/** + * An execution whose journal refuses to record an Agent turn. + * + * The one way a canonical publication fails after the turn has already run: + * everything the provider was going to say has been said, the overlay is + * terminal, and then nothing retains it. + */ +function refusingExecution(): ReplExecution { + const stream = new InMemoryStream(); + const appended = stream.append.bind(stream); + stream.append = function* (event: DurableEvent): Operation { + if (event.type === "yield" && event.description.type === "agent_prompt") { + throw new Error("this journal refused the record"); + } + yield* appended(event); + }; + return { id: "agent-kernel", stream }; +} + function installations(): readonly ExecutionInstallation[] { return [{ evaluation: ordinaryEvaluationProfile() }, { components: agentIdentityComponents() }]; } @@ -1630,7 +1649,26 @@ describe("P6 — only a canonical Prompt claims a record", () => { expect(session.agent.turns).toEqual([]); }); - for (const ended of ["failed", "cancelled"] as const) { + it("P6: a refused publication leaves the turn neither retained nor mounted", function* () { + const stub = createStub({ one: { deltas: ["reply"] } }); + yield* useStub(stub); + const holder = refusingExecution(); + const session = opened(yield* start(holder, ONE_PROMPT)); + const outcome = yield* session.join(); + + // The provider really ran and really finished, so the overlay was + // terminal at the moment publication was refused. + expect(stub.asked).toEqual(["one"]); + // Nothing retained it. + expect(outcome.ok).toBe(false); + expect(session.model.turns).toEqual([]); + // And nothing is still mounted waiting for a record that will never come. + expect(session.agent.turns).toEqual([]); + expect(session.live).toBe(false); + }); + + const endings: readonly ("failed" | "cancelled")[] = ["failed", "cancelled"]; + for (const ended of endings) { it(`P6: a ${ended} canonical Prompt hands off exactly as a completed one does`, function* () { // `association` is absent for every unsuccessful turn, so this is the // case where nothing but the handle can say which live turn ended. diff --git a/packages/core/src/agent/components.ts b/packages/core/src/agent/components.ts index 0f444bd09..8c87d4712 100644 --- a/packages/core/src/agent/components.ts +++ b/packages/core/src/agent/components.ts @@ -233,13 +233,16 @@ export function* installAgentComponents(options?: AgentComponentsOptions): Opera yield* useAgentComponents(); - // One observer for this installation, outside every policy installed after it. + // Auditing for this installation, outside every policy installed after it. // A policy decides without delegating — that is what deciding means — so an // observer anywhere inside one never sees the decision it makes. Installed // here, in the Agent installation itself, it wraps the host's own policy and - // the REPL's authority alike. Each Prompt puts its own ledger where its - // requests will find it, and this copies the outcome into the place the - // request reserved on the way in. + // the REPL's authority alike. + // + // It owns both sides. Each `Agent.prompt()` call gets a private ledger and a + // wrapped stream, so every event that call produces is read with that + // ledger in place; a request raised while it is read finds that ledger and + // no other. Nothing a component does installs, places or brackets an audit. yield* observePermissionDecisions(); const rootProvider = options?.rootProvider; diff --git a/packages/core/src/agent/publication.ts b/packages/core/src/agent/publication.ts index da5f5039e..88d49543a 100644 --- a/packages/core/src/agent/publication.ts +++ b/packages/core/src/agent/publication.ts @@ -1,6 +1,6 @@ /** - * Where a completed Prompt's durable result is published, for a host that - * retains something beside it. + * Where a Prompt's durable result is published, for a host that retains + * something beside it. * * An ordinary `xmd run` has no publisher, and publishes exactly as it always * did: the `agent_prompt` event is appended by the durable machinery and @@ -11,9 +11,16 @@ * or not at all, so no association can survive a Prompt that was never * journaled and no journaled Prompt can be left half-described. * - * The Prompt itself runs *before* any of this. A publisher receives a Prompt - * that has already finished talking to its provider, so nothing a host does - * here holds a database open across a conversation. + * A publisher sees one turn at two moments, and only these two. `begin()` is + * called before the provider is asked, so a host that shows live work can + * create it there; `publish()` is called once the turn has finished talking to + * its provider, so nothing a host does at publication holds a database open + * across a conversation. What `begin()` returned comes back on the + * publication, and that is the only thing tying the two together — core never + * reads it. + * + * `begin()` is optional. A publisher that declares none is called exactly as + * it always was, and its publications carry an undefined handle. * * Installing one is a host act and reads as one at the import: this is reached * through `@executablemd/core/host`, and the private Api it seeds is exported diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index b27578a5f..12a0e8872 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -540,13 +540,15 @@ Observing a request changes no decision, and what observes one is deliberately somewhere else from what answers it. A policy decides every request inside its scope and never defers outward, so an observer installed inside a policy — or inside the turn, below a policy a host installed around the execution — sees -nothing of the decision it exists to record. One observer is therefore installed -with the Agent installation itself, outside every policy installed after it, and -each turn puts its own private ledger where a request it makes will find it. A -request raised while that turn's provider stream is being consumed inherits that -turn's ledger through its own scope, so two turns asking at the same time keep -their audits apart without anything correlating by recency, prompt text, agent, -conversation or the order decisions settled in. +nothing of the decision it exists to record. Auditing is therefore installed with the +Agent itself, outside every policy installed after it, and it owns both sides: +each `Agent.prompt()` call is given its own private ledger, and the cold stream +that call returns is wrapped so every event is read with that ledger in place. A +request raised while a turn's stream is being read inherits that turn's ledger, +so two turns asking at the same time keep their audits apart without anything +correlating by recency, prompt text, agent, conversation or the order decisions +settled in. Nothing a turn does installs its own audit, and a turn reads what it +was allowed to do from the exact stream it consumed. The ledger is a destination and grants no authority: holding it says which turn a decision belongs to, never what the decision is. The observer reserves the From dc65bff7da339bcbacaf08d7a6b0df517776604c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 30 Sep 2026 06:34:12 -0400 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9A=A1=20Measure=20test=20weights=20at?= =?UTF-8?q?=20084397e1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the runner by **Measure test weights** run 36655497896, on exactly `084397e1dbca25cbbd35993f237184b8d30534d0`, attempt 1, `ubuntu-latest`. The artifact is committed byte for byte; no millisecond is edited. The corpus moved: this slice adds `packages/cli/tests/repl-admission.test.ts` and new rows to two existing files, and the three earlier measurements were cancelled because each described an architecture that no longer exists. Measured floors against the installed shard counts: | runtime | files | sum (ms) | floor | installed | | --- | --- | --- | --- | --- | | deno | 428 | 3917295 | 14 | 15 | | node | 333 | 2049123 | 7 | 10 | | bun | 332 | 1117648 | 4 | 5 | Every floor is at or below its installed count, and no shard missed its window, so no count changes. A floor is not by itself authorization to change one — only the documented five-run rule is. --- test-weights.json | 2142 +++++++++++++++++++++++---------------------- 1 file changed, 1074 insertions(+), 1068 deletions(-) diff --git a/test-weights.json b/test-weights.json index 1874ae498..4f5f59b34 100644 --- a/test-weights.json +++ b/test-weights.json @@ -1,1107 +1,1113 @@ { "version": 1, "source": { - "commit": "1d317942eb24810816c172998c083033f5d1165b", - "runUrl": "https://github.com/taras/executable.md/actions/runs/36577302779", + "commit": "084397e1dbca25cbbd35993f237184b8d30534d0", + "runUrl": "https://github.com/taras/executable.md/actions/runs/36655497896", "attempt": 1, "runner": "ubuntu-latest", "deno": "deno 2.9.5 (stable, release, x86_64-unknown-linux-gnu)", - "node": "v22.23.2", + "node": "v22.23.3", "bun": "1.4.0" }, "runtimes": { "deno": { - "packages/acp/tests/acceptance.test.ts": 5052, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 8265, - "packages/acp/tests/acpx-materialization.test.ts": 8084, - "packages/acp/tests/acpx-transient-env.test.ts": 1772, - "packages/acp/tests/adapter-materialization.test.ts": 76537, - "packages/acp/tests/adapter-protocol.test.ts": 60468, - "packages/acp/tests/codex-surfaces.test.ts": 1819, - "packages/acp/tests/loaded-copy.test.ts": 4625, - "packages/acp/tests/native-launch.test.ts": 5333, - "packages/acp/tests/permission-bridge.test.ts": 4193, - "packages/acp/tests/provider.test.ts": 5345, - "packages/acp/tests/serial-queue.test.ts": 1527, - "packages/acp/tests/session-key.test.ts": 4017, - "packages/acp/tests/session-route.test.ts": 4255, - "packages/acp/tests/terminal-screen.test.ts": 1693, - "packages/cli/tests/agent-adapters.test.ts": 5698, - "packages/cli/tests/agent-cli.test.ts": 37667, - "packages/cli/tests/agent-config.test.ts": 3713, - "packages/cli/tests/agent-options-cli.test.ts": 15490, - "packages/cli/tests/agent-options.test.ts": 3668, - "packages/cli/tests/agent-session-coordinator.test.ts": 4387, - "packages/cli/tests/cli-help.test.ts": 20250, - "packages/cli/tests/command.test.ts": 10303, - "packages/cli/tests/compiled-upgrade.test.ts": 10775, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 8964, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 119858, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 8472, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 12290, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 10519, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 15587, - "packages/cli/tests/evaluate-component.test.ts": 11840, - "packages/cli/tests/evaluate-workflow.test.ts": 5549, - "packages/cli/tests/fetch-cli.test.ts": 3840, - "packages/cli/tests/file-stream.test.ts": 1813, - "packages/cli/tests/github-zero-effect.test.ts": 7355, - "packages/cli/tests/inline-cli.test.ts": 33157, - "packages/cli/tests/launch-timeout.test.ts": 11665, - "packages/cli/tests/packaged-document.test.ts": 5100, - "packages/cli/tests/plan-args.test.ts": 1604, - "packages/cli/tests/plan-cli.test.ts": 99258, - "packages/cli/tests/plan-command-document.test.ts": 10015, - "packages/cli/tests/plan-component.test.ts": 12327, - "packages/cli/tests/plan-host-acts.test.ts": 5534, - "packages/cli/tests/plan.test.ts": 16548, - "packages/cli/tests/plugin-cli.test.ts": 41189, - "packages/cli/tests/plugin-host.test.ts": 6252, - "packages/cli/tests/plugin-modules.test.ts": 1573, - "packages/cli/tests/plugin-selection.test.ts": 1563, - "packages/cli/tests/process-retention.test.ts": 5369, - "packages/cli/tests/props-cli.test.ts": 30488, - "packages/cli/tests/props-schema.test.ts": 4164, - "packages/cli/tests/props-sources.test.ts": 2281, - "packages/cli/tests/pull-request-read-fork.test.ts": 7124, - "packages/cli/tests/repl-boundaries.test.ts": 7465, - "packages/cli/tests/repl-composition.test.ts": 2041, - "packages/cli/tests/repl-execution.test.ts": 5465, - "packages/cli/tests/repl-journey.test.ts": 20626, - "packages/cli/tests/repl-model.test.ts": 5262, - "packages/cli/tests/repl-route.test.ts": 4795, - "packages/cli/tests/repl-terminal.test.ts": 2918, - "packages/cli/tests/run-composition-deno.test.ts": 8040, - "packages/cli/tests/run-composition-nested.test.ts": 21027, - "packages/cli/tests/run-composition.test.ts": 7115, - "packages/cli/tests/run-deadline.test.ts": 8779, - "packages/cli/tests/run-profile.test.ts": 4429, - "packages/cli/tests/run-timeouts.test.ts": 13312, - "packages/cli/tests/secret-detection-cli.test.ts": 19647, - "packages/cli/tests/service-document.test.ts": 5296, - "packages/cli/tests/service-host.test.ts": 5176, - "packages/cli/tests/session-launch-cli.test.ts": 10798, - "packages/cli/tests/stdin-cli.test.ts": 37368, - "packages/cli/tests/stdout-delivery.test.ts": 1550, - "packages/cli/tests/syntax-cli.test.ts": 36390, - "packages/cli/tests/targets-cli.test.ts": 28754, - "packages/cli/tests/test-root-profile.test.ts": 7297, - "packages/cli/tests/test-target.test.ts": 37254, - "packages/cli/tests/testing-activation.test.ts": 4614, - "packages/cli/tests/testing-execution-host.test.ts": 57092, - "packages/cli/tests/upgrade-cli.test.ts": 21098, - "packages/cli/tests/upgrade-command-document.test.ts": 7460, - "packages/cli/tests/upgrade-output.test.ts": 4847, - "packages/cli/tests/value-root.test.ts": 17921, - "packages/cli/tests/verbose-component.test.ts": 5102, - "packages/cli/tests/workflow-agent-sessions.test.ts": 20280, - "packages/cli/tests/workflow-agent.test.ts": 16266, - "packages/cli/tests/workflow-cli.test.ts": 109626, - "packages/cli/tests/workflow-crash.test.ts": 81308, - "packages/cli/tests/workflow-declaration.test.ts": 4615, - "packages/cli/tests/workflow-export-publication.test.ts": 17473, - "packages/cli/tests/workflow-fetch.test.ts": 8201, - "packages/cli/tests/workflow-fork.test.ts": 85080, - "packages/cli/tests/workflow-host.test.ts": 14668, - "packages/cli/tests/workflow-inspection.test.ts": 115910, - "packages/cli/tests/workflow-installation.test.ts": 6162, - "packages/cli/tests/workflow-lifecycle-control.test.ts": 20775, - "packages/cli/tests/workflow-retention.test.ts": 23092, - "packages/cli/tests/workflow-suspension.test.ts": 8136, - "packages/code-review-agent/tests/doctor.test.ts": 1533, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 1611, - "packages/code-review-agent/tests/parse-diff.test.ts": 1601, - "packages/code-review-agent/tests/parse-doctor.test.ts": 4012, - "packages/code-review-agent/tests/parse-oxlint.test.ts": 1540, - "packages/code-review-agent/tests/policy.test.ts": 1551, - "packages/core/tests/agent-api.test.ts": 1567, - "packages/core/tests/agent-components.test.ts": 4627, - "packages/core/tests/agent-function-components.test.ts": 6345, - "packages/core/tests/agent-session-launch.test.ts": 6716, - "packages/core/tests/agent-session-placement.test.ts": 1612, - "packages/core/tests/agent-session-use-loaded-copy.test.ts": 1674, - "packages/core/tests/agent-session-use.test.ts": 1583, - "packages/core/tests/agent-stack.test.ts": 4198, - "packages/core/tests/all.test.ts": 5454, - "packages/core/tests/answer-identity.test.ts": 3695, - "packages/core/tests/answers-component.test.ts": 4788, - "packages/core/tests/answers-expansion-recursion.test.ts": 3892, - "packages/core/tests/capture-errors.test.ts": 4191, - "packages/core/tests/capture-props.test.ts": 3892, - "packages/core/tests/cli-journal.test.ts": 12094, - "packages/core/tests/code-block-component.test.ts": 5375, - "packages/core/tests/compiler-boundary.test.ts": 3850, - "packages/core/tests/component-api.test.ts": 3541, - "packages/core/tests/component-registration.test.ts": 4641, - "packages/core/tests/component-returns.test.ts": 5098, - "packages/core/tests/component-schema-conformance.test.ts": 3949, - "packages/core/tests/config-api.test.ts": 4047, - "packages/core/tests/construct-error-observation.test.ts": 4317, - "packages/core/tests/contextual-cwd.test.ts": 5151, - "packages/core/tests/daemon-integration.test.ts": 4696, - "packages/core/tests/daemon.test.ts": 3692, - "packages/core/tests/declared-markdown-component.test.ts": 5313, - "packages/core/tests/document-output-api.test.ts": 1535, - "packages/core/tests/document-target-execution.test.ts": 6755, - "packages/core/tests/document-targets.test.ts": 4030, - "packages/core/tests/document-validation.test.ts": 5348, - "packages/core/tests/documentation-index.test.ts": 3766, - "packages/core/tests/each.test.ts": 4056, - "packages/core/tests/elicit-component.test.ts": 4571, - "packages/core/tests/ephemeral-service.test.ts": 4359, - "packages/core/tests/eval-bindings.test.ts": 3914, - "packages/core/tests/eval-context.test.ts": 2152, - "packages/core/tests/eval-durable.test.ts": 3942, - "packages/core/tests/eval-error-mode.test.ts": 4324, - "packages/core/tests/eval-interpolate.test.ts": 1501, - "packages/core/tests/eval-middleware.test.ts": 3588, - "packages/core/tests/eval-persist.test.ts": 4073, - "packages/core/tests/eval-return.test.ts": 3935, - "packages/core/tests/eval-scope.test.ts": 3898, - "packages/core/tests/eval-timeout.test.ts": 4012, - "packages/core/tests/eval-transform.test.ts": 3660, - "packages/core/tests/evaluate-component.test.ts": 5827, - "packages/core/tests/evaluate-loaded-copy.test.ts": 3994, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 4113, - "packages/core/tests/evaluation-profile.test.ts": 3779, - "packages/core/tests/exec-timeout.test.ts": 4022, - "packages/core/tests/execute.test.ts": 4458, - "packages/core/tests/execution-protocol.test.ts": 5301, - "packages/core/tests/expand.test.ts": 4673, - "packages/core/tests/expansion-identity.test.ts": 4548, - "packages/core/tests/expansion-metadata.test.ts": 3861, - "packages/core/tests/expression-props.test.ts": 4437, - "packages/core/tests/fail-component.test.ts": 4195, - "packages/core/tests/failure-printing.test.ts": 4156, - "packages/core/tests/fatal-cause.test.ts": 3759, - "packages/core/tests/fetch-component.test.ts": 5241, - "packages/core/tests/file-component.test.ts": 5891, - "packages/core/tests/file-delete-component.test.ts": 4132, - "packages/core/tests/files-fatal.test.ts": 4879, - "packages/core/tests/foreground-exec.test.ts": 4978, - "packages/core/tests/frontmatter.test.ts": 3609, - "packages/core/tests/function-components.test.ts": 4564, - "packages/core/tests/generated-composition.test.ts": 4502, - "packages/core/tests/generated-xmd.test.ts": 5865, - "packages/core/tests/glob-api.test.ts": 1870, - "packages/core/tests/glob-component.test.ts": 4612, - "packages/core/tests/guarded-journal.test.ts": 3953, - "packages/core/tests/has-content.test.ts": 3774, - "packages/core/tests/heal.test.ts": 3735, - "packages/core/tests/if.test.ts": 4420, - "packages/core/tests/inline-root.test.ts": 3999, - "packages/core/tests/invocation-failures.test.ts": 3708, - "packages/core/tests/invocation-identity.test.ts": 4385, - "packages/core/tests/invocation-scope.test.ts": 4060, - "packages/core/tests/journal-source-position.test.ts": 4210, - "packages/core/tests/json-component.test.ts": 4198, - "packages/core/tests/json.test.ts": 3686, - "packages/core/tests/let.test.ts": 4053, - "packages/core/tests/loaded-copy-files.test.ts": 3954, - "packages/core/tests/loop.test.ts": 4890, - "packages/core/tests/named-slots.test.ts": 4735, - "packages/core/tests/output-error-mode.test.ts": 5015, - "packages/core/tests/output-normalize.test.ts": 1529, - "packages/core/tests/output-terminal.test.ts": 1659, - "packages/core/tests/parse-components.test.ts": 4034, - "packages/core/tests/plan-response.test.ts": 1561, - "packages/core/tests/plugin-api.test.ts": 3606, - "packages/core/tests/plugin.test.ts": 3795, - "packages/core/tests/props-binding.test.ts": 4064, - "packages/core/tests/protected-content.test.ts": 3574, - "packages/core/tests/registered-printed-errors.test.ts": 3690, - "packages/core/tests/replay-stream.test.ts": 1506, - "packages/core/tests/retain.test.ts": 3951, - "packages/core/tests/root-composition.test.ts": 4078, - "packages/core/tests/root-props.test.ts": 4101, - "packages/core/tests/root-provider.test.ts": 4105, - "packages/core/tests/sample-component.test.ts": 4739, - "packages/core/tests/scanner.test.ts": 3611, - "packages/core/tests/scope-local.test.ts": 1522, - "packages/core/tests/secret-detection.test.ts": 4752, - "packages/core/tests/secret-files.test.ts": 2054, - "packages/core/tests/secret-rules.test.ts": 2065, - "packages/core/tests/secret-scanner-baseline.test.ts": 1712, - "packages/core/tests/secret-scanner.test.ts": 1894, - "packages/core/tests/source-position.test.ts": 3886, - "packages/core/tests/state-ownership.test.ts": 3992, - "packages/core/tests/streaming-emission.test.ts": 3859, - "packages/core/tests/switch.test.ts": 4486, - "packages/core/tests/syntax-catalog.test.ts": 4543, - "packages/core/tests/syntax-component.test.ts": 5165, - "packages/core/tests/syntax-loaded-copy.test.ts": 3999, - "packages/core/tests/temp-dir.test.ts": 6398, - "packages/core/tests/temp-file-compiler.test.ts": 2199, - "packages/core/tests/text-interpolation.test.ts": 3718, - "packages/core/tests/try-content.test.ts": 3770, - "packages/core/tests/unused-in-diff.test.ts": 4055, - "packages/core/tests/validate.test.ts": 3855, - "packages/core/tests/validation-integration.test.ts": 3878, - "packages/core/tests/workflow-component-bundle.test.ts": 4177, - "packages/durable-streams/tests/context.test.ts": 1758, - "packages/durable-streams/tests/deterministic-id.test.ts": 1837, - "packages/durable-streams/tests/divergence-api.test.ts": 1795, - "packages/durable-streams/tests/divergence.test.ts": 1842, - "packages/durable-streams/tests/durable-each.test.ts": 1825, - "packages/durable-streams/tests/durable-position.test.ts": 1826, - "packages/durable-streams/tests/durable-run.test.ts": 1865, - "packages/durable-streams/tests/ephemeral.test.ts": 1833, - "packages/durable-streams/tests/fail-stop.test.ts": 1867, - "packages/durable-streams/tests/guard-stream.test.ts": 1921, - "packages/durable-streams/tests/live-coordinator.test.ts": 1889, - "packages/durable-streams/tests/parse.test.ts": 1805, - "packages/durable-streams/tests/replay-guard.test.ts": 1904, - "packages/durable-streams/tests/replay-index.test.ts": 1648, - "packages/durable-streams/tests/retained.test.ts": 1659, - "packages/durable-streams/tests/serialize-event.test.ts": 1795, - "packages/durable-streams/tests/smoke.test.ts": 1755, - "packages/durable-streams/tests/structured-concurrency.test.ts": 1917, - "packages/durable-streams/tests/terminal-boundary.test.ts": 1849, - "packages/durable-streams/tests/types.test.ts": 1474, - "packages/git/tests/ambient-authentication.test.ts": 26920, - "packages/git/tests/api-entrypoint.test.ts": 5024, - "packages/git/tests/credential-helper.test.ts": 6150, - "packages/git/tests/git-add-crash.test.ts": 7101, - "packages/git/tests/git-add-durability.test.ts": 26659, - "packages/git/tests/git-add.test.ts": 36546, - "packages/git/tests/git-commit-crash.test.ts": 7525, - "packages/git/tests/git-commit-durability.test.ts": 35279, - "packages/git/tests/git-commit.test.ts": 41445, - "packages/git/tests/git-host-effect.test.ts": 8853, - "packages/git/tests/git-push-crash.test.ts": 10168, - "packages/git/tests/git-push-durability.test.ts": 44331, - "packages/git/tests/git-push.test.ts": 52468, - "packages/git/tests/git-switch-crash.test.ts": 7212, - "packages/git/tests/git-switch-durability.test.ts": 22671, - "packages/git/tests/git-switch.test.ts": 23633, - "packages/git/tests/git.test.ts": 4425, - "packages/git/tests/github-activation.test.ts": 5638, - "packages/git/tests/github-issues.test.ts": 4456, - "packages/git/tests/github-pull-requests.test.ts": 4028, - "packages/git/tests/github-workflow-activation.test.ts": 6072, - "packages/git/tests/issue-markdown.test.ts": 11597, - "packages/git/tests/issue-records.test.ts": 4345, - "packages/git/tests/materialization.test.ts": 5100, - "packages/git/tests/module-partition.test.ts": 5568, - "packages/git/tests/plugin.test.ts": 4922, - "packages/git/tests/provider-neutrality.test.ts": 3530, - "packages/git/tests/public-entrypoint.test.ts": 9609, - "packages/git/tests/pull-request-crash.test.ts": 8456, - "packages/git/tests/pull-request-durability.test.ts": 42879, - "packages/git/tests/pull-request-read.test.ts": 8248, - "packages/git/tests/pull-request-records.test.ts": 4297, - "packages/git/tests/pull-request.test.ts": 76370, - "packages/git/tests/repository-components.test.ts": 12989, - "packages/git/tests/repository-control-plane.test.ts": 11769, - "packages/git/tests/repository-materialization.test.ts": 8521, - "packages/git/tests/repository-replay.test.ts": 16218, - "packages/git/tests/repository-storage.test.ts": 19972, - "packages/git/tests/run-composition-ambient.test.ts": 13475, - "packages/git/tests/run-composition-lazy.test.ts": 5858, - "packages/git/tests/run-composition-managed.test.ts": 15234, - "packages/git/tests/run-composition-remote.test.ts": 15401, - "packages/git/tests/selection-authentication.test.ts": 1789, - "packages/git/tests/worktree-replay.test.ts": 6885, - "packages/runtime/tests/agent-session-coordinator.test.ts": 2147, - "packages/runtime/tests/duration.test.ts": 1526, - "packages/runtime/tests/executable-observer.test.ts": 1867, - "packages/runtime/tests/fetch.test.ts": 2341, - "packages/runtime/tests/fs.test.ts": 1744, - "packages/runtime/tests/host-files.test.ts": 2608, - "packages/runtime/tests/native-launcher.test.ts": 3932, - "packages/runtime/tests/process.test.ts": 1698, - "packages/runtime/tests/service.test.ts": 1878, - "packages/test-agent/tests/acp-server.test.ts": 1670, - "packages/test-agent/tests/behavior-engine.test.ts": 4244, - "packages/test-agent/tests/bridge.test.ts": 3685, - "packages/test-agent/tests/command-lazy.test.ts": 7878, - "packages/test-agent/tests/components.test.ts": 61379, - "packages/test-agent/tests/controller.test.ts": 2867, - "packages/test-agent/tests/cross-package-resolution.test.ts": 6126, - "packages/test-agent/tests/native-launch.test.ts": 30721, - "packages/test-agent/tests/net.test.ts": 1612, - "packages/test-agent/tests/profile.test.ts": 3973, - "packages/test-agent/tests/protocol.test.ts": 2381, - "packages/test-agent/tests/provider.test.ts": 4345, - "packages/test-agent/tests/public-api.test.ts": 5052, - "packages/test-agent/tests/route-slot.test.ts": 1545, - "packages/test-agent/tests/smoke.test.ts": 21015, - "packages/test-agent/tests/template.test.ts": 3946, - "packages/test-agent/tests/worker-lifecycle.test.ts": 13274, - "packages/test-support/tests/journal.test.ts": 1790, - "packages/testing/tests/agent-composition.test.ts": 4374, - "packages/testing/tests/assert-throws.test.ts": 5586, - "packages/testing/tests/assert.test.ts": 1535, - "packages/testing/tests/assertions.test.ts": 6102, - "packages/testing/tests/boundary-contract.test.ts": 4997, - "packages/testing/tests/cli.test.ts": 6740, - "packages/testing/tests/execution-harness.test.ts": 8986, - "packages/testing/tests/replay.test.ts": 5551, - "packages/testing/tests/smoke.test.ts": 5689, - "packages/testing/tests/test-component.test.ts": 6283, - "packages/testing/tests/testing-activation.test.ts": 5898, - "packages/testing/tests/testing-boundary.test.ts": 4710, - "packages/testing/tests/testing-mode.test.ts": 6244, - "packages/testing/tests/use-testing.test.ts": 5237, - "packages/web/tests/assets.test.ts": 1596, - "packages/web/tests/client-logic.test.ts": 1641, - "packages/web/tests/compile.test.ts": 4685, - "packages/web/tests/component.test.ts": 5161, - "packages/web/tests/declaration.test.ts": 4462, - "packages/web/tests/document.test.ts": 4704, - "packages/web/tests/elicitation.test.ts": 4446, - "packages/web/tests/live-form.test.ts": 4335, - "packages/web/tests/markdown.test.ts": 1739, - "packages/web/tests/opener.test.ts": 4387, - "packages/web/tests/page.test.ts": 1617, - "packages/web/tests/responder.test.ts": 4673, - "packages/web/tests/server-lifecycle.test.ts": 4609, - "packages/web/tests/server.test.ts": 4878, - "packages/workflow/tests/generated-agent-component.test.ts": 6951, - "packages/workflow/tests/generated-observations.test.ts": 4117, - "packages/workflow/tests/public-entrypoint.test.ts": 5937, - "packages/workflow/tests/retained-run.test.ts": 5062, - "packages/workflow/tests/service-denial.test.ts": 1840, - "packages/workflow/tests/workflow-agent-checkpoints.test.ts": 6196, - "packages/workflow/tests/workflow-agent-sessions.test.ts": 5043, - "packages/workflow/tests/workflow-bundle.test.ts": 4453, - "packages/workflow/tests/workflow-checkpoint.test.ts": 6455, - "packages/workflow/tests/workflow-definition.test.ts": 4303, - "packages/workflow/tests/workflow-export.test.ts": 8192, - "packages/workflow/tests/workflow-fork-source.test.ts": 5083, - "packages/workflow/tests/workflow-fork.test.ts": 4149, - "packages/workflow/tests/workflow-lifecycle-authority.test.ts": 6054, - "packages/workflow/tests/workflow-lifecycle-control.test.ts": 6620, - "packages/workflow/tests/workflow-lifecycle-inspection.test.ts": 11366, - "packages/workflow/tests/workflow-run-journal.test.ts": 7268, - "packages/workflow/tests/workflow-run-storage.test.ts": 8669, - "packages/workflow/tests/workflow-run.test.ts": 5002, - "packages/workflow/tests/workflow-suspension-answer.test.ts": 5957, - "packages/workflow/tests/workflow-suspension.test.ts": 5651, - "packages/workflow/tests/workspace-crash-recovery.test.ts": 7782, - "packages/workflow/tests/workspace-effect-loaded-copy.test.ts": 5088, - "packages/workflow/tests/workspace-effect-transaction.test.ts": 6058, - "packages/workflow/tests/workspace-effect.test.ts": 5885, - "packages/workflow/tests/workspace-files.test.ts": 12944, - "packages/workflow/tests/workspace-root-restoration.test.ts": 6768, - "packages/workflow/tests/workspace-root.test.ts": 5232, - "packages/workflow/tests/workspace-transaction.test.ts": 5253, - "packages/workflow/tests/xmd-artifact.test.ts": 7086, - "scripts/tests/acpx-vendor.test.ts": 2133, - "scripts/tests/adapter-distribution.test.ts": 4533, - "scripts/tests/adapter-npm-package.test.ts": 29375, - "scripts/tests/adapter-vendor.test.ts": 4164, - "scripts/tests/bootstrap-npm-package.test.ts": 11332, - "scripts/tests/build-npm.test.ts": 40632, - "scripts/tests/build-web-client.test.ts": 30546, - "scripts/tests/bump-version.test.ts": 1665, - "scripts/tests/changed-paths.test.ts": 1507, - "scripts/tests/ci-workflow.test.ts": 4789, - "scripts/tests/cli-npm-bin.test.ts": 254860, - "scripts/tests/cloudflare-dofs-vendor.test.ts": 4318, - "scripts/tests/consumer-cycle.test.ts": 1629, - "scripts/tests/documentation-validation.test.ts": 6250, - "scripts/tests/filesystem-contract-workflow.test.ts": 1587, - "scripts/tests/frozen-entry.test.ts": 8636, - "scripts/tests/jsr-consumer-documentation.test.ts": 18081, - "scripts/tests/main-green.test.ts": 1952, - "scripts/tests/main-health.test.ts": 1704, - "scripts/tests/measure-test-weights.test.ts": 1951, - "scripts/tests/no-module-scoped-registry.test.ts": 2386, - "scripts/tests/no-redundant-test-scope.test.ts": 6499, - "scripts/tests/no-sync-filesystem.test.ts": 6361, - "scripts/tests/no-yield-in-finally.test.ts": 2391, - "scripts/tests/oxlint-policy.test.ts": 12056, - "scripts/tests/packaged-document.test.ts": 1592, - "scripts/tests/prefer-effection-operation.test.ts": 7576, - "scripts/tests/prefer-effection-result.test.ts": 5674, - "scripts/tests/prepared-state.test.ts": 2741, - "scripts/tests/publish-workflow-generator.test.ts": 6106, - "scripts/tests/publish-workflow-membership.test.ts": 1724, - "scripts/tests/publishable-membership-agreement.test.ts": 4059, - "scripts/tests/readme-targets.test.ts": 18856, - "scripts/tests/release-targets.test.ts": 1661, - "scripts/tests/review-infrastructure.test.ts": 6247, - "scripts/tests/runtime-exclusions.test.ts": 2075, - "scripts/tests/runtime-tests.test.ts": 1699, - "scripts/tests/scope-bound-event-registration.test.ts": 6375, - "scripts/tests/shard-execution.test.ts": 11387, - "scripts/tests/staged-write.test.ts": 1543, - "scripts/tests/test-file-discovery.test.ts": 1781, - "scripts/tests/test-shards.test.ts": 1561, - "scripts/tests/test-weights.test.ts": 1863, - "scripts/tests/tracked.test.ts": 1526, - "scripts/tests/verify-adapter.test.ts": 7643, - "scripts/tests/verify-clean.test.ts": 1751, - "scripts/tests/verify-coordinator.test.ts": 1943, - "scripts/tests/version-lockstep.test.ts": 1743, - "scripts/tests/web-client-module.test.ts": 1573, - "scripts/tests/workspace.test.ts": 1528 + "packages/acp/tests/acceptance.test.ts": 4684, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 7911, + "packages/acp/tests/acpx-materialization.test.ts": 8409, + "packages/acp/tests/acpx-transient-env.test.ts": 1720, + "packages/acp/tests/adapter-materialization.test.ts": 74952, + "packages/acp/tests/adapter-protocol.test.ts": 57009, + "packages/acp/tests/codex-surfaces.test.ts": 1715, + "packages/acp/tests/loaded-copy.test.ts": 4391, + "packages/acp/tests/native-launch.test.ts": 5018, + "packages/acp/tests/permission-bridge.test.ts": 3855, + "packages/acp/tests/provider.test.ts": 5014, + "packages/acp/tests/serial-queue.test.ts": 1483, + "packages/acp/tests/session-key.test.ts": 3747, + "packages/acp/tests/session-route.test.ts": 3939, + "packages/acp/tests/terminal-screen.test.ts": 1635, + "packages/cli/tests/agent-adapters.test.ts": 5252, + "packages/cli/tests/agent-cli.test.ts": 37158, + "packages/cli/tests/agent-config.test.ts": 3425, + "packages/cli/tests/agent-options-cli.test.ts": 15138, + "packages/cli/tests/agent-options.test.ts": 3479, + "packages/cli/tests/agent-session-coordinator.test.ts": 4134, + "packages/cli/tests/cli-help.test.ts": 20086, + "packages/cli/tests/command.test.ts": 10167, + "packages/cli/tests/compiled-upgrade.test.ts": 10251, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 8468, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 117403, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 8020, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 11434, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 9716, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 14789, + "packages/cli/tests/evaluate-component.test.ts": 11221, + "packages/cli/tests/evaluate-workflow.test.ts": 5223, + "packages/cli/tests/fetch-cli.test.ts": 3667, + "packages/cli/tests/file-stream.test.ts": 1715, + "packages/cli/tests/github-zero-effect.test.ts": 6763, + "packages/cli/tests/inline-cli.test.ts": 32327, + "packages/cli/tests/launch-timeout.test.ts": 11567, + "packages/cli/tests/packaged-document.test.ts": 4625, + "packages/cli/tests/plan-args.test.ts": 1503, + "packages/cli/tests/plan-cli.test.ts": 96878, + "packages/cli/tests/plan-command-document.test.ts": 9231, + "packages/cli/tests/plan-component.test.ts": 11451, + "packages/cli/tests/plan-host-acts.test.ts": 5149, + "packages/cli/tests/plan.test.ts": 15116, + "packages/cli/tests/plugin-cli.test.ts": 39314, + "packages/cli/tests/plugin-host.test.ts": 5782, + "packages/cli/tests/plugin-modules.test.ts": 1498, + "packages/cli/tests/plugin-selection.test.ts": 1472, + "packages/cli/tests/process-retention.test.ts": 5280, + "packages/cli/tests/props-cli.test.ts": 29702, + "packages/cli/tests/props-schema.test.ts": 3892, + "packages/cli/tests/props-sources.test.ts": 2190, + "packages/cli/tests/pull-request-read-fork.test.ts": 6941, + "packages/cli/tests/repl-admission.test.ts": 1455, + "packages/cli/tests/repl-agent-execution.test.ts": 5949, + "packages/cli/tests/repl-boundaries.test.ts": 7194, + "packages/cli/tests/repl-composition.test.ts": 1972, + "packages/cli/tests/repl-execution.test.ts": 5186, + "packages/cli/tests/repl-journey.test.ts": 20413, + "packages/cli/tests/repl-model.test.ts": 5005, + "packages/cli/tests/repl-route.test.ts": 4579, + "packages/cli/tests/repl-terminal.test.ts": 2778, + "packages/cli/tests/run-composition-deno.test.ts": 7460, + "packages/cli/tests/run-composition-nested.test.ts": 20232, + "packages/cli/tests/run-composition.test.ts": 6624, + "packages/cli/tests/run-deadline.test.ts": 8368, + "packages/cli/tests/run-profile.test.ts": 4167, + "packages/cli/tests/run-timeouts.test.ts": 12915, + "packages/cli/tests/secret-detection-cli.test.ts": 18915, + "packages/cli/tests/service-document.test.ts": 5082, + "packages/cli/tests/service-host.test.ts": 4964, + "packages/cli/tests/session-launch-cli.test.ts": 10573, + "packages/cli/tests/stdin-cli.test.ts": 36225, + "packages/cli/tests/stdout-delivery.test.ts": 1502, + "packages/cli/tests/syntax-cli.test.ts": 35141, + "packages/cli/tests/targets-cli.test.ts": 28018, + "packages/cli/tests/test-root-profile.test.ts": 7134, + "packages/cli/tests/test-target.test.ts": 36226, + "packages/cli/tests/testing-activation.test.ts": 4268, + "packages/cli/tests/testing-execution-host.test.ts": 54817, + "packages/cli/tests/upgrade-cli.test.ts": 20574, + "packages/cli/tests/upgrade-command-document.test.ts": 6799, + "packages/cli/tests/upgrade-output.test.ts": 4280, + "packages/cli/tests/value-root.test.ts": 17005, + "packages/cli/tests/verbose-component.test.ts": 4757, + "packages/cli/tests/workflow-agent-sessions.test.ts": 18828, + "packages/cli/tests/workflow-agent.test.ts": 15123, + "packages/cli/tests/workflow-cli.test.ts": 106565, + "packages/cli/tests/workflow-crash.test.ts": 79447, + "packages/cli/tests/workflow-declaration.test.ts": 4306, + "packages/cli/tests/workflow-export-publication.test.ts": 17054, + "packages/cli/tests/workflow-fetch.test.ts": 7904, + "packages/cli/tests/workflow-fork.test.ts": 82695, + "packages/cli/tests/workflow-host.test.ts": 14229, + "packages/cli/tests/workflow-inspection.test.ts": 113974, + "packages/cli/tests/workflow-installation.test.ts": 5777, + "packages/cli/tests/workflow-lifecycle-control.test.ts": 20138, + "packages/cli/tests/workflow-retention.test.ts": 22437, + "packages/cli/tests/workflow-suspension.test.ts": 7422, + "packages/code-review-agent/tests/doctor.test.ts": 1455, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 1515, + "packages/code-review-agent/tests/parse-diff.test.ts": 1481, + "packages/code-review-agent/tests/parse-doctor.test.ts": 3841, + "packages/code-review-agent/tests/parse-oxlint.test.ts": 1483, + "packages/code-review-agent/tests/policy.test.ts": 1479, + "packages/core/tests/agent-api.test.ts": 1509, + "packages/core/tests/agent-components.test.ts": 4338, + "packages/core/tests/agent-function-components.test.ts": 6009, + "packages/core/tests/agent-session-launch.test.ts": 6290, + "packages/core/tests/agent-session-placement.test.ts": 1543, + "packages/core/tests/agent-session-use-loaded-copy.test.ts": 1600, + "packages/core/tests/agent-session-use.test.ts": 1508, + "packages/core/tests/agent-stack.test.ts": 3990, + "packages/core/tests/all.test.ts": 5105, + "packages/core/tests/answer-identity.test.ts": 3388, + "packages/core/tests/answers-component.test.ts": 4528, + "packages/core/tests/answers-expansion-recursion.test.ts": 3638, + "packages/core/tests/capture-errors.test.ts": 3844, + "packages/core/tests/capture-props.test.ts": 3638, + "packages/core/tests/cli-journal.test.ts": 11722, + "packages/core/tests/code-block-component.test.ts": 5039, + "packages/core/tests/compiler-boundary.test.ts": 3672, + "packages/core/tests/component-api.test.ts": 3414, + "packages/core/tests/component-registration.test.ts": 4422, + "packages/core/tests/component-returns.test.ts": 4804, + "packages/core/tests/component-schema-conformance.test.ts": 3785, + "packages/core/tests/config-api.test.ts": 3900, + "packages/core/tests/construct-error-observation.test.ts": 3894, + "packages/core/tests/contextual-cwd.test.ts": 4939, + "packages/core/tests/daemon-integration.test.ts": 4426, + "packages/core/tests/daemon.test.ts": 3403, + "packages/core/tests/declared-markdown-component.test.ts": 5142, + "packages/core/tests/document-output-api.test.ts": 1491, + "packages/core/tests/document-target-execution.test.ts": 6336, + "packages/core/tests/document-targets.test.ts": 3780, + "packages/core/tests/document-validation.test.ts": 5139, + "packages/core/tests/documentation-index.test.ts": 3501, + "packages/core/tests/each.test.ts": 3843, + "packages/core/tests/elicit-component.test.ts": 4286, + "packages/core/tests/ephemeral-service.test.ts": 4213, + "packages/core/tests/eval-bindings.test.ts": 3756, + "packages/core/tests/eval-context.test.ts": 2121, + "packages/core/tests/eval-durable.test.ts": 3778, + "packages/core/tests/eval-error-mode.test.ts": 3994, + "packages/core/tests/eval-interpolate.test.ts": 1438, + "packages/core/tests/eval-middleware.test.ts": 3431, + "packages/core/tests/eval-persist.test.ts": 3915, + "packages/core/tests/eval-return.test.ts": 3779, + "packages/core/tests/eval-scope.test.ts": 3676, + "packages/core/tests/eval-timeout.test.ts": 3738, + "packages/core/tests/eval-transform.test.ts": 3389, + "packages/core/tests/evaluate-component.test.ts": 5549, + "packages/core/tests/evaluate-loaded-copy.test.ts": 3805, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 3871, + "packages/core/tests/evaluation-profile.test.ts": 3571, + "packages/core/tests/exec-timeout.test.ts": 3805, + "packages/core/tests/execute.test.ts": 4339, + "packages/core/tests/execution-protocol.test.ts": 4963, + "packages/core/tests/expand.test.ts": 4453, + "packages/core/tests/expansion-identity.test.ts": 4320, + "packages/core/tests/expansion-metadata.test.ts": 3661, + "packages/core/tests/expression-props.test.ts": 4243, + "packages/core/tests/fail-component.test.ts": 3961, + "packages/core/tests/failure-printing.test.ts": 3898, + "packages/core/tests/fatal-cause.test.ts": 3582, + "packages/core/tests/fetch-component.test.ts": 5041, + "packages/core/tests/file-component.test.ts": 5612, + "packages/core/tests/file-delete-component.test.ts": 3864, + "packages/core/tests/files-fatal.test.ts": 4678, + "packages/core/tests/foreground-exec.test.ts": 4690, + "packages/core/tests/frontmatter.test.ts": 3380, + "packages/core/tests/function-components.test.ts": 4235, + "packages/core/tests/generated-composition.test.ts": 4265, + "packages/core/tests/generated-xmd.test.ts": 5573, + "packages/core/tests/glob-api.test.ts": 1826, + "packages/core/tests/glob-component.test.ts": 4412, + "packages/core/tests/guarded-journal.test.ts": 3731, + "packages/core/tests/has-content.test.ts": 3612, + "packages/core/tests/heal.test.ts": 3595, + "packages/core/tests/if.test.ts": 4223, + "packages/core/tests/inline-root.test.ts": 3787, + "packages/core/tests/invocation-failures.test.ts": 3490, + "packages/core/tests/invocation-identity.test.ts": 4410, + "packages/core/tests/invocation-scope.test.ts": 3794, + "packages/core/tests/journal-source-position.test.ts": 3964, + "packages/core/tests/json-component.test.ts": 3942, + "packages/core/tests/json.test.ts": 3363, + "packages/core/tests/let.test.ts": 3748, + "packages/core/tests/loaded-copy-files.test.ts": 3734, + "packages/core/tests/loop.test.ts": 4549, + "packages/core/tests/named-slots.test.ts": 4465, + "packages/core/tests/output-error-mode.test.ts": 4632, + "packages/core/tests/output-normalize.test.ts": 1481, + "packages/core/tests/output-terminal.test.ts": 1626, + "packages/core/tests/parse-components.test.ts": 3795, + "packages/core/tests/plan-response.test.ts": 1517, + "packages/core/tests/plugin-api.test.ts": 3364, + "packages/core/tests/plugin.test.ts": 3569, + "packages/core/tests/props-binding.test.ts": 3873, + "packages/core/tests/protected-content.test.ts": 3384, + "packages/core/tests/registered-printed-errors.test.ts": 3579, + "packages/core/tests/replay-stream.test.ts": 1449, + "packages/core/tests/retain.test.ts": 3733, + "packages/core/tests/root-composition.test.ts": 3875, + "packages/core/tests/root-props.test.ts": 3829, + "packages/core/tests/root-provider.test.ts": 3905, + "packages/core/tests/sample-component.test.ts": 4519, + "packages/core/tests/scanner.test.ts": 3452, + "packages/core/tests/scope-local.test.ts": 1462, + "packages/core/tests/secret-detection.test.ts": 4509, + "packages/core/tests/secret-files.test.ts": 1949, + "packages/core/tests/secret-rules.test.ts": 1953, + "packages/core/tests/secret-scanner-baseline.test.ts": 1660, + "packages/core/tests/secret-scanner.test.ts": 1820, + "packages/core/tests/source-position.test.ts": 3663, + "packages/core/tests/state-ownership.test.ts": 3710, + "packages/core/tests/streaming-emission.test.ts": 3650, + "packages/core/tests/switch.test.ts": 4189, + "packages/core/tests/syntax-catalog.test.ts": 4327, + "packages/core/tests/syntax-component.test.ts": 4893, + "packages/core/tests/syntax-loaded-copy.test.ts": 3854, + "packages/core/tests/temp-dir.test.ts": 6169, + "packages/core/tests/temp-file-compiler.test.ts": 2138, + "packages/core/tests/text-interpolation.test.ts": 3549, + "packages/core/tests/try-content.test.ts": 3588, + "packages/core/tests/unused-in-diff.test.ts": 3836, + "packages/core/tests/validate.test.ts": 3611, + "packages/core/tests/validation-integration.test.ts": 3680, + "packages/core/tests/workflow-component-bundle.test.ts": 3963, + "packages/durable-streams/tests/context.test.ts": 1694, + "packages/durable-streams/tests/deterministic-id.test.ts": 1755, + "packages/durable-streams/tests/divergence-api.test.ts": 1710, + "packages/durable-streams/tests/divergence.test.ts": 1770, + "packages/durable-streams/tests/durable-each.test.ts": 1766, + "packages/durable-streams/tests/durable-position.test.ts": 1710, + "packages/durable-streams/tests/durable-run.test.ts": 1765, + "packages/durable-streams/tests/ephemeral.test.ts": 1734, + "packages/durable-streams/tests/fail-stop.test.ts": 1783, + "packages/durable-streams/tests/guard-stream.test.ts": 1804, + "packages/durable-streams/tests/live-coordinator.test.ts": 1771, + "packages/durable-streams/tests/parse.test.ts": 1753, + "packages/durable-streams/tests/replay-guard.test.ts": 1827, + "packages/durable-streams/tests/replay-index.test.ts": 1568, + "packages/durable-streams/tests/retained.test.ts": 1582, + "packages/durable-streams/tests/serialize-event.test.ts": 1744, + "packages/durable-streams/tests/smoke.test.ts": 1697, + "packages/durable-streams/tests/structured-concurrency.test.ts": 1853, + "packages/durable-streams/tests/terminal-boundary.test.ts": 1790, + "packages/durable-streams/tests/types.test.ts": 1428, + "packages/git/tests/ambient-authentication.test.ts": 26144, + "packages/git/tests/api-entrypoint.test.ts": 4875, + "packages/git/tests/credential-helper.test.ts": 5844, + "packages/git/tests/git-add-crash.test.ts": 6757, + "packages/git/tests/git-add-durability.test.ts": 25557, + "packages/git/tests/git-add.test.ts": 34045, + "packages/git/tests/git-commit-crash.test.ts": 7015, + "packages/git/tests/git-commit-durability.test.ts": 32762, + "packages/git/tests/git-commit.test.ts": 39336, + "packages/git/tests/git-host-effect.test.ts": 8358, + "packages/git/tests/git-push-crash.test.ts": 9603, + "packages/git/tests/git-push-durability.test.ts": 42141, + "packages/git/tests/git-push.test.ts": 50834, + "packages/git/tests/git-switch-crash.test.ts": 6753, + "packages/git/tests/git-switch-durability.test.ts": 20958, + "packages/git/tests/git-switch.test.ts": 22167, + "packages/git/tests/git.test.ts": 4145, + "packages/git/tests/github-activation.test.ts": 5322, + "packages/git/tests/github-issues.test.ts": 4160, + "packages/git/tests/github-pull-requests.test.ts": 3825, + "packages/git/tests/github-workflow-activation.test.ts": 5635, + "packages/git/tests/issue-markdown.test.ts": 10625, + "packages/git/tests/issue-records.test.ts": 4059, + "packages/git/tests/materialization.test.ts": 4785, + "packages/git/tests/module-partition.test.ts": 5028, + "packages/git/tests/plugin.test.ts": 4481, + "packages/git/tests/provider-neutrality.test.ts": 3228, + "packages/git/tests/public-entrypoint.test.ts": 9398, + "packages/git/tests/pull-request-crash.test.ts": 7962, + "packages/git/tests/pull-request-durability.test.ts": 40566, + "packages/git/tests/pull-request-read.test.ts": 7673, + "packages/git/tests/pull-request-records.test.ts": 4103, + "packages/git/tests/pull-request.test.ts": 72474, + "packages/git/tests/repository-components.test.ts": 12119, + "packages/git/tests/repository-control-plane.test.ts": 10929, + "packages/git/tests/repository-materialization.test.ts": 7982, + "packages/git/tests/repository-replay.test.ts": 15237, + "packages/git/tests/repository-storage.test.ts": 18670, + "packages/git/tests/run-composition-ambient.test.ts": 12652, + "packages/git/tests/run-composition-lazy.test.ts": 5383, + "packages/git/tests/run-composition-managed.test.ts": 14603, + "packages/git/tests/run-composition-remote.test.ts": 14980, + "packages/git/tests/selection-authentication.test.ts": 1760, + "packages/git/tests/worktree-replay.test.ts": 6498, + "packages/runtime/tests/agent-session-coordinator.test.ts": 2058, + "packages/runtime/tests/duration.test.ts": 1473, + "packages/runtime/tests/executable-observer.test.ts": 1811, + "packages/runtime/tests/fetch.test.ts": 2258, + "packages/runtime/tests/fs.test.ts": 1664, + "packages/runtime/tests/host-files.test.ts": 2479, + "packages/runtime/tests/native-launcher.test.ts": 3869, + "packages/runtime/tests/process.test.ts": 1638, + "packages/runtime/tests/service.test.ts": 1760, + "packages/test-agent/tests/acp-server.test.ts": 1593, + "packages/test-agent/tests/behavior-engine.test.ts": 4047, + "packages/test-agent/tests/bridge.test.ts": 3559, + "packages/test-agent/tests/command-lazy.test.ts": 7661, + "packages/test-agent/tests/components.test.ts": 60503, + "packages/test-agent/tests/controller.test.ts": 2761, + "packages/test-agent/tests/cross-package-resolution.test.ts": 5690, + "packages/test-agent/tests/native-launch.test.ts": 30027, + "packages/test-agent/tests/net.test.ts": 1573, + "packages/test-agent/tests/profile.test.ts": 3767, + "packages/test-agent/tests/protocol.test.ts": 2342, + "packages/test-agent/tests/provider.test.ts": 4074, + "packages/test-agent/tests/public-api.test.ts": 4740, + "packages/test-agent/tests/route-slot.test.ts": 1491, + "packages/test-agent/tests/smoke.test.ts": 20597, + "packages/test-agent/tests/template.test.ts": 3762, + "packages/test-agent/tests/worker-lifecycle.test.ts": 13052, + "packages/test-support/tests/journal.test.ts": 1736, + "packages/testing/tests/agent-composition.test.ts": 4095, + "packages/testing/tests/assert-throws.test.ts": 5231, + "packages/testing/tests/assert.test.ts": 1503, + "packages/testing/tests/assertions.test.ts": 5740, + "packages/testing/tests/boundary-contract.test.ts": 4733, + "packages/testing/tests/cli.test.ts": 6584, + "packages/testing/tests/execution-harness.test.ts": 8311, + "packages/testing/tests/replay.test.ts": 5216, + "packages/testing/tests/smoke.test.ts": 5357, + "packages/testing/tests/test-component.test.ts": 5846, + "packages/testing/tests/testing-activation.test.ts": 5508, + "packages/testing/tests/testing-boundary.test.ts": 4445, + "packages/testing/tests/testing-mode.test.ts": 5989, + "packages/testing/tests/use-testing.test.ts": 4997, + "packages/web/tests/assets.test.ts": 1595, + "packages/web/tests/client-logic.test.ts": 1629, + "packages/web/tests/compile.test.ts": 4428, + "packages/web/tests/component.test.ts": 4918, + "packages/web/tests/declaration.test.ts": 4203, + "packages/web/tests/document.test.ts": 4460, + "packages/web/tests/elicitation.test.ts": 4241, + "packages/web/tests/live-form.test.ts": 4147, + "packages/web/tests/markdown.test.ts": 1698, + "packages/web/tests/opener.test.ts": 4134, + "packages/web/tests/page.test.ts": 1508, + "packages/web/tests/responder.test.ts": 4388, + "packages/web/tests/server-lifecycle.test.ts": 4279, + "packages/web/tests/server.test.ts": 4605, + "packages/workflow/tests/generated-agent-component.test.ts": 6383, + "packages/workflow/tests/generated-observations.test.ts": 4037, + "packages/workflow/tests/public-entrypoint.test.ts": 5694, + "packages/workflow/tests/retained-run.test.ts": 4833, + "packages/workflow/tests/service-denial.test.ts": 1776, + "packages/workflow/tests/workflow-agent-checkpoints.test.ts": 5866, + "packages/workflow/tests/workflow-agent-sessions.test.ts": 4832, + "packages/workflow/tests/workflow-bundle.test.ts": 4386, + "packages/workflow/tests/workflow-checkpoint.test.ts": 5931, + "packages/workflow/tests/workflow-definition.test.ts": 4091, + "packages/workflow/tests/workflow-export.test.ts": 7740, + "packages/workflow/tests/workflow-fork-source.test.ts": 4901, + "packages/workflow/tests/workflow-fork.test.ts": 4055, + "packages/workflow/tests/workflow-lifecycle-authority.test.ts": 5859, + "packages/workflow/tests/workflow-lifecycle-control.test.ts": 6343, + "packages/workflow/tests/workflow-lifecycle-inspection.test.ts": 10830, + "packages/workflow/tests/workflow-run-journal.test.ts": 6900, + "packages/workflow/tests/workflow-run-storage.test.ts": 7788, + "packages/workflow/tests/workflow-run.test.ts": 4676, + "packages/workflow/tests/workflow-suspension-answer.test.ts": 5603, + "packages/workflow/tests/workflow-suspension.test.ts": 5319, + "packages/workflow/tests/workspace-crash-recovery.test.ts": 7519, + "packages/workflow/tests/workspace-effect-loaded-copy.test.ts": 4912, + "packages/workflow/tests/workspace-effect-transaction.test.ts": 5569, + "packages/workflow/tests/workspace-effect.test.ts": 5532, + "packages/workflow/tests/workspace-files.test.ts": 12183, + "packages/workflow/tests/workspace-root-restoration.test.ts": 6416, + "packages/workflow/tests/workspace-root.test.ts": 4845, + "packages/workflow/tests/workspace-transaction.test.ts": 4903, + "packages/workflow/tests/xmd-artifact.test.ts": 6615, + "scripts/tests/acpx-vendor.test.ts": 2057, + "scripts/tests/adapter-distribution.test.ts": 4404, + "scripts/tests/adapter-npm-package.test.ts": 31673, + "scripts/tests/adapter-vendor.test.ts": 3903, + "scripts/tests/bootstrap-npm-package.test.ts": 10858, + "scripts/tests/build-npm.test.ts": 38544, + "scripts/tests/build-web-client.test.ts": 28858, + "scripts/tests/bump-version.test.ts": 1601, + "scripts/tests/changed-paths.test.ts": 1487, + "scripts/tests/ci-workflow.test.ts": 4643, + "scripts/tests/cli-npm-bin.test.ts": 238191, + "scripts/tests/cloudflare-dofs-vendor.test.ts": 4194, + "scripts/tests/consumer-cycle.test.ts": 1614, + "scripts/tests/documentation-validation.test.ts": 6069, + "scripts/tests/filesystem-contract-workflow.test.ts": 1491, + "scripts/tests/frozen-entry.test.ts": 7857, + "scripts/tests/jsr-consumer-documentation.test.ts": 17583, + "scripts/tests/main-green.test.ts": 1907, + "scripts/tests/main-health.test.ts": 1646, + "scripts/tests/measure-test-weights.test.ts": 1938, + "scripts/tests/no-module-scoped-registry.test.ts": 2327, + "scripts/tests/no-redundant-test-scope.test.ts": 6439, + "scripts/tests/no-sync-filesystem.test.ts": 6219, + "scripts/tests/no-yield-in-finally.test.ts": 2345, + "scripts/tests/oxlint-policy.test.ts": 12080, + "scripts/tests/packaged-document.test.ts": 1551, + "scripts/tests/prefer-effection-operation.test.ts": 7392, + "scripts/tests/prefer-effection-result.test.ts": 5561, + "scripts/tests/prepared-state.test.ts": 2608, + "scripts/tests/publish-workflow-generator.test.ts": 6078, + "scripts/tests/publish-workflow-membership.test.ts": 1650, + "scripts/tests/publishable-membership-agreement.test.ts": 3926, + "scripts/tests/readme-targets.test.ts": 18513, + "scripts/tests/release-targets.test.ts": 1601, + "scripts/tests/review-infrastructure.test.ts": 5920, + "scripts/tests/runtime-exclusions.test.ts": 1980, + "scripts/tests/runtime-tests.test.ts": 1653, + "scripts/tests/scope-bound-event-registration.test.ts": 6208, + "scripts/tests/shard-execution.test.ts": 11115, + "scripts/tests/staged-write.test.ts": 1505, + "scripts/tests/test-file-discovery.test.ts": 1751, + "scripts/tests/test-shards.test.ts": 1516, + "scripts/tests/test-weights.test.ts": 1811, + "scripts/tests/tracked.test.ts": 1508, + "scripts/tests/verify-adapter.test.ts": 7750, + "scripts/tests/verify-clean.test.ts": 1712, + "scripts/tests/verify-coordinator.test.ts": 1871, + "scripts/tests/version-lockstep.test.ts": 1661, + "scripts/tests/web-client-module.test.ts": 1517, + "scripts/tests/workspace.test.ts": 1472 }, "node": { - "packages/acp/tests/acceptance.test.ts": 2110, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4869, - "packages/acp/tests/acpx-materialization.test.ts": 7089, - "packages/acp/tests/acpx-transient-env.test.ts": 828, - "packages/acp/tests/adapter-materialization.test.ts": 73589, - "packages/acp/tests/adapter-protocol.test.ts": 54177, - "packages/acp/tests/codex-surfaces.test.ts": 657, - "packages/acp/tests/native-launch.test.ts": 2222, - "packages/acp/tests/permission-bridge.test.ts": 1220, - "packages/acp/tests/provider.test.ts": 2161, + "packages/acp/tests/acceptance.test.ts": 2089, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4861, + "packages/acp/tests/acpx-materialization.test.ts": 7096, + "packages/acp/tests/acpx-transient-env.test.ts": 804, + "packages/acp/tests/adapter-materialization.test.ts": 73196, + "packages/acp/tests/adapter-protocol.test.ts": 52860, + "packages/acp/tests/codex-surfaces.test.ts": 673, + "packages/acp/tests/native-launch.test.ts": 2173, + "packages/acp/tests/permission-bridge.test.ts": 1225, + "packages/acp/tests/provider.test.ts": 2093, "packages/acp/tests/serial-queue.test.ts": 493, - "packages/acp/tests/session-key.test.ts": 549, - "packages/acp/tests/terminal-screen.test.ts": 611, - "packages/cli/tests/agent-adapters.test.ts": 2453, - "packages/cli/tests/agent-cli.test.ts": 43285, - "packages/cli/tests/agent-config.test.ts": 486, - "packages/cli/tests/agent-options-cli.test.ts": 16242, - "packages/cli/tests/agent-options.test.ts": 481, - "packages/cli/tests/agent-session-coordinator.test.ts": 1353, - "packages/cli/tests/cli-help.test.ts": 40747, - "packages/cli/tests/command.test.ts": 14935, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 4260, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 171781, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 3738, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 10122, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 6100, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 18177, - "packages/cli/tests/evaluate-component.test.ts": 15487, - "packages/cli/tests/fetch-cli.test.ts": 4949, - "packages/cli/tests/file-stream.test.ts": 533, - "packages/cli/tests/inline-cli.test.ts": 68083, - "packages/cli/tests/launch-timeout.test.ts": 20547, - "packages/cli/tests/packaged-document.test.ts": 1584, - "packages/cli/tests/plan-args.test.ts": 506, - "packages/cli/tests/plan-cli.test.ts": 119226, - "packages/cli/tests/plan-command-document.test.ts": 8084, - "packages/cli/tests/plan-component.test.ts": 11022, - "packages/cli/tests/plan-host-acts.test.ts": 2346, - "packages/cli/tests/plan.test.ts": 15829, - "packages/cli/tests/plugin-cli.test.ts": 82240, - "packages/cli/tests/plugin-host.test.ts": 3281, - "packages/cli/tests/plugin-modules.test.ts": 505, - "packages/cli/tests/plugin-selection.test.ts": 512, - "packages/cli/tests/process-retention.test.ts": 4997, - "packages/cli/tests/props-cli.test.ts": 63707, - "packages/cli/tests/props-schema.test.ts": 1350, - "packages/cli/tests/props-sources.test.ts": 683, - "packages/cli/tests/repl-boundaries.test.ts": 2529, - "packages/cli/tests/repl-composition.test.ts": 751, - "packages/cli/tests/repl-execution.test.ts": 2941, - "packages/cli/tests/repl-journey.test.ts": 11042, - "packages/cli/tests/repl-model.test.ts": 2778, - "packages/cli/tests/repl-route.test.ts": 2307, - "packages/cli/tests/repl-terminal.test.ts": 1256, - "packages/cli/tests/run-composition.test.ts": 4152, - "packages/cli/tests/run-deadline.test.ts": 3685, - "packages/cli/tests/run-profile.test.ts": 1253, - "packages/cli/tests/run-timeouts.test.ts": 20254, - "packages/cli/tests/secret-detection-cli.test.ts": 37931, - "packages/cli/tests/service-document.test.ts": 2565, - "packages/cli/tests/service-host.test.ts": 2347, - "packages/cli/tests/session-launch-cli.test.ts": 11044, - "packages/cli/tests/stdin-cli.test.ts": 63445, - "packages/cli/tests/stdout-delivery.test.ts": 493, - "packages/cli/tests/syntax-cli.test.ts": 62013, - "packages/cli/tests/targets-cli.test.ts": 51063, - "packages/cli/tests/test-root-profile.test.ts": 7125, - "packages/cli/tests/test-target.test.ts": 73776, - "packages/cli/tests/testing-activation.test.ts": 1497, - "packages/cli/tests/testing-execution-host.test.ts": 91050, - "packages/cli/tests/upgrade-cli.test.ts": 46673, - "packages/cli/tests/upgrade-command-document.test.ts": 5530, - "packages/cli/tests/upgrade-output.test.ts": 1845, - "packages/cli/tests/value-root.test.ts": 31600, - "packages/cli/tests/verbose-component.test.ts": 1940, - "packages/cli/tests/workflow-declaration.test.ts": 1449, - "packages/cli/tests/workflow-host.test.ts": 10859, - "packages/code-review-agent/tests/doctor.test.ts": 478, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 522, - "packages/code-review-agent/tests/parse-diff.test.ts": 517, - "packages/code-review-agent/tests/parse-doctor.test.ts": 1167, - "packages/code-review-agent/tests/parse-oxlint.test.ts": 482, - "packages/code-review-agent/tests/policy.test.ts": 484, - "packages/core/tests/agent-api.test.ts": 493, - "packages/core/tests/agent-components.test.ts": 2035, - "packages/core/tests/agent-function-components.test.ts": 4189, - "packages/core/tests/agent-session-launch.test.ts": 4584, - "packages/core/tests/agent-session-placement.test.ts": 495, - "packages/core/tests/agent-session-use.test.ts": 483, - "packages/core/tests/agent-stack.test.ts": 1530, - "packages/core/tests/all.test.ts": 3001, - "packages/core/tests/answer-identity.test.ts": 578, - "packages/core/tests/answers-component.test.ts": 2373, - "packages/core/tests/answers-expansion-recursion.test.ts": 970, - "packages/core/tests/capture-errors.test.ts": 1288, - "packages/core/tests/capture-props.test.ts": 993, - "packages/core/tests/cli-journal.test.ts": 21990, - "packages/core/tests/code-block-component.test.ts": 3154, - "packages/core/tests/compiler-boundary.test.ts": 1162, - "packages/core/tests/component-api.test.ts": 557, - "packages/core/tests/component-registration.test.ts": 2067, - "packages/core/tests/component-returns.test.ts": 2830, - "packages/core/tests/component-schema-conformance.test.ts": 1092, - "packages/core/tests/config-api.test.ts": 1242, - "packages/core/tests/construct-error-observation.test.ts": 1361, - "packages/core/tests/contextual-cwd.test.ts": 2435, - "packages/core/tests/daemon-integration.test.ts": 1891, - "packages/core/tests/daemon.test.ts": 578, - "packages/core/tests/declared-markdown-component.test.ts": 3168, - "packages/core/tests/document-output-api.test.ts": 495, - "packages/core/tests/document-target-execution.test.ts": 4535, - "packages/core/tests/document-targets.test.ts": 1191, - "packages/core/tests/document-validation.test.ts": 3100, - "packages/core/tests/documentation-index.test.ts": 819, - "packages/core/tests/each.test.ts": 1263, - "packages/core/tests/elicit-component.test.ts": 1996, - "packages/core/tests/ephemeral-service.test.ts": 1791, - "packages/core/tests/eval-bindings.test.ts": 1271, - "packages/core/tests/eval-context.test.ts": 572, - "packages/core/tests/eval-durable.test.ts": 1276, - "packages/core/tests/eval-error-mode.test.ts": 1635, - "packages/core/tests/eval-interpolate.test.ts": 502, - "packages/core/tests/eval-middleware.test.ts": 595, - "packages/core/tests/eval-persist.test.ts": 1380, - "packages/core/tests/eval-return.test.ts": 1313, - "packages/core/tests/eval-scope.test.ts": 1207, - "packages/core/tests/eval-timeout.test.ts": 1278, - "packages/core/tests/eval-transform.test.ts": 573, - "packages/core/tests/evaluate-component.test.ts": 3608, - "packages/core/tests/evaluate-loaded-copy.test.ts": 1339, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 1438, - "packages/core/tests/evaluation-profile.test.ts": 861, - "packages/core/tests/exec-timeout.test.ts": 1308, - "packages/core/tests/execute.test.ts": 1969, - "packages/core/tests/execution-protocol.test.ts": 2822, - "packages/core/tests/expand.test.ts": 1968, - "packages/core/tests/expansion-identity.test.ts": 2011, - "packages/core/tests/expansion-metadata.test.ts": 1010, - "packages/core/tests/expression-props.test.ts": 1863, - "packages/core/tests/fail-component.test.ts": 1631, - "packages/core/tests/failure-printing.test.ts": 1468, - "packages/core/tests/fatal-cause.test.ts": 890, - "packages/core/tests/fetch-component.test.ts": 2900, - "packages/core/tests/file-component.test.ts": 3333, - "packages/core/tests/file-delete-component.test.ts": 1461, - "packages/core/tests/files-fatal.test.ts": 2432, - "packages/core/tests/foreground-exec.test.ts": 2309, - "packages/core/tests/frontmatter.test.ts": 546, - "packages/core/tests/function-components.test.ts": 1982, - "packages/core/tests/generated-composition.test.ts": 2014, - "packages/core/tests/generated-xmd.test.ts": 3536, - "packages/core/tests/glob-api.test.ts": 625, - "packages/core/tests/glob-component.test.ts": 2058, - "packages/core/tests/guarded-journal.test.ts": 1304, - "packages/core/tests/has-content.test.ts": 924, - "packages/core/tests/heal.test.ts": 939, - "packages/core/tests/if.test.ts": 1837, - "packages/core/tests/inline-root.test.ts": 1327, - "packages/core/tests/invocation-failures.test.ts": 597, - "packages/core/tests/invocation-identity.test.ts": 1761, - "packages/core/tests/invocation-scope.test.ts": 1193, - "packages/core/tests/journal-source-position.test.ts": 1461, - "packages/core/tests/json-component.test.ts": 1541, - "packages/core/tests/json.test.ts": 499, - "packages/core/tests/let.test.ts": 1290, - "packages/core/tests/loop.test.ts": 2310, - "packages/core/tests/named-slots.test.ts": 2304, - "packages/core/tests/output-error-mode.test.ts": 2652, - "packages/core/tests/output-normalize.test.ts": 552, - "packages/core/tests/output-terminal.test.ts": 704, - "packages/core/tests/parse-components.test.ts": 1449, - "packages/core/tests/plan-response.test.ts": 609, - "packages/core/tests/plugin-api.test.ts": 532, - "packages/core/tests/plugin.test.ts": 1004, - "packages/core/tests/props-binding.test.ts": 1505, - "packages/core/tests/protected-content.test.ts": 551, - "packages/core/tests/registered-printed-errors.test.ts": 854, - "packages/core/tests/replay-stream.test.ts": 495, - "packages/core/tests/retain.test.ts": 1153, - "packages/core/tests/root-composition.test.ts": 1451, - "packages/core/tests/root-props.test.ts": 1490, - "packages/core/tests/root-provider.test.ts": 1343, - "packages/core/tests/sample-component.test.ts": 2330, - "packages/core/tests/scanner.test.ts": 590, - "packages/core/tests/scope-local.test.ts": 489, - "packages/core/tests/secret-detection.test.ts": 2104, - "packages/core/tests/secret-files.test.ts": 742, - "packages/core/tests/secret-rules.test.ts": 831, - "packages/core/tests/secret-scanner-baseline.test.ts": 747, - "packages/core/tests/secret-scanner.test.ts": 701, - "packages/core/tests/source-position.test.ts": 1126, - "packages/core/tests/state-ownership.test.ts": 1193, - "packages/core/tests/streaming-emission.test.ts": 1174, - "packages/core/tests/switch.test.ts": 1868, - "packages/core/tests/syntax-catalog.test.ts": 1826, - "packages/core/tests/syntax-component.test.ts": 2697, - "packages/core/tests/temp-dir.test.ts": 3741, - "packages/core/tests/temp-file-compiler.test.ts": 555, - "packages/core/tests/text-interpolation.test.ts": 907, - "packages/core/tests/try-content.test.ts": 905, - "packages/core/tests/unused-in-diff.test.ts": 1388, - "packages/core/tests/validate.test.ts": 795, - "packages/core/tests/validation-integration.test.ts": 1219, - "packages/core/tests/workflow-component-bundle.test.ts": 1577, - "packages/durable-streams/tests/context.test.ts": 528, - "packages/durable-streams/tests/deterministic-id.test.ts": 556, - "packages/durable-streams/tests/divergence-api.test.ts": 536, - "packages/durable-streams/tests/divergence.test.ts": 571, - "packages/durable-streams/tests/durable-each.test.ts": 567, - "packages/durable-streams/tests/durable-position.test.ts": 582, - "packages/durable-streams/tests/durable-run.test.ts": 566, - "packages/durable-streams/tests/ephemeral.test.ts": 551, - "packages/durable-streams/tests/fail-stop.test.ts": 586, - "packages/durable-streams/tests/guard-stream.test.ts": 619, - "packages/durable-streams/tests/live-coordinator.test.ts": 558, - "packages/durable-streams/tests/parse.test.ts": 586, - "packages/durable-streams/tests/replay-guard.test.ts": 587, - "packages/durable-streams/tests/replay-index.test.ts": 534, - "packages/durable-streams/tests/retained.test.ts": 535, - "packages/durable-streams/tests/serialize-event.test.ts": 536, - "packages/durable-streams/tests/smoke.test.ts": 521, - "packages/durable-streams/tests/structured-concurrency.test.ts": 604, - "packages/durable-streams/tests/terminal-boundary.test.ts": 577, - "packages/durable-streams/tests/types.test.ts": 479, - "packages/git/tests/api-entrypoint.test.ts": 1319, - "packages/git/tests/git-host-effect.test.ts": 7178, - "packages/git/tests/git.test.ts": 1302, - "packages/git/tests/github-activation.test.ts": 1658, - "packages/git/tests/github-issues.test.ts": 1228, - "packages/git/tests/github-pull-requests.test.ts": 604, - "packages/git/tests/issue-markdown.test.ts": 9924, - "packages/git/tests/issue-records.test.ts": 1197, - "packages/git/tests/module-partition.test.ts": 3241, - "packages/git/tests/plugin.test.ts": 1731, - "packages/git/tests/provider-neutrality.test.ts": 2312, - "packages/git/tests/pull-request-records.test.ts": 1278, - "packages/git/tests/run-composition-lazy.test.ts": 1935, - "packages/git/tests/selection-authentication.test.ts": 499, - "packages/runtime/tests/duration.test.ts": 487, - "packages/runtime/tests/fetch.test.ts": 1112, - "packages/runtime/tests/fs.test.ts": 552, - "packages/runtime/tests/host-files.test.ts": 1286, - "packages/runtime/tests/native-launcher.test.ts": 2872, - "packages/runtime/tests/process.test.ts": 551, - "packages/runtime/tests/service.test.ts": 522, - "packages/test-agent/tests/acp-server.test.ts": 671, - "packages/test-agent/tests/behavior-engine.test.ts": 1586, - "packages/test-agent/tests/bridge.test.ts": 510, - "packages/test-agent/tests/command-lazy.test.ts": 6555, - "packages/test-agent/tests/components.test.ts": 107217, - "packages/test-agent/tests/controller.test.ts": 795, - "packages/test-agent/tests/cross-package-resolution.test.ts": 2908, - "packages/test-agent/tests/native-launch.test.ts": 54180, - "packages/test-agent/tests/net.test.ts": 588, - "packages/test-agent/tests/profile.test.ts": 1219, - "packages/test-agent/tests/protocol.test.ts": 577, - "packages/test-agent/tests/provider.test.ts": 1340, - "packages/test-agent/tests/public-api.test.ts": 1560, - "packages/test-agent/tests/route-slot.test.ts": 481, - "packages/test-agent/tests/smoke.test.ts": 28732, - "packages/test-agent/tests/template.test.ts": 1180, - "packages/test-agent/tests/worker-lifecycle.test.ts": 23743, - "packages/test-support/tests/journal.test.ts": 490, - "packages/testing/tests/agent-composition.test.ts": 1468, - "packages/testing/tests/assert-throws.test.ts": 3147, + "packages/acp/tests/session-key.test.ts": 538, + "packages/acp/tests/terminal-screen.test.ts": 622, + "packages/cli/tests/agent-adapters.test.ts": 2390, + "packages/cli/tests/agent-cli.test.ts": 42118, + "packages/cli/tests/agent-config.test.ts": 489, + "packages/cli/tests/agent-options-cli.test.ts": 15836, + "packages/cli/tests/agent-options.test.ts": 485, + "packages/cli/tests/agent-session-coordinator.test.ts": 1328, + "packages/cli/tests/cli-help.test.ts": 39802, + "packages/cli/tests/command.test.ts": 14546, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 4111, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 167498, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 3496, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 9531, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 5746, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 17402, + "packages/cli/tests/evaluate-component.test.ts": 14869, + "packages/cli/tests/fetch-cli.test.ts": 4805, + "packages/cli/tests/file-stream.test.ts": 534, + "packages/cli/tests/inline-cli.test.ts": 65480, + "packages/cli/tests/launch-timeout.test.ts": 20546, + "packages/cli/tests/packaged-document.test.ts": 1570, + "packages/cli/tests/plan-args.test.ts": 500, + "packages/cli/tests/plan-cli.test.ts": 114275, + "packages/cli/tests/plan-command-document.test.ts": 7627, + "packages/cli/tests/plan-component.test.ts": 10384, + "packages/cli/tests/plan-host-acts.test.ts": 2265, + "packages/cli/tests/plan.test.ts": 15081, + "packages/cli/tests/plugin-cli.test.ts": 78571, + "packages/cli/tests/plugin-host.test.ts": 3060, + "packages/cli/tests/plugin-modules.test.ts": 491, + "packages/cli/tests/plugin-selection.test.ts": 509, + "packages/cli/tests/process-retention.test.ts": 4709, + "packages/cli/tests/props-cli.test.ts": 60792, + "packages/cli/tests/props-schema.test.ts": 1313, + "packages/cli/tests/props-sources.test.ts": 663, + "packages/cli/tests/repl-admission.test.ts": 544, + "packages/cli/tests/repl-agent-execution.test.ts": 3673, + "packages/cli/tests/repl-boundaries.test.ts": 2424, + "packages/cli/tests/repl-composition.test.ts": 722, + "packages/cli/tests/repl-execution.test.ts": 2859, + "packages/cli/tests/repl-journey.test.ts": 10976, + "packages/cli/tests/repl-model.test.ts": 2634, + "packages/cli/tests/repl-route.test.ts": 2153, + "packages/cli/tests/repl-terminal.test.ts": 1226, + "packages/cli/tests/run-composition.test.ts": 3910, + "packages/cli/tests/run-deadline.test.ts": 3586, + "packages/cli/tests/run-profile.test.ts": 1218, + "packages/cli/tests/run-timeouts.test.ts": 19558, + "packages/cli/tests/secret-detection-cli.test.ts": 36194, + "packages/cli/tests/service-document.test.ts": 2530, + "packages/cli/tests/service-host.test.ts": 2332, + "packages/cli/tests/session-launch-cli.test.ts": 10548, + "packages/cli/tests/stdin-cli.test.ts": 60570, + "packages/cli/tests/stdout-delivery.test.ts": 487, + "packages/cli/tests/syntax-cli.test.ts": 59238, + "packages/cli/tests/targets-cli.test.ts": 48904, + "packages/cli/tests/test-root-profile.test.ts": 6743, + "packages/cli/tests/test-target.test.ts": 70089, + "packages/cli/tests/testing-activation.test.ts": 1432, + "packages/cli/tests/testing-execution-host.test.ts": 86909, + "packages/cli/tests/upgrade-cli.test.ts": 44611, + "packages/cli/tests/upgrade-command-document.test.ts": 5110, + "packages/cli/tests/upgrade-output.test.ts": 1778, + "packages/cli/tests/value-root.test.ts": 29764, + "packages/cli/tests/verbose-component.test.ts": 1853, + "packages/cli/tests/workflow-declaration.test.ts": 1425, + "packages/cli/tests/workflow-host.test.ts": 10417, + "packages/code-review-agent/tests/doctor.test.ts": 475, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 515, + "packages/code-review-agent/tests/parse-diff.test.ts": 479, + "packages/code-review-agent/tests/parse-doctor.test.ts": 1140, + "packages/code-review-agent/tests/parse-oxlint.test.ts": 497, + "packages/code-review-agent/tests/policy.test.ts": 480, + "packages/core/tests/agent-api.test.ts": 502, + "packages/core/tests/agent-components.test.ts": 1931, + "packages/core/tests/agent-function-components.test.ts": 4063, + "packages/core/tests/agent-session-launch.test.ts": 3960, + "packages/core/tests/agent-session-placement.test.ts": 479, + "packages/core/tests/agent-session-use.test.ts": 466, + "packages/core/tests/agent-stack.test.ts": 1431, + "packages/core/tests/all.test.ts": 3019, + "packages/core/tests/answer-identity.test.ts": 540, + "packages/core/tests/answers-component.test.ts": 2237, + "packages/core/tests/answers-expansion-recursion.test.ts": 931, + "packages/core/tests/capture-errors.test.ts": 1209, + "packages/core/tests/capture-props.test.ts": 978, + "packages/core/tests/cli-journal.test.ts": 21006, + "packages/core/tests/code-block-component.test.ts": 2919, + "packages/core/tests/compiler-boundary.test.ts": 1110, + "packages/core/tests/component-api.test.ts": 528, + "packages/core/tests/component-registration.test.ts": 2007, + "packages/core/tests/component-returns.test.ts": 2678, + "packages/core/tests/component-schema-conformance.test.ts": 1106, + "packages/core/tests/config-api.test.ts": 1227, + "packages/core/tests/construct-error-observation.test.ts": 1287, + "packages/core/tests/contextual-cwd.test.ts": 2418, + "packages/core/tests/daemon-integration.test.ts": 1863, + "packages/core/tests/daemon.test.ts": 557, + "packages/core/tests/declared-markdown-component.test.ts": 3080, + "packages/core/tests/document-output-api.test.ts": 490, + "packages/core/tests/document-target-execution.test.ts": 4282, + "packages/core/tests/document-targets.test.ts": 1134, + "packages/core/tests/document-validation.test.ts": 2809, + "packages/core/tests/documentation-index.test.ts": 824, + "packages/core/tests/each.test.ts": 1213, + "packages/core/tests/elicit-component.test.ts": 1893, + "packages/core/tests/ephemeral-service.test.ts": 1713, + "packages/core/tests/eval-bindings.test.ts": 1218, + "packages/core/tests/eval-context.test.ts": 570, + "packages/core/tests/eval-durable.test.ts": 1244, + "packages/core/tests/eval-error-mode.test.ts": 1573, + "packages/core/tests/eval-interpolate.test.ts": 494, + "packages/core/tests/eval-middleware.test.ts": 567, + "packages/core/tests/eval-persist.test.ts": 1367, + "packages/core/tests/eval-return.test.ts": 1276, + "packages/core/tests/eval-scope.test.ts": 1124, + "packages/core/tests/eval-timeout.test.ts": 1196, + "packages/core/tests/eval-transform.test.ts": 555, + "packages/core/tests/evaluate-component.test.ts": 3337, + "packages/core/tests/evaluate-loaded-copy.test.ts": 1290, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 1385, + "packages/core/tests/evaluation-profile.test.ts": 848, + "packages/core/tests/exec-timeout.test.ts": 1287, + "packages/core/tests/execute.test.ts": 1847, + "packages/core/tests/execution-protocol.test.ts": 2693, + "packages/core/tests/expand.test.ts": 1884, + "packages/core/tests/expansion-identity.test.ts": 1918, + "packages/core/tests/expansion-metadata.test.ts": 983, + "packages/core/tests/expression-props.test.ts": 1780, + "packages/core/tests/fail-component.test.ts": 1544, + "packages/core/tests/failure-printing.test.ts": 1406, + "packages/core/tests/fatal-cause.test.ts": 862, + "packages/core/tests/fetch-component.test.ts": 2721, + "packages/core/tests/file-component.test.ts": 3220, + "packages/core/tests/file-delete-component.test.ts": 1426, + "packages/core/tests/files-fatal.test.ts": 2282, + "packages/core/tests/foreground-exec.test.ts": 2173, + "packages/core/tests/frontmatter.test.ts": 550, + "packages/core/tests/function-components.test.ts": 1897, + "packages/core/tests/generated-composition.test.ts": 1951, + "packages/core/tests/generated-xmd.test.ts": 3377, + "packages/core/tests/glob-api.test.ts": 605, + "packages/core/tests/glob-component.test.ts": 1985, + "packages/core/tests/guarded-journal.test.ts": 1284, + "packages/core/tests/has-content.test.ts": 897, + "packages/core/tests/heal.test.ts": 910, + "packages/core/tests/if.test.ts": 1732, + "packages/core/tests/inline-root.test.ts": 1290, + "packages/core/tests/invocation-failures.test.ts": 582, + "packages/core/tests/invocation-identity.test.ts": 2012, + "packages/core/tests/invocation-scope.test.ts": 1164, + "packages/core/tests/journal-source-position.test.ts": 1384, + "packages/core/tests/json-component.test.ts": 1499, + "packages/core/tests/json.test.ts": 486, + "packages/core/tests/let.test.ts": 1260, + "packages/core/tests/loop.test.ts": 2157, + "packages/core/tests/named-slots.test.ts": 2175, + "packages/core/tests/output-error-mode.test.ts": 2458, + "packages/core/tests/output-normalize.test.ts": 499, + "packages/core/tests/output-terminal.test.ts": 688, + "packages/core/tests/parse-components.test.ts": 1359, + "packages/core/tests/plan-response.test.ts": 589, + "packages/core/tests/plugin-api.test.ts": 506, + "packages/core/tests/plugin.test.ts": 979, + "packages/core/tests/props-binding.test.ts": 1367, + "packages/core/tests/protected-content.test.ts": 534, + "packages/core/tests/registered-printed-errors.test.ts": 859, + "packages/core/tests/replay-stream.test.ts": 483, + "packages/core/tests/retain.test.ts": 1109, + "packages/core/tests/root-composition.test.ts": 1324, + "packages/core/tests/root-props.test.ts": 1401, + "packages/core/tests/root-provider.test.ts": 1281, + "packages/core/tests/sample-component.test.ts": 2225, + "packages/core/tests/scanner.test.ts": 592, + "packages/core/tests/scope-local.test.ts": 490, + "packages/core/tests/secret-detection.test.ts": 2019, + "packages/core/tests/secret-files.test.ts": 739, + "packages/core/tests/secret-rules.test.ts": 830, + "packages/core/tests/secret-scanner-baseline.test.ts": 725, + "packages/core/tests/secret-scanner.test.ts": 684, + "packages/core/tests/source-position.test.ts": 1108, + "packages/core/tests/state-ownership.test.ts": 1191, + "packages/core/tests/streaming-emission.test.ts": 1124, + "packages/core/tests/switch.test.ts": 1819, + "packages/core/tests/syntax-catalog.test.ts": 1752, + "packages/core/tests/syntax-component.test.ts": 2548, + "packages/core/tests/temp-dir.test.ts": 3646, + "packages/core/tests/temp-file-compiler.test.ts": 542, + "packages/core/tests/text-interpolation.test.ts": 871, + "packages/core/tests/try-content.test.ts": 901, + "packages/core/tests/unused-in-diff.test.ts": 1302, + "packages/core/tests/validate.test.ts": 781, + "packages/core/tests/validation-integration.test.ts": 1147, + "packages/core/tests/workflow-component-bundle.test.ts": 1482, + "packages/durable-streams/tests/context.test.ts": 515, + "packages/durable-streams/tests/deterministic-id.test.ts": 541, + "packages/durable-streams/tests/divergence-api.test.ts": 523, + "packages/durable-streams/tests/divergence.test.ts": 552, + "packages/durable-streams/tests/durable-each.test.ts": 558, + "packages/durable-streams/tests/durable-position.test.ts": 526, + "packages/durable-streams/tests/durable-run.test.ts": 587, + "packages/durable-streams/tests/ephemeral.test.ts": 532, + "packages/durable-streams/tests/fail-stop.test.ts": 584, + "packages/durable-streams/tests/guard-stream.test.ts": 605, + "packages/durable-streams/tests/live-coordinator.test.ts": 538, + "packages/durable-streams/tests/parse.test.ts": 581, + "packages/durable-streams/tests/replay-guard.test.ts": 580, + "packages/durable-streams/tests/replay-index.test.ts": 530, + "packages/durable-streams/tests/retained.test.ts": 518, + "packages/durable-streams/tests/serialize-event.test.ts": 516, + "packages/durable-streams/tests/smoke.test.ts": 516, + "packages/durable-streams/tests/structured-concurrency.test.ts": 576, + "packages/durable-streams/tests/terminal-boundary.test.ts": 586, + "packages/durable-streams/tests/types.test.ts": 474, + "packages/git/tests/api-entrypoint.test.ts": 1354, + "packages/git/tests/git-host-effect.test.ts": 6680, + "packages/git/tests/git.test.ts": 1264, + "packages/git/tests/github-activation.test.ts": 1606, + "packages/git/tests/github-issues.test.ts": 1207, + "packages/git/tests/github-pull-requests.test.ts": 574, + "packages/git/tests/issue-markdown.test.ts": 9275, + "packages/git/tests/issue-records.test.ts": 1188, + "packages/git/tests/module-partition.test.ts": 2921, + "packages/git/tests/plugin.test.ts": 1674, + "packages/git/tests/provider-neutrality.test.ts": 2168, + "packages/git/tests/pull-request-records.test.ts": 1236, + "packages/git/tests/run-composition-lazy.test.ts": 1965, + "packages/git/tests/selection-authentication.test.ts": 482, + "packages/runtime/tests/duration.test.ts": 485, + "packages/runtime/tests/fetch.test.ts": 1101, + "packages/runtime/tests/fs.test.ts": 538, + "packages/runtime/tests/host-files.test.ts": 1252, + "packages/runtime/tests/native-launcher.test.ts": 2856, + "packages/runtime/tests/process.test.ts": 544, + "packages/runtime/tests/service.test.ts": 511, + "packages/test-agent/tests/acp-server.test.ts": 653, + "packages/test-agent/tests/behavior-engine.test.ts": 1514, + "packages/test-agent/tests/bridge.test.ts": 521, + "packages/test-agent/tests/command-lazy.test.ts": 6373, + "packages/test-agent/tests/components.test.ts": 102634, + "packages/test-agent/tests/controller.test.ts": 781, + "packages/test-agent/tests/cross-package-resolution.test.ts": 2750, + "packages/test-agent/tests/native-launch.test.ts": 52156, + "packages/test-agent/tests/net.test.ts": 592, + "packages/test-agent/tests/profile.test.ts": 1211, + "packages/test-agent/tests/protocol.test.ts": 553, + "packages/test-agent/tests/provider.test.ts": 1289, + "packages/test-agent/tests/public-api.test.ts": 1510, + "packages/test-agent/tests/route-slot.test.ts": 487, + "packages/test-agent/tests/smoke.test.ts": 27582, + "packages/test-agent/tests/template.test.ts": 1147, + "packages/test-agent/tests/worker-lifecycle.test.ts": 22671, + "packages/test-support/tests/journal.test.ts": 507, + "packages/testing/tests/agent-composition.test.ts": 1420, + "packages/testing/tests/assert-throws.test.ts": 2981, "packages/testing/tests/assert.test.ts": 500, - "packages/testing/tests/assertions.test.ts": 3776, - "packages/testing/tests/boundary-contract.test.ts": 2504, - "packages/testing/tests/cli.test.ts": 10930, - "packages/testing/tests/execution-harness.test.ts": 7583, - "packages/testing/tests/replay.test.ts": 3031, - "packages/testing/tests/smoke.test.ts": 3238, - "packages/testing/tests/test-component.test.ts": 3984, - "packages/testing/tests/testing-activation.test.ts": 3433, - "packages/testing/tests/testing-boundary.test.ts": 2039, - "packages/testing/tests/testing-mode.test.ts": 4040, - "packages/testing/tests/use-testing.test.ts": 2862, - "packages/web/tests/assets.test.ts": 478, - "packages/web/tests/client-logic.test.ts": 543, - "packages/web/tests/compile.test.ts": 2041, - "packages/web/tests/component.test.ts": 2472, - "packages/web/tests/declaration.test.ts": 1651, - "packages/web/tests/document.test.ts": 1811, - "packages/web/tests/elicitation.test.ts": 1657, - "packages/web/tests/live-form.test.ts": 1487, - "packages/web/tests/markdown.test.ts": 686, - "packages/web/tests/opener.test.ts": 1477, - "packages/web/tests/page.test.ts": 480, - "packages/web/tests/responder.test.ts": 1832, - "packages/web/tests/server-lifecycle.test.ts": 1727, - "packages/web/tests/server.test.ts": 2061, - "packages/workflow/tests/generated-observations.test.ts": 1458, - "packages/workflow/tests/retained-run.test.ts": 2195, - "packages/workflow/tests/service-denial.test.ts": 508, - "packages/workflow/tests/workflow-bundle.test.ts": 1638, - "packages/workflow/tests/workflow-definition.test.ts": 1319, - "packages/workflow/tests/workflow-fork.test.ts": 1209, - "packages/workflow/tests/workflow-run.test.ts": 2081, - "packages/workflow/tests/workspace-effect.test.ts": 3067, - "packages/workflow/tests/xmd-artifact.test.ts": 3662, - "scripts/tests/adapter-vendor.test.ts": 721, - "scripts/tests/bootstrap-npm-package.test.ts": 8295, - "scripts/tests/bump-version.test.ts": 611, - "scripts/tests/changed-paths.test.ts": 512, - "scripts/tests/ci-workflow.test.ts": 3638, - "scripts/tests/consumer-cycle.test.ts": 694, - "scripts/tests/filesystem-contract-workflow.test.ts": 587, - "scripts/tests/main-green.test.ts": 771, - "scripts/tests/main-health.test.ts": 619, - "scripts/tests/no-module-scoped-registry.test.ts": 1311, - "scripts/tests/no-redundant-test-scope.test.ts": 5343, - "scripts/tests/no-sync-filesystem.test.ts": 5664, - "scripts/tests/no-yield-in-finally.test.ts": 1317, - "scripts/tests/oxlint-policy.test.ts": 10912, - "scripts/tests/packaged-document.test.ts": 619, - "scripts/tests/prefer-effection-operation.test.ts": 6307, - "scripts/tests/prefer-effection-result.test.ts": 4550, - "scripts/tests/publish-workflow-membership.test.ts": 671, - "scripts/tests/review-infrastructure.test.ts": 3238, - "scripts/tests/runtime-exclusions.test.ts": 988, - "scripts/tests/runtime-tests.test.ts": 546, - "scripts/tests/scope-bound-event-registration.test.ts": 5495, - "scripts/tests/staged-write.test.ts": 537, - "scripts/tests/test-file-discovery.test.ts": 845, - "scripts/tests/test-shards.test.ts": 534, - "scripts/tests/test-weights.test.ts": 713, - "scripts/tests/tracked.test.ts": 542, - "scripts/tests/verify-coordinator.test.ts": 717, - "scripts/tests/version-lockstep.test.ts": 698, - "scripts/tests/web-client-module.test.ts": 530, - "scripts/tests/workspace.test.ts": 506 + "packages/testing/tests/assertions.test.ts": 3636, + "packages/testing/tests/boundary-contract.test.ts": 2406, + "packages/testing/tests/cli.test.ts": 10554, + "packages/testing/tests/execution-harness.test.ts": 7075, + "packages/testing/tests/replay.test.ts": 2871, + "packages/testing/tests/smoke.test.ts": 3096, + "packages/testing/tests/test-component.test.ts": 3648, + "packages/testing/tests/testing-activation.test.ts": 3235, + "packages/testing/tests/testing-boundary.test.ts": 1934, + "packages/testing/tests/testing-mode.test.ts": 3820, + "packages/testing/tests/use-testing.test.ts": 2676, + "packages/web/tests/assets.test.ts": 485, + "packages/web/tests/client-logic.test.ts": 530, + "packages/web/tests/compile.test.ts": 1889, + "packages/web/tests/component.test.ts": 2417, + "packages/web/tests/declaration.test.ts": 1633, + "packages/web/tests/document.test.ts": 1744, + "packages/web/tests/elicitation.test.ts": 1534, + "packages/web/tests/live-form.test.ts": 1433, + "packages/web/tests/markdown.test.ts": 672, + "packages/web/tests/opener.test.ts": 1487, + "packages/web/tests/page.test.ts": 489, + "packages/web/tests/responder.test.ts": 1728, + "packages/web/tests/server-lifecycle.test.ts": 1562, + "packages/web/tests/server.test.ts": 1935, + "packages/workflow/tests/generated-observations.test.ts": 1441, + "packages/workflow/tests/retained-run.test.ts": 2082, + "packages/workflow/tests/service-denial.test.ts": 516, + "packages/workflow/tests/workflow-bundle.test.ts": 1600, + "packages/workflow/tests/workflow-definition.test.ts": 1285, + "packages/workflow/tests/workflow-fork.test.ts": 1162, + "packages/workflow/tests/workflow-run.test.ts": 2045, + "packages/workflow/tests/workspace-effect.test.ts": 2882, + "packages/workflow/tests/xmd-artifact.test.ts": 3560, + "scripts/tests/adapter-vendor.test.ts": 698, + "scripts/tests/bootstrap-npm-package.test.ts": 8036, + "scripts/tests/bump-version.test.ts": 609, + "scripts/tests/changed-paths.test.ts": 517, + "scripts/tests/ci-workflow.test.ts": 3537, + "scripts/tests/consumer-cycle.test.ts": 665, + "scripts/tests/filesystem-contract-workflow.test.ts": 556, + "scripts/tests/main-green.test.ts": 769, + "scripts/tests/main-health.test.ts": 596, + "scripts/tests/no-module-scoped-registry.test.ts": 1334, + "scripts/tests/no-redundant-test-scope.test.ts": 5298, + "scripts/tests/no-sync-filesystem.test.ts": 5668, + "scripts/tests/no-yield-in-finally.test.ts": 1295, + "scripts/tests/oxlint-policy.test.ts": 10807, + "scripts/tests/packaged-document.test.ts": 595, + "scripts/tests/prefer-effection-operation.test.ts": 6357, + "scripts/tests/prefer-effection-result.test.ts": 4473, + "scripts/tests/publish-workflow-membership.test.ts": 660, + "scripts/tests/review-infrastructure.test.ts": 3047, + "scripts/tests/runtime-exclusions.test.ts": 957, + "scripts/tests/runtime-tests.test.ts": 534, + "scripts/tests/scope-bound-event-registration.test.ts": 5246, + "scripts/tests/staged-write.test.ts": 529, + "scripts/tests/test-file-discovery.test.ts": 796, + "scripts/tests/test-shards.test.ts": 536, + "scripts/tests/test-weights.test.ts": 679, + "scripts/tests/tracked.test.ts": 556, + "scripts/tests/verify-coordinator.test.ts": 706, + "scripts/tests/version-lockstep.test.ts": 677, + "scripts/tests/web-client-module.test.ts": 521, + "scripts/tests/workspace.test.ts": 497 }, "bun": { - "packages/acp/tests/acceptance.test.ts": 881, - "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4309, - "packages/acp/tests/acpx-materialization.test.ts": 6504, - "packages/acp/tests/acpx-transient-env.test.ts": 255, - "packages/acp/tests/adapter-materialization.test.ts": 70550, - "packages/acp/tests/adapter-protocol.test.ts": 53397, - "packages/acp/tests/codex-surfaces.test.ts": 124, - "packages/acp/tests/native-launch.test.ts": 1004, - "packages/acp/tests/permission-bridge.test.ts": 367, - "packages/acp/tests/provider.test.ts": 1110, - "packages/acp/tests/serial-queue.test.ts": 65, - "packages/acp/tests/session-key.test.ts": 70, + "packages/acp/tests/acceptance.test.ts": 764, + "packages/acp/tests/acpx-checkpoint-meta.test.ts": 4310, + "packages/acp/tests/acpx-materialization.test.ts": 6501, + "packages/acp/tests/acpx-transient-env.test.ts": 256, + "packages/acp/tests/adapter-materialization.test.ts": 69597, + "packages/acp/tests/adapter-protocol.test.ts": 51387, + "packages/acp/tests/codex-surfaces.test.ts": 125, + "packages/acp/tests/native-launch.test.ts": 966, + "packages/acp/tests/permission-bridge.test.ts": 350, + "packages/acp/tests/provider.test.ts": 1095, + "packages/acp/tests/serial-queue.test.ts": 66, + "packages/acp/tests/session-key.test.ts": 63, "packages/acp/tests/terminal-screen.test.ts": 92, - "packages/cli/tests/agent-adapters.test.ts": 1152, - "packages/cli/tests/agent-cli.test.ts": 21790, + "packages/cli/tests/agent-adapters.test.ts": 1126, + "packages/cli/tests/agent-cli.test.ts": 19668, "packages/cli/tests/agent-config.test.ts": 30, - "packages/cli/tests/agent-options-cli.test.ts": 8164, - "packages/cli/tests/agent-options.test.ts": 33, - "packages/cli/tests/agent-session-coordinator.test.ts": 396, - "packages/cli/tests/cli-help.test.ts": 15775, - "packages/cli/tests/command.test.ts": 7938, - "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 2930, - "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 106383, - "packages/cli/tests/document-suites/props/props-markdown.test.ts": 2384, - "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 5311, - "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 5016, - "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 9107, - "packages/cli/tests/evaluate-component.test.ts": 8013, - "packages/cli/tests/fetch-cli.test.ts": 2379, - "packages/cli/tests/file-stream.test.ts": 56, - "packages/cli/tests/inline-cli.test.ts": 32942, - "packages/cli/tests/launch-timeout.test.ts": 10096, - "packages/cli/tests/packaged-document.test.ts": 465, - "packages/cli/tests/plan-args.test.ts": 39, - "packages/cli/tests/plan-cli.test.ts": 61713, - "packages/cli/tests/plan-command-document.test.ts": 5437, - "packages/cli/tests/plan-component.test.ts": 8825, - "packages/cli/tests/plan-host-acts.test.ts": 1167, - "packages/cli/tests/plan.test.ts": 12886, - "packages/cli/tests/plugin-cli.test.ts": 39744, - "packages/cli/tests/plugin-host.test.ts": 2006, - "packages/cli/tests/plugin-modules.test.ts": 36, - "packages/cli/tests/plugin-selection.test.ts": 36, - "packages/cli/tests/process-retention.test.ts": 2607, - "packages/cli/tests/props-cli.test.ts": 29136, - "packages/cli/tests/props-schema.test.ts": 422, - "packages/cli/tests/props-sources.test.ts": 128, - "packages/cli/tests/repl-boundaries.test.ts": 1229, - "packages/cli/tests/repl-composition.test.ts": 145, - "packages/cli/tests/repl-execution.test.ts": 1935, - "packages/cli/tests/repl-journey.test.ts": 10392, - "packages/cli/tests/repl-model.test.ts": 1784, - "packages/cli/tests/repl-route.test.ts": 1261, - "packages/cli/tests/repl-terminal.test.ts": 821, - "packages/cli/tests/run-composition.test.ts": 3038, - "packages/cli/tests/run-deadline.test.ts": 2382, - "packages/cli/tests/run-profile.test.ts": 312, - "packages/cli/tests/run-timeouts.test.ts": 11377, - "packages/cli/tests/secret-detection-cli.test.ts": 18381, - "packages/cli/tests/service-document.test.ts": 1532, - "packages/cli/tests/service-host.test.ts": 1390, - "packages/cli/tests/session-launch-cli.test.ts": 6066, - "packages/cli/tests/stdin-cli.test.ts": 31362, - "packages/cli/tests/stdout-delivery.test.ts": 52, - "packages/cli/tests/syntax-cli.test.ts": 33188, - "packages/cli/tests/targets-cli.test.ts": 21933, - "packages/cli/tests/test-root-profile.test.ts": 3814, - "packages/cli/tests/test-target.test.ts": 36435, - "packages/cli/tests/testing-activation.test.ts": 578, - "packages/cli/tests/testing-execution-host.test.ts": 49246, - "packages/cli/tests/upgrade-cli.test.ts": 18227, - "packages/cli/tests/upgrade-command-document.test.ts": 3569, - "packages/cli/tests/upgrade-output.test.ts": 831, - "packages/cli/tests/value-root.test.ts": 15949, - "packages/cli/tests/verbose-component.test.ts": 820, - "packages/cli/tests/workflow-declaration.test.ts": 538, - "packages/cli/tests/workflow-host.test.ts": 4406, - "packages/code-review-agent/tests/doctor.test.ts": 30, - "packages/code-review-agent/tests/parse-diagnostics.test.ts": 39, + "packages/cli/tests/agent-options-cli.test.ts": 7421, + "packages/cli/tests/agent-options.test.ts": 34, + "packages/cli/tests/agent-session-coordinator.test.ts": 388, + "packages/cli/tests/cli-help.test.ts": 15087, + "packages/cli/tests/command.test.ts": 7762, + "packages/cli/tests/document-suites/inline/inline-markdown.test.ts": 2793, + "packages/cli/tests/document-suites/plan/plan-markdown.test.ts": 103624, + "packages/cli/tests/document-suites/props/props-markdown.test.ts": 2168, + "packages/cli/tests/document-suites/syntax/syntax-markdown.test.ts": 4746, + "packages/cli/tests/document-suites/targets/targets-markdown.test.ts": 4632, + "packages/cli/tests/document-suites/verbose/verbose-markdown.test.ts": 8337, + "packages/cli/tests/evaluate-component.test.ts": 7487, + "packages/cli/tests/fetch-cli.test.ts": 2230, + "packages/cli/tests/file-stream.test.ts": 64, + "packages/cli/tests/inline-cli.test.ts": 30588, + "packages/cli/tests/launch-timeout.test.ts": 10086, + "packages/cli/tests/packaged-document.test.ts": 457, + "packages/cli/tests/plan-args.test.ts": 35, + "packages/cli/tests/plan-cli.test.ts": 55503, + "packages/cli/tests/plan-command-document.test.ts": 5230, + "packages/cli/tests/plan-component.test.ts": 8517, + "packages/cli/tests/plan-host-acts.test.ts": 1117, + "packages/cli/tests/plan.test.ts": 12156, + "packages/cli/tests/plugin-cli.test.ts": 37649, + "packages/cli/tests/plugin-host.test.ts": 1910, + "packages/cli/tests/plugin-modules.test.ts": 37, + "packages/cli/tests/plugin-selection.test.ts": 37, + "packages/cli/tests/process-retention.test.ts": 2310, + "packages/cli/tests/props-cli.test.ts": 27589, + "packages/cli/tests/props-schema.test.ts": 408, + "packages/cli/tests/props-sources.test.ts": 123, + "packages/cli/tests/repl-admission.test.ts": 48, + "packages/cli/tests/repl-agent-execution.test.ts": 3041, + "packages/cli/tests/repl-boundaries.test.ts": 1135, + "packages/cli/tests/repl-composition.test.ts": 140, + "packages/cli/tests/repl-execution.test.ts": 1875, + "packages/cli/tests/repl-journey.test.ts": 10206, + "packages/cli/tests/repl-model.test.ts": 1738, + "packages/cli/tests/repl-route.test.ts": 1196, + "packages/cli/tests/repl-terminal.test.ts": 812, + "packages/cli/tests/run-composition.test.ts": 2849, + "packages/cli/tests/run-deadline.test.ts": 2336, + "packages/cli/tests/run-profile.test.ts": 305, + "packages/cli/tests/run-timeouts.test.ts": 11057, + "packages/cli/tests/secret-detection-cli.test.ts": 17489, + "packages/cli/tests/service-document.test.ts": 1530, + "packages/cli/tests/service-host.test.ts": 1388, + "packages/cli/tests/session-launch-cli.test.ts": 5391, + "packages/cli/tests/stdin-cli.test.ts": 29849, + "packages/cli/tests/stdout-delivery.test.ts": 50, + "packages/cli/tests/syntax-cli.test.ts": 31280, + "packages/cli/tests/targets-cli.test.ts": 20986, + "packages/cli/tests/test-root-profile.test.ts": 3561, + "packages/cli/tests/test-target.test.ts": 34618, + "packages/cli/tests/testing-activation.test.ts": 557, + "packages/cli/tests/testing-execution-host.test.ts": 47781, + "packages/cli/tests/upgrade-cli.test.ts": 17837, + "packages/cli/tests/upgrade-command-document.test.ts": 3818, + "packages/cli/tests/upgrade-output.test.ts": 779, + "packages/cli/tests/value-root.test.ts": 15186, + "packages/cli/tests/verbose-component.test.ts": 785, + "packages/cli/tests/workflow-declaration.test.ts": 562, + "packages/cli/tests/workflow-host.test.ts": 4014, + "packages/code-review-agent/tests/doctor.test.ts": 31, + "packages/code-review-agent/tests/parse-diagnostics.test.ts": 40, "packages/code-review-agent/tests/parse-diff.test.ts": 36, - "packages/code-review-agent/tests/parse-doctor.test.ts": 297, + "packages/code-review-agent/tests/parse-doctor.test.ts": 293, "packages/code-review-agent/tests/parse-oxlint.test.ts": 30, - "packages/code-review-agent/tests/policy.test.ts": 34, + "packages/code-review-agent/tests/policy.test.ts": 32, "packages/core/tests/agent-api.test.ts": 38, - "packages/core/tests/agent-components.test.ts": 1287, - "packages/core/tests/agent-function-components.test.ts": 3532, - "packages/core/tests/agent-session-launch.test.ts": 3433, - "packages/core/tests/agent-session-placement.test.ts": 38, - "packages/core/tests/agent-session-use.test.ts": 47, - "packages/core/tests/agent-stack.test.ts": 785, - "packages/core/tests/all.test.ts": 2166, - "packages/core/tests/answer-identity.test.ts": 68, - "packages/core/tests/answers-component.test.ts": 1361, - "packages/core/tests/answers-expansion-recursion.test.ts": 327, - "packages/core/tests/capture-errors.test.ts": 569, - "packages/core/tests/capture-props.test.ts": 337, - "packages/core/tests/cli-journal.test.ts": 10559, - "packages/core/tests/code-block-component.test.ts": 2336, - "packages/core/tests/compiler-boundary.test.ts": 352, - "packages/core/tests/component-api.test.ts": 57, - "packages/core/tests/component-registration.test.ts": 1096, - "packages/core/tests/component-returns.test.ts": 1602, - "packages/core/tests/component-schema-conformance.test.ts": 553, - "packages/core/tests/config-api.test.ts": 339, - "packages/core/tests/construct-error-observation.test.ts": 710, - "packages/core/tests/contextual-cwd.test.ts": 1531, - "packages/core/tests/daemon-integration.test.ts": 1026, + "packages/core/tests/agent-components.test.ts": 1180, + "packages/core/tests/agent-function-components.test.ts": 3691, + "packages/core/tests/agent-session-launch.test.ts": 3158, + "packages/core/tests/agent-session-placement.test.ts": 37, + "packages/core/tests/agent-session-use.test.ts": 31, + "packages/core/tests/agent-stack.test.ts": 676, + "packages/core/tests/all.test.ts": 2322, + "packages/core/tests/answer-identity.test.ts": 47, + "packages/core/tests/answers-component.test.ts": 1381, + "packages/core/tests/answers-expansion-recursion.test.ts": 315, + "packages/core/tests/capture-errors.test.ts": 555, + "packages/core/tests/capture-props.test.ts": 315, + "packages/core/tests/cli-journal.test.ts": 9779, + "packages/core/tests/code-block-component.test.ts": 2152, + "packages/core/tests/compiler-boundary.test.ts": 326, + "packages/core/tests/component-api.test.ts": 53, + "packages/core/tests/component-registration.test.ts": 1062, + "packages/core/tests/component-returns.test.ts": 1573, + "packages/core/tests/component-schema-conformance.test.ts": 510, + "packages/core/tests/config-api.test.ts": 348, + "packages/core/tests/construct-error-observation.test.ts": 666, + "packages/core/tests/contextual-cwd.test.ts": 1518, + "packages/core/tests/daemon-integration.test.ts": 1028, "packages/core/tests/daemon.test.ts": 72, - "packages/core/tests/declared-markdown-component.test.ts": 1988, - "packages/core/tests/document-output-api.test.ts": 38, - "packages/core/tests/document-target-execution.test.ts": 3473, - "packages/core/tests/document-targets.test.ts": 407, - "packages/core/tests/document-validation.test.ts": 2083, - "packages/core/tests/documentation-index.test.ts": 167, - "packages/core/tests/each.test.ts": 421, - "packages/core/tests/elicit-component.test.ts": 1083, - "packages/core/tests/ephemeral-service.test.ts": 831, - "packages/core/tests/eval-bindings.test.ts": 401, - "packages/core/tests/eval-context.test.ts": 86, - "packages/core/tests/eval-durable.test.ts": 426, - "packages/core/tests/eval-error-mode.test.ts": 690, - "packages/core/tests/eval-interpolate.test.ts": 39, + "packages/core/tests/declared-markdown-component.test.ts": 2118, + "packages/core/tests/document-output-api.test.ts": 39, + "packages/core/tests/document-target-execution.test.ts": 3322, + "packages/core/tests/document-targets.test.ts": 396, + "packages/core/tests/document-validation.test.ts": 1987, + "packages/core/tests/documentation-index.test.ts": 168, + "packages/core/tests/each.test.ts": 390, + "packages/core/tests/elicit-component.test.ts": 1065, + "packages/core/tests/ephemeral-service.test.ts": 798, + "packages/core/tests/eval-bindings.test.ts": 374, + "packages/core/tests/eval-context.test.ts": 88, + "packages/core/tests/eval-durable.test.ts": 418, + "packages/core/tests/eval-error-mode.test.ts": 681, + "packages/core/tests/eval-interpolate.test.ts": 38, "packages/core/tests/eval-middleware.test.ts": 76, - "packages/core/tests/eval-persist.test.ts": 506, - "packages/core/tests/eval-return.test.ts": 424, - "packages/core/tests/eval-scope.test.ts": 334, - "packages/core/tests/eval-timeout.test.ts": 433, - "packages/core/tests/eval-transform.test.ts": 74, - "packages/core/tests/evaluate-component.test.ts": 2514, - "packages/core/tests/evaluate-loaded-copy.test.ts": 517, - "packages/core/tests/evaluate-provider-lifetime.test.ts": 576, - "packages/core/tests/evaluation-profile.test.ts": 193, - "packages/core/tests/exec-timeout.test.ts": 502, - "packages/core/tests/execute.test.ts": 993, - "packages/core/tests/execution-protocol.test.ts": 1607, - "packages/core/tests/expand.test.ts": 1247, - "packages/core/tests/expansion-identity.test.ts": 1237, - "packages/core/tests/expansion-metadata.test.ts": 333, - "packages/core/tests/expression-props.test.ts": 891, - "packages/core/tests/fail-component.test.ts": 754, - "packages/core/tests/failure-printing.test.ts": 683, - "packages/core/tests/fatal-cause.test.ts": 188, - "packages/core/tests/fetch-component.test.ts": 1772, - "packages/core/tests/file-component.test.ts": 2425, - "packages/core/tests/file-delete-component.test.ts": 620, - "packages/core/tests/files-fatal.test.ts": 1468, - "packages/core/tests/foreground-exec.test.ts": 1236, - "packages/core/tests/frontmatter.test.ts": 48, - "packages/core/tests/function-components.test.ts": 912, - "packages/core/tests/generated-composition.test.ts": 1135, - "packages/core/tests/generated-xmd.test.ts": 2338, - "packages/core/tests/glob-api.test.ts": 99, - "packages/core/tests/glob-component.test.ts": 1236, - "packages/core/tests/guarded-journal.test.ts": 445, - "packages/core/tests/has-content.test.ts": 270, - "packages/core/tests/heal.test.ts": 266, - "packages/core/tests/if.test.ts": 827, - "packages/core/tests/inline-root.test.ts": 493, - "packages/core/tests/invocation-failures.test.ts": 104, - "packages/core/tests/invocation-identity.test.ts": 825, - "packages/core/tests/invocation-scope.test.ts": 550, - "packages/core/tests/journal-source-position.test.ts": 599, - "packages/core/tests/json-component.test.ts": 2337, + "packages/core/tests/eval-persist.test.ts": 481, + "packages/core/tests/eval-return.test.ts": 412, + "packages/core/tests/eval-scope.test.ts": 321, + "packages/core/tests/eval-timeout.test.ts": 413, + "packages/core/tests/eval-transform.test.ts": 62, + "packages/core/tests/evaluate-component.test.ts": 2441, + "packages/core/tests/evaluate-loaded-copy.test.ts": 500, + "packages/core/tests/evaluate-provider-lifetime.test.ts": 560, + "packages/core/tests/evaluation-profile.test.ts": 185, + "packages/core/tests/exec-timeout.test.ts": 459, + "packages/core/tests/execute.test.ts": 974, + "packages/core/tests/execution-protocol.test.ts": 1543, + "packages/core/tests/expand.test.ts": 1235, + "packages/core/tests/expansion-identity.test.ts": 1134, + "packages/core/tests/expansion-metadata.test.ts": 329, + "packages/core/tests/expression-props.test.ts": 854, + "packages/core/tests/fail-component.test.ts": 716, + "packages/core/tests/failure-printing.test.ts": 649, + "packages/core/tests/fatal-cause.test.ts": 189, + "packages/core/tests/fetch-component.test.ts": 1695, + "packages/core/tests/file-component.test.ts": 2361, + "packages/core/tests/file-delete-component.test.ts": 589, + "packages/core/tests/files-fatal.test.ts": 1459, + "packages/core/tests/foreground-exec.test.ts": 1197, + "packages/core/tests/frontmatter.test.ts": 49, + "packages/core/tests/function-components.test.ts": 886, + "packages/core/tests/generated-composition.test.ts": 1168, + "packages/core/tests/generated-xmd.test.ts": 2246, + "packages/core/tests/glob-api.test.ts": 95, + "packages/core/tests/glob-component.test.ts": 1182, + "packages/core/tests/guarded-journal.test.ts": 440, + "packages/core/tests/has-content.test.ts": 260, + "packages/core/tests/heal.test.ts": 252, + "packages/core/tests/if.test.ts": 778, + "packages/core/tests/inline-root.test.ts": 480, + "packages/core/tests/invocation-failures.test.ts": 101, + "packages/core/tests/invocation-identity.test.ts": 1014, + "packages/core/tests/invocation-scope.test.ts": 533, + "packages/core/tests/journal-source-position.test.ts": 543, + "packages/core/tests/json-component.test.ts": 2448, "packages/core/tests/json.test.ts": 35, - "packages/core/tests/let.test.ts": 423, - "packages/core/tests/loop.test.ts": 1241, - "packages/core/tests/named-slots.test.ts": 1274, - "packages/core/tests/output-error-mode.test.ts": 1565, + "packages/core/tests/let.test.ts": 424, + "packages/core/tests/loop.test.ts": 1161, + "packages/core/tests/named-slots.test.ts": 1189, + "packages/core/tests/output-error-mode.test.ts": 1516, "packages/core/tests/output-normalize.test.ts": 40, - "packages/core/tests/output-terminal.test.ts": 161, - "packages/core/tests/parse-components.test.ts": 664, - "packages/core/tests/plan-response.test.ts": 96, - "packages/core/tests/plugin-api.test.ts": 47, - "packages/core/tests/plugin.test.ts": 191, - "packages/core/tests/props-binding.test.ts": 533, - "packages/core/tests/protected-content.test.ts": 49, - "packages/core/tests/registered-printed-errors.test.ts": 214, - "packages/core/tests/replay-stream.test.ts": 35, - "packages/core/tests/retain.test.ts": 483, - "packages/core/tests/root-composition.test.ts": 572, - "packages/core/tests/root-props.test.ts": 569, - "packages/core/tests/root-provider.test.ts": 580, - "packages/core/tests/sample-component.test.ts": 1104, - "packages/core/tests/scanner.test.ts": 77, - "packages/core/tests/scope-local.test.ts": 35, - "packages/core/tests/secret-detection.test.ts": 1148, + "packages/core/tests/output-terminal.test.ts": 165, + "packages/core/tests/parse-components.test.ts": 603, + "packages/core/tests/plan-response.test.ts": 84, + "packages/core/tests/plugin-api.test.ts": 46, + "packages/core/tests/plugin.test.ts": 189, + "packages/core/tests/props-binding.test.ts": 574, + "packages/core/tests/protected-content.test.ts": 48, + "packages/core/tests/registered-printed-errors.test.ts": 207, + "packages/core/tests/replay-stream.test.ts": 37, + "packages/core/tests/retain.test.ts": 485, + "packages/core/tests/root-composition.test.ts": 629, + "packages/core/tests/root-props.test.ts": 606, + "packages/core/tests/root-provider.test.ts": 593, + "packages/core/tests/sample-component.test.ts": 1175, + "packages/core/tests/scanner.test.ts": 78, + "packages/core/tests/scope-local.test.ts": 93, + "packages/core/tests/secret-detection.test.ts": 1131, "packages/core/tests/secret-files.test.ts": 128, - "packages/core/tests/secret-rules.test.ts": 150, - "packages/core/tests/secret-scanner-baseline.test.ts": 152, - "packages/core/tests/secret-scanner.test.ts": 103, - "packages/core/tests/source-position.test.ts": 376, - "packages/core/tests/state-ownership.test.ts": 404, - "packages/core/tests/streaming-emission.test.ts": 332, - "packages/core/tests/switch.test.ts": 817, - "packages/core/tests/syntax-catalog.test.ts": 894, - "packages/core/tests/syntax-component.test.ts": 1764, - "packages/core/tests/temp-dir.test.ts": 2800, - "packages/core/tests/temp-file-compiler.test.ts": 74, - "packages/core/tests/text-interpolation.test.ts": 229, - "packages/core/tests/try-content.test.ts": 270, - "packages/core/tests/unused-in-diff.test.ts": 502, - "packages/core/tests/validate.test.ts": 322, - "packages/core/tests/validation-integration.test.ts": 366, - "packages/core/tests/workflow-component-bundle.test.ts": 706, - "packages/durable-streams/tests/context.test.ts": 35, - "packages/durable-streams/tests/deterministic-id.test.ts": 68, + "packages/core/tests/secret-rules.test.ts": 147, + "packages/core/tests/secret-scanner-baseline.test.ts": 160, + "packages/core/tests/secret-scanner.test.ts": 96, + "packages/core/tests/source-position.test.ts": 334, + "packages/core/tests/state-ownership.test.ts": 379, + "packages/core/tests/streaming-emission.test.ts": 313, + "packages/core/tests/switch.test.ts": 808, + "packages/core/tests/syntax-catalog.test.ts": 853, + "packages/core/tests/syntax-component.test.ts": 1845, + "packages/core/tests/temp-dir.test.ts": 2820, + "packages/core/tests/temp-file-compiler.test.ts": 84, + "packages/core/tests/text-interpolation.test.ts": 222, + "packages/core/tests/try-content.test.ts": 261, + "packages/core/tests/unused-in-diff.test.ts": 492, + "packages/core/tests/validate.test.ts": 329, + "packages/core/tests/validation-integration.test.ts": 353, + "packages/core/tests/workflow-component-bundle.test.ts": 739, + "packages/durable-streams/tests/context.test.ts": 38, + "packages/durable-streams/tests/deterministic-id.test.ts": 73, "packages/durable-streams/tests/divergence-api.test.ts": 46, - "packages/durable-streams/tests/divergence.test.ts": 56, - "packages/durable-streams/tests/durable-each.test.ts": 69, + "packages/durable-streams/tests/divergence.test.ts": 57, + "packages/durable-streams/tests/durable-each.test.ts": 67, "packages/durable-streams/tests/durable-position.test.ts": 58, - "packages/durable-streams/tests/durable-run.test.ts": 65, - "packages/durable-streams/tests/ephemeral.test.ts": 60, - "packages/durable-streams/tests/fail-stop.test.ts": 73, - "packages/durable-streams/tests/guard-stream.test.ts": 109, + "packages/durable-streams/tests/durable-run.test.ts": 66, + "packages/durable-streams/tests/ephemeral.test.ts": 62, + "packages/durable-streams/tests/fail-stop.test.ts": 77, + "packages/durable-streams/tests/guard-stream.test.ts": 104, "packages/durable-streams/tests/live-coordinator.test.ts": 59, - "packages/durable-streams/tests/parse.test.ts": 51, - "packages/durable-streams/tests/replay-guard.test.ts": 70, - "packages/durable-streams/tests/replay-index.test.ts": 48, - "packages/durable-streams/tests/retained.test.ts": 44, - "packages/durable-streams/tests/serialize-event.test.ts": 41, - "packages/durable-streams/tests/smoke.test.ts": 33, - "packages/durable-streams/tests/structured-concurrency.test.ts": 94, - "packages/durable-streams/tests/terminal-boundary.test.ts": 77, + "packages/durable-streams/tests/parse.test.ts": 50, + "packages/durable-streams/tests/replay-guard.test.ts": 71, + "packages/durable-streams/tests/replay-index.test.ts": 49, + "packages/durable-streams/tests/retained.test.ts": 45, + "packages/durable-streams/tests/serialize-event.test.ts": 66, + "packages/durable-streams/tests/smoke.test.ts": 69, + "packages/durable-streams/tests/structured-concurrency.test.ts": 91, + "packages/durable-streams/tests/terminal-boundary.test.ts": 76, "packages/durable-streams/tests/types.test.ts": 30, - "packages/git/tests/api-entrypoint.test.ts": 306, - "packages/git/tests/git-host-effect.test.ts": 6749, - "packages/git/tests/git.test.ts": 376, - "packages/git/tests/github-activation.test.ts": 697, - "packages/git/tests/github-issues.test.ts": 317, - "packages/git/tests/github-pull-requests.test.ts": 100, - "packages/git/tests/issue-markdown.test.ts": 8692, - "packages/git/tests/issue-records.test.ts": 314, - "packages/git/tests/module-partition.test.ts": 1883, - "packages/git/tests/plugin.test.ts": 799, - "packages/git/tests/provider-neutrality.test.ts": 1090, - "packages/git/tests/pull-request-records.test.ts": 332, - "packages/git/tests/run-composition-lazy.test.ts": 785, - "packages/git/tests/selection-authentication.test.ts": 39, + "packages/git/tests/api-entrypoint.test.ts": 313, + "packages/git/tests/git-host-effect.test.ts": 6206, + "packages/git/tests/git.test.ts": 351, + "packages/git/tests/github-activation.test.ts": 643, + "packages/git/tests/github-issues.test.ts": 314, + "packages/git/tests/github-pull-requests.test.ts": 99, + "packages/git/tests/issue-markdown.test.ts": 8221, + "packages/git/tests/issue-records.test.ts": 302, + "packages/git/tests/module-partition.test.ts": 1718, + "packages/git/tests/plugin.test.ts": 811, + "packages/git/tests/provider-neutrality.test.ts": 1018, + "packages/git/tests/pull-request-records.test.ts": 317, + "packages/git/tests/run-composition-lazy.test.ts": 755, + "packages/git/tests/selection-authentication.test.ts": 40, "packages/runtime/tests/duration.test.ts": 30, - "packages/runtime/tests/fetch.test.ts": 620, - "packages/runtime/tests/fs.test.ts": 78, - "packages/runtime/tests/host-files.test.ts": 732, - "packages/runtime/tests/native-launcher.test.ts": 2404, - "packages/runtime/tests/process.test.ts": 82, - "packages/runtime/tests/service.test.ts": 66, - "packages/test-agent/tests/acp-server.test.ts": 102, - "packages/test-agent/tests/behavior-engine.test.ts": 662, + "packages/runtime/tests/fetch.test.ts": 617, + "packages/runtime/tests/fs.test.ts": 69, + "packages/runtime/tests/host-files.test.ts": 723, + "packages/runtime/tests/native-launcher.test.ts": 2430, + "packages/runtime/tests/process.test.ts": 89, + "packages/runtime/tests/service.test.ts": 59, + "packages/test-agent/tests/acp-server.test.ts": 105, + "packages/test-agent/tests/behavior-engine.test.ts": 626, "packages/test-agent/tests/bridge.test.ts": 68, - "packages/test-agent/tests/command-lazy.test.ts": 3306, - "packages/test-agent/tests/components.test.ts": 52683, + "packages/test-agent/tests/command-lazy.test.ts": 3224, + "packages/test-agent/tests/components.test.ts": 51236, "packages/test-agent/tests/controller.test.ts": 197, - "packages/test-agent/tests/cross-package-resolution.test.ts": 1798, - "packages/test-agent/tests/native-launch.test.ts": 24490, - "packages/test-agent/tests/net.test.ts": 110, - "packages/test-agent/tests/profile.test.ts": 321, - "packages/test-agent/tests/protocol.test.ts": 61, - "packages/test-agent/tests/provider.test.ts": 405, - "packages/test-agent/tests/public-api.test.ts": 476, - "packages/test-agent/tests/route-slot.test.ts": 59, - "packages/test-agent/tests/smoke.test.ts": 15508, - "packages/test-agent/tests/template.test.ts": 295, - "packages/test-agent/tests/worker-lifecycle.test.ts": 9251, + "packages/test-agent/tests/cross-package-resolution.test.ts": 1762, + "packages/test-agent/tests/native-launch.test.ts": 23529, + "packages/test-agent/tests/net.test.ts": 104, + "packages/test-agent/tests/profile.test.ts": 302, + "packages/test-agent/tests/protocol.test.ts": 57, + "packages/test-agent/tests/provider.test.ts": 383, + "packages/test-agent/tests/public-api.test.ts": 467, + "packages/test-agent/tests/route-slot.test.ts": 58, + "packages/test-agent/tests/smoke.test.ts": 15120, + "packages/test-agent/tests/template.test.ts": 298, + "packages/test-agent/tests/worker-lifecycle.test.ts": 8954, "packages/test-support/tests/journal.test.ts": 41, - "packages/testing/tests/agent-composition.test.ts": 610, - "packages/testing/tests/assert-throws.test.ts": 2263, + "packages/testing/tests/agent-composition.test.ts": 596, + "packages/testing/tests/assert-throws.test.ts": 2134, "packages/testing/tests/assert.test.ts": 56, - "packages/testing/tests/assertions.test.ts": 2965, - "packages/testing/tests/boundary-contract.test.ts": 1659, - "packages/testing/tests/cli.test.ts": 4908, - "packages/testing/tests/execution-harness.test.ts": 6397, - "packages/testing/tests/replay.test.ts": 2206, - "packages/testing/tests/smoke.test.ts": 1784, - "packages/testing/tests/test-component.test.ts": 2982, - "packages/testing/tests/testing-activation.test.ts": 2597, - "packages/testing/tests/testing-boundary.test.ts": 1088, - "packages/testing/tests/testing-mode.test.ts": 3165, - "packages/testing/tests/use-testing.test.ts": 1880, + "packages/testing/tests/assertions.test.ts": 2821, + "packages/testing/tests/boundary-contract.test.ts": 1475, + "packages/testing/tests/cli.test.ts": 4823, + "packages/testing/tests/execution-harness.test.ts": 6332, + "packages/testing/tests/replay.test.ts": 2094, + "packages/testing/tests/smoke.test.ts": 1859, + "packages/testing/tests/test-component.test.ts": 2875, + "packages/testing/tests/testing-activation.test.ts": 2519, + "packages/testing/tests/testing-boundary.test.ts": 1070, + "packages/testing/tests/testing-mode.test.ts": 3021, + "packages/testing/tests/use-testing.test.ts": 1767, "packages/web/tests/assets.test.ts": 30, - "packages/web/tests/client-logic.test.ts": 37, - "packages/web/tests/compile.test.ts": 997, - "packages/web/tests/component.test.ts": 1441, - "packages/web/tests/declaration.test.ts": 649, - "packages/web/tests/document.test.ts": 791, - "packages/web/tests/elicitation.test.ts": 559, - "packages/web/tests/live-form.test.ts": 510, - "packages/web/tests/markdown.test.ts": 137, - "packages/web/tests/opener.test.ts": 508, - "packages/web/tests/page.test.ts": 32, - "packages/web/tests/responder.test.ts": 832, - "packages/web/tests/server-lifecycle.test.ts": 632, - "packages/web/tests/server.test.ts": 1031, - "packages/workflow/tests/generated-observations.test.ts": 533, - "packages/workflow/tests/retained-run.test.ts": 1282, - "packages/workflow/tests/service-denial.test.ts": 58, - "packages/workflow/tests/workflow-bundle.test.ts": 695, - "packages/workflow/tests/workflow-definition.test.ts": 348, - "packages/workflow/tests/workflow-fork.test.ts": 301, - "packages/workflow/tests/workflow-run.test.ts": 1066, - "packages/workflow/tests/workspace-effect.test.ts": 1462, - "scripts/tests/adapter-vendor.test.ts": 157, - "scripts/tests/bootstrap-npm-package.test.ts": 5374, - "scripts/tests/bump-version.test.ts": 88, - "scripts/tests/changed-paths.test.ts": 39, - "scripts/tests/ci-workflow.test.ts": 1886, - "scripts/tests/consumer-cycle.test.ts": 105, - "scripts/tests/filesystem-contract-workflow.test.ts": 82, - "scripts/tests/main-green.test.ts": 292, - "scripts/tests/main-health.test.ts": 107, - "scripts/tests/no-module-scoped-registry.test.ts": 856, - "scripts/tests/no-redundant-test-scope.test.ts": 4891, - "scripts/tests/no-sync-filesystem.test.ts": 4519, - "scripts/tests/no-yield-in-finally.test.ts": 884, - "scripts/tests/oxlint-policy.test.ts": 10422, - "scripts/tests/packaged-document.test.ts": 75, - "scripts/tests/prefer-effection-operation.test.ts": 5919, - "scripts/tests/prefer-effection-result.test.ts": 4091, - "scripts/tests/publish-workflow-membership.test.ts": 94, - "scripts/tests/review-infrastructure.test.ts": 1854, - "scripts/tests/runtime-exclusions.test.ts": 326, - "scripts/tests/runtime-tests.test.ts": 80, - "scripts/tests/scope-bound-event-registration.test.ts": 4515, - "scripts/tests/staged-write.test.ts": 69, - "scripts/tests/test-file-discovery.test.ts": 248, - "scripts/tests/test-shards.test.ts": 47, - "scripts/tests/test-weights.test.ts": 206, - "scripts/tests/tracked.test.ts": 39, - "scripts/tests/verify-coordinator.test.ts": 225, - "scripts/tests/version-lockstep.test.ts": 125, - "scripts/tests/web-client-module.test.ts": 96, - "scripts/tests/workspace.test.ts": 70 + "packages/web/tests/client-logic.test.ts": 73, + "packages/web/tests/compile.test.ts": 899, + "packages/web/tests/component.test.ts": 1400, + "packages/web/tests/declaration.test.ts": 620, + "packages/web/tests/document.test.ts": 744, + "packages/web/tests/elicitation.test.ts": 526, + "packages/web/tests/live-form.test.ts": 479, + "packages/web/tests/markdown.test.ts": 135, + "packages/web/tests/opener.test.ts": 464, + "packages/web/tests/page.test.ts": 33, + "packages/web/tests/responder.test.ts": 806, + "packages/web/tests/server-lifecycle.test.ts": 615, + "packages/web/tests/server.test.ts": 974, + "packages/workflow/tests/generated-observations.test.ts": 515, + "packages/workflow/tests/retained-run.test.ts": 1176, + "packages/workflow/tests/service-denial.test.ts": 67, + "packages/workflow/tests/workflow-bundle.test.ts": 678, + "packages/workflow/tests/workflow-definition.test.ts": 334, + "packages/workflow/tests/workflow-fork.test.ts": 296, + "packages/workflow/tests/workflow-run.test.ts": 1058, + "packages/workflow/tests/workspace-effect.test.ts": 1372, + "scripts/tests/adapter-vendor.test.ts": 149, + "scripts/tests/bootstrap-npm-package.test.ts": 5126, + "scripts/tests/bump-version.test.ts": 84, + "scripts/tests/changed-paths.test.ts": 40, + "scripts/tests/ci-workflow.test.ts": 1841, + "scripts/tests/consumer-cycle.test.ts": 107, + "scripts/tests/filesystem-contract-workflow.test.ts": 78, + "scripts/tests/main-green.test.ts": 286, + "scripts/tests/main-health.test.ts": 97, + "scripts/tests/no-module-scoped-registry.test.ts": 853, + "scripts/tests/no-redundant-test-scope.test.ts": 4848, + "scripts/tests/no-sync-filesystem.test.ts": 4429, + "scripts/tests/no-yield-in-finally.test.ts": 865, + "scripts/tests/oxlint-policy.test.ts": 10340, + "scripts/tests/packaged-document.test.ts": 78, + "scripts/tests/prefer-effection-operation.test.ts": 5942, + "scripts/tests/prefer-effection-result.test.ts": 4046, + "scripts/tests/publish-workflow-membership.test.ts": 96, + "scripts/tests/review-infrastructure.test.ts": 1758, + "scripts/tests/runtime-exclusions.test.ts": 297, + "scripts/tests/runtime-tests.test.ts": 75, + "scripts/tests/scope-bound-event-registration.test.ts": 4413, + "scripts/tests/staged-write.test.ts": 65, + "scripts/tests/test-file-discovery.test.ts": 227, + "scripts/tests/test-shards.test.ts": 44, + "scripts/tests/test-weights.test.ts": 203, + "scripts/tests/tracked.test.ts": 38, + "scripts/tests/verify-coordinator.test.ts": 272, + "scripts/tests/version-lockstep.test.ts": 114, + "scripts/tests/web-client-module.test.ts": 67, + "scripts/tests/workspace.test.ts": 47 } } } From 47ff2d6074e7f6dc70766123a311f3958b3e27b1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 30 Sep 2026 06:52:35 -0400 Subject: [PATCH 9/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Make=20the=20REPL's=20?= =?UTF-8?q?live=20Agent=20owner=20a=20resource=20with=20a=20real=20lifetim?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useReplAgent()` built the session's mutable live-turn owner — announcements, the turns and requests presented right now, which publication is appending, and who is waiting on the first failure — and handed it back as a plain object. Its comment said all of that belonged to the creating scope and died with it. Nothing established that: the scope was never asked for, so nothing was tied to it and nothing was released. It is a resource now, acquired with `yield* useReplAgent(...)`. Releasing the scope retires the owner, and retiring answers nothing and releases nothing: a permission wait still held is abandoned rather than denied, because the scope that raised it is going away too and a decision invented on the way out is a decision nobody made. Whoever was waiting on `failed` is left alone for the same reason — there is no failure to report. `installation.install()` stays a separate, explicit act. Installing the middleware inside the resource body would put it in the resource's own child scope, which the execution started afterwards does not inherit. The resource owns the kernel; the execution installation places the contextual middleware. Two rows, each red under its own defect: - releasing the owning scope retires the owner — red when the teardown retires nothing, because the live turn is still presented afterwards; - retiring wakes nobody waiting on `failed` — red when the teardown settles the held request and resolves the waiters instead of dropping them. --- packages/cli/src/repl/agent.ts | 742 +++++++++--------- packages/cli/src/repl/session.ts | 2 +- .../cli/tests/repl-agent-execution.test.ts | 64 +- 3 files changed, 450 insertions(+), 358 deletions(-) diff --git a/packages/cli/src/repl/agent.ts b/packages/cli/src/repl/agent.ts index 0f9a3110a..debea38a9 100644 --- a/packages/cli/src/repl/agent.ts +++ b/packages/cli/src/repl/agent.ts @@ -55,7 +55,7 @@ * answer requests correctly. */ -import { action, createSignal, useScope } from "effection"; +import { action, createSignal, resource, useScope } from "effection"; import type { Operation, Scope, Stream } from "effection"; import { Agent, denyPermission } from "@executablemd/core"; import type { @@ -279,388 +279,418 @@ function* currentCoroutine(): Operation { } /** - * Create the private live Agent owner for one session. + * The private live Agent owner for one session, for as long as that session. * - * The owner is created here and installed into the execution through - * `installation`, so everything it holds belongs to the scope that created it - * and dies with that scope — including the middleware, which an execution - * elsewhere would otherwise inherit. + * A resource, because everything it holds is mutable and session-scoped: + * announcements, the turns and requests presented right now, which publication + * is appending, and who is waiting on the first failure. Acquiring it ties all + * of that to the acquiring scope, and releasing that scope retires it. + * + * Retiring answers nothing and releases nothing. A permission wait still held + * at teardown is abandoned, not denied — the scope that raised it is going away + * too, and a decision invented on the way out would be a decision nobody made. + * The same goes for whoever was waiting on `failed`: there is no failure to + * report, so no one is woken. + * + * `installation` stays a separate, explicit act. Installing the middleware in + * this body would put it in the resource's own child scope, which the execution + * started afterwards does not inherit: the resource owns the kernel, and the + * execution installation is what places the contextual middleware. */ -export function useReplAgent(mode: PermissionMode): ReplAgentKernel { - const changes = createSignal(); - const turns: LiveTurn[] = []; - const requests: LiveRequest[] = []; - /** The live turn core began in a scope, until that scope's prompt claims it. */ - const begun = new Map(); - /** - * The live turn each handle this owner minted stands for. - * - * The handle is an object of this owner's own making, so a value from - * anywhere else simply is not a key here — which is how a handle is read - * back without asserting anything about what it is. - */ - const minted = new WeakMap(); - /** - * The canonical publication appending right now on each coroutine. - * - * The handle is what identifies the turn; this only says which of several - * concurrent publications an append belongs to. At most one canonical - * publication is ever in flight per coroutine, because expansion inside one - * coroutine is strictly sequential — so this is an index, never a queue, and - * it holds nothing between transitions. - */ - const publishing = new Map(); - let reading: ReplAgentReading = Object.freeze({ - turns: Object.freeze([]), - requests: Object.freeze([]), - }); - let keys = 0; - let failure: Error | undefined; - const failures: Array<(error: Error) => void> = []; - - function allocate(prefix: string): string { - keys += 1; - return `${prefix}-${keys}`; - } - - function project(): void { - reading = Object.freeze({ - turns: Object.freeze(turns.map(frozenTurn)), - requests: Object.freeze(requests.map(frozenRequest)), +export function useReplAgent(mode: PermissionMode): Operation { + return resource(function* (provide) { + const changes = createSignal(); + const turns: LiveTurn[] = []; + const requests: LiveRequest[] = []; + /** The live turn core began in a scope, until that scope's prompt claims it. */ + const begun = new Map(); + /** + * The live turn each handle this owner minted stands for. + * + * The handle is an object of this owner's own making, so a value from + * anywhere else simply is not a key here — which is how a handle is read + * back without asserting anything about what it is. + */ + const minted = new WeakMap(); + /** + * The canonical publication appending right now on each coroutine. + * + * The handle is what identifies the turn; this only says which of several + * concurrent publications an append belongs to. At most one canonical + * publication is ever in flight per coroutine, because expansion inside one + * coroutine is strictly sequential — so this is an index, never a queue, and + * it holds nothing between transitions. + */ + const publishing = new Map(); + let reading: ReplAgentReading = Object.freeze({ + turns: Object.freeze([]), + requests: Object.freeze([]), }); - } - - function announce(): void { - project(); - changes.send(reading); - } + let keys = 0; + let failure: Error | undefined; + const failures: Array<(error: Error) => void> = []; - /** - * Record the first failure that must end this session, and report it. - * - * Reported to whoever is waiting rather than thrown here: the caller also has - * to raise it into the operation that caused it, and the two are different - * deliveries of one failure. - */ - function fail(error: Error): Error { - if (failure === undefined) { - failure = error; - for (const waiting of failures) { - waiting(error); - } - failures.length = 0; + function allocate(prefix: string): string { + keys += 1; + return `${prefix}-${keys}`; } - return failure; - } - /** Take one live turn down, leaving the announcement to the caller. */ - function retire(turn: LiveTurn): void { - const at = turns.indexOf(turn); - if (at >= 0) { - turns.splice(at, 1); + function project(): void { + reading = Object.freeze({ + turns: Object.freeze(turns.map(frozenTurn)), + requests: Object.freeze(requests.map(frozenRequest)), + }); } - } - function queued(coroutine: string, prompt: string): LiveTurn { - const turn: LiveTurn = { - key: allocate("turn"), - coroutine, - prompt, - state: "queued", - text: "", - agent: undefined, - sessionKey: undefined, - agentSessionId: undefined, - status: undefined, - stopReason: undefined, - failure: undefined, - }; - turns.push(turn); - announce(); - return turn; - } + function announce(): void { + project(); + changes.send(reading); + } - /** Copy one provider event's facts into the reading, changing nothing else. */ - function observed(turn: LiveTurn, event: AgentPromptEvent): void { - if (event.type === "started") { - turn.state = "active"; - turn.agent = event.agent; - turn.sessionKey = event.session.sessionKey; - turn.agentSessionId = event.session.agentSessionId; - } else if (event.type === "text_delta") { - turn.state = "active"; - turn.text += event.text; - } else { - turn.state = "terminal"; - turn.status = event.status; - turn.stopReason = event.stopReason; - turn.failure = event.error?.message; + /** + * Record the first failure that must end this session, and report it. + * + * Reported to whoever is waiting rather than thrown here: the caller also has + * to raise it into the operation that caused it, and the two are different + * deliveries of one failure. + */ + function fail(error: Error): Error { + if (failure === undefined) { + failure = error; + for (const waiting of failures) { + waiting(error); + } + failures.length = 0; + } + return failure; } - announce(); - } - /** - * Wrap the provider's cold stream so subscribing still starts exactly one - * turn, owned by ``. - * - * Nothing is collected, pre-read or replaced: each event travels back the - * moment it arrives, as the same object the provider produced, and the final - * value is the provider's own. - */ - function watch( - turn: LiveTurn, - stream: Stream, - ): Stream { - return { - *[Symbol.iterator]() { - const subscription = yield* stream; - return { - *next() { - const next = yield* subscription.next(); - if (!next.done) { - observed(turn, next.value); - } - return next; - }, - }; - }, - }; - } + /** Take one live turn down, leaving the announcement to the caller. */ + function retire(turn: LiveTurn): void { + const at = turns.indexOf(turn); + if (at >= 0) { + turns.splice(at, 1); + } + } - /** The live turn a permission request on this coroutine belongs to. */ - function owner(coroutine: string): LiveTurn { - const candidates = turns.filter( - (turn) => turn.coroutine === coroutine && turn.state !== "terminal", - ); - const only = candidates[0]; - if (only === undefined || candidates.length > 1) { - throw fail( - new ReplPermissionOwnerError( - candidates.length > 1 - ? "an interactive permission request arrived where more than one live Prompt turn " + - "could own it, so the session cannot say which conversation is asking." - : "an interactive permission request arrived with no live Prompt turn to own it, so " + - "the session cannot present or answer it.", - ), - ); + function queued(coroutine: string, prompt: string): LiveTurn { + const turn: LiveTurn = { + key: allocate("turn"), + coroutine, + prompt, + state: "queued", + text: "", + agent: undefined, + sessionKey: undefined, + agentSessionId: undefined, + status: undefined, + stopReason: undefined, + failure: undefined, + }; + turns.push(turn); + announce(); + return turn; } - return only; - } - function remove(request: LiveRequest): void { - const at = requests.indexOf(request); - if (at >= 0) { - requests.splice(at, 1); + /** Copy one provider event's facts into the reading, changing nothing else. */ + function observed(turn: LiveTurn, event: AgentPromptEvent): void { + if (event.type === "started") { + turn.state = "active"; + turn.agent = event.agent; + turn.sessionKey = event.session.sessionKey; + turn.agentSessionId = event.session.agentSessionId; + } else if (event.type === "text_delta") { + turn.state = "active"; + turn.text += event.text; + } else { + turn.state = "terminal"; + turn.status = event.status; + turn.stopReason = event.stopReason; + turn.failure = event.error?.message; + } + announce(); } - } - function* interactive(request: PermissionRequest): Operation { - // Decided before anything is published: an unowned request publishes no - // reading and no key, and never becomes a denial. - const held = owner(yield* currentCoroutine()); - return yield* action(function (resolve) { - let settled = false; - const live: LiveRequest = { - key: allocate("request"), - turn: held.key, - toolCallId: request.toolCall.toolCallId, - title: request.toolCall.title, - kind: request.toolCall.kind, - choices: offeredChoices(request), - request, - settle(outcome: PermissionOutcome): void { - if (settled) { - return; - } - settled = true; - remove(live); - announce(); - resolve(outcome); + /** + * Wrap the provider's cold stream so subscribing still starts exactly one + * turn, owned by ``. + * + * Nothing is collected, pre-read or replaced: each event travels back the + * moment it arrives, as the same object the provider produced, and the final + * value is the provider's own. + */ + function watch( + turn: LiveTurn, + stream: Stream, + ): Stream { + return { + *[Symbol.iterator]() { + const subscription = yield* stream; + return { + *next() { + const next = yield* subscription.next(); + if (!next.done) { + observed(turn, next.value); + } + return next; + }, + }; }, }; - // Whatever ends this — an answer, a dismissal, teardown, a failure - // upstream — the reading disappears exactly when the wait does. Returned - // as `action`'s own cleanup, which is registered before this body's - // caller can suspend, and the wait is never resolved synchronously. - const dispose = (): void => { - if (!settled) { - settled = true; - remove(live); - announce(); - } - }; - requests.push(live); - announce(); - return dispose; - }); - } + } - /** - * The selected mode, applied exactly, with every other kind asked. - * - * A mode that cannot approve denies through Core's own `denyPermission` - * rather than a rule spelled again here, so an automatic denial is the same - * decision the base handler would have reached. - */ - function* decide(request: PermissionRequest): Operation { - if (mode === "approve-all") { - return approval(request) ?? denyPermission(request); + /** The live turn a permission request on this coroutine belongs to. */ + function owner(coroutine: string): LiveTurn { + const candidates = turns.filter( + (turn) => turn.coroutine === coroutine && turn.state !== "terminal", + ); + const only = candidates[0]; + if (only === undefined || candidates.length > 1) { + throw fail( + new ReplPermissionOwnerError( + candidates.length > 1 + ? "an interactive permission request arrived where more than one live Prompt turn " + + "could own it, so the session cannot say which conversation is asking." + : "an interactive permission request arrived with no live Prompt turn to own it, so " + + "the session cannot present or answer it.", + ), + ); + } + return only; } - if (mode === "deny-all") { - return denyPermission(request); + + function remove(request: LiveRequest): void { + const at = requests.indexOf(request); + if (at >= 0) { + requests.splice(at, 1); + } } - if (isRead(request)) { - return approval(request) ?? denyPermission(request); + + function* interactive(request: PermissionRequest): Operation { + // Decided before anything is published: an unowned request publishes no + // reading and no key, and never becomes a denial. + const held = owner(yield* currentCoroutine()); + return yield* action(function (resolve) { + let settled = false; + const live: LiveRequest = { + key: allocate("request"), + turn: held.key, + toolCallId: request.toolCall.toolCallId, + title: request.toolCall.title, + kind: request.toolCall.kind, + choices: offeredChoices(request), + request, + settle(outcome: PermissionOutcome): void { + if (settled) { + return; + } + settled = true; + remove(live); + announce(); + resolve(outcome); + }, + }; + // Whatever ends this — an answer, a dismissal, teardown, a failure + // upstream — the reading disappears exactly when the wait does. Returned + // as `action`'s own cleanup, which is registered before this body's + // caller can suspend, and the wait is never resolved synchronously. + const dispose = (): void => { + if (!settled) { + settled = true; + remove(live); + announce(); + } + }; + requests.push(live); + announce(); + return dispose; + }); } - return yield* interactive(request); - } - const authority: ReplAgentAuthority = { - choose(request: string, option: string): boolean { - const live = requests.find((candidate) => candidate.key === request); - if (live === undefined) { - return false; - } - // Only what the provider offered: a stray identifier settles nothing - // rather than selecting an option this turn was never given. - if (!live.choices.some((choice) => choice.optionId === option)) { - return false; + /** + * The selected mode, applied exactly, with every other kind asked. + * + * A mode that cannot approve denies through Core's own `denyPermission` + * rather than a rule spelled again here, so an automatic denial is the same + * decision the base handler would have reached. + */ + function* decide(request: PermissionRequest): Operation { + if (mode === "approve-all") { + return approval(request) ?? denyPermission(request); } - live.settle({ outcome: "selected", optionId: option }); - return true; - }, - dismiss(request: string): boolean { - const live = requests.find((candidate) => candidate.key === request); - if (live === undefined) { - return false; + if (mode === "deny-all") { + return denyPermission(request); } - // A dismissal while the session continues is a denial the provider turn - // resumes with, decided by the one authoritative rule. - live.settle(denyPermission(live.request)); - return true; - }, - }; - - const publisher: AgentPromptPublisher = { - *begin(input: string): Operation { - // Created before the provider is asked for anything at all, and handed - // back on this turn's own publication — the only thing that will say - // which live turn that record ended. - const turn = queued(yield* currentCoroutine(), input); - begun.set(yield* useScope(), turn); - // An opaque token rather than the turn itself: core carries it back - // untouched, and only this map can say what it stood for. - const handle: object = {}; - minted.set(handle, turn); - return handle; - }, - *publish(publication: AgentPromptPublication): Operation { - const handle = publication.begun; - // A handle this owner did not mint is not a key in this map: another - // host's publisher, or a turn from an execution this session never ran. - // Read back by lookup, never by asserting what the value is. - const turn = typeof handle === "object" && handle !== null ? minted.get(handle) : undefined; - const where = turn?.coroutine; - if (turn !== undefined && where !== undefined) { - publishing.set(where, turn); + if (isRead(request)) { + return approval(request) ?? denyPermission(request); } - try { - // The single durable handoff. `consume()` runs inside this append, in - // the caller's one transition, and removes exactly this turn. - yield* publication.append(); - } catch (error) { - // Nothing was retained, so nothing may still be shown as though it is - // about to be. The turn this publication began is taken down and the - // removal announced before the failure travels on — otherwise a - // terminal overlay outlives the record it was waiting for. - if (turn !== undefined) { - retire(turn); - announce(); + return yield* interactive(request); + } + + const authority: ReplAgentAuthority = { + choose(request: string, option: string): boolean { + const live = requests.find((candidate) => candidate.key === request); + if (live === undefined) { + return false; } - throw error; - } finally { - if (where !== undefined) { - publishing.delete(where); + // Only what the provider offered: a stray identifier settles nothing + // rather than selecting an option this turn was never given. + if (!live.choices.some((choice) => choice.optionId === option)) { + return false; } - } - }, - }; - - const installation: ExecutionInstallation = { - *install(): Operation { - // At the ordinary position, not `min`. A provider installs its own - // handlers innermost and answers without delegating, so an observer - // installed there would never see the call it exists to wrap — and a - // policy installed there would be decided for, by whatever the provider - // brought with it. Outermost is where this session's own authority goes: - // it wraps the provider's stream, and it decides permission before - // anything inherited can. - yield* useAgentPromptPublisher(publisher); - yield* Agent.around({ - *prompt([text, options], next) { - // Only the turn core began in this exact scope is journal-owned work. - // A registered component calling the public `Agent.prompt()` arrives - // here having begun nothing: it is delegated untouched, shown in no - // reading, and left unable to claim any record. - const scope = yield* useScope(); - const turn = begun.get(scope); - begun.delete(scope); - const stream = yield* next(text, options); - return turn === undefined ? stream : watch(turn, stream); - }, - *requestPermission([request]) { - return yield* decide(request); - }, - }); - }, - }; - - return { - get reading() { - return reading; - }, - changes, - authority, - installation, - publisher, - consume(event: DurableEvent): void { - if (event.type !== "yield" || event.description.type !== AGENT_PROMPT) { - return; - } - const turn = publishing.get(event.coroutineId); - if (turn === undefined) { - // An `agent_prompt` appended where this process observed no turn. The - // session has already been admitted, so there is nothing left to refuse - // atomically: the owner is terminated instead of guessing which reading - // this record replaced. - throw fail( - new ReplAgentCorrelationError( - "an agent turn was recorded that this session never observed, so its live view " + - "cannot be reconciled with the journal.", - ), - ); - } - retire(turn); - project(); - }, - announce, - get failed(): Operation { - return { - *[Symbol.iterator]() { - if (failure !== undefined) { - return failure; + live.settle({ outcome: "selected", optionId: option }); + return true; + }, + dismiss(request: string): boolean { + const live = requests.find((candidate) => candidate.key === request); + if (live === undefined) { + return false; + } + // A dismissal while the session continues is a denial the provider turn + // resumes with, decided by the one authoritative rule. + live.settle(denyPermission(live.request)); + return true; + }, + }; + + const publisher: AgentPromptPublisher = { + *begin(input: string): Operation { + // Created before the provider is asked for anything at all, and handed + // back on this turn's own publication — the only thing that will say + // which live turn that record ended. + const turn = queued(yield* currentCoroutine(), input); + begun.set(yield* useScope(), turn); + // An opaque token rather than the turn itself: core carries it back + // untouched, and only this map can say what it stood for. + const handle: object = {}; + minted.set(handle, turn); + return handle; + }, + *publish(publication: AgentPromptPublication): Operation { + const handle = publication.begun; + // A handle this owner did not mint is not a key in this map: another + // host's publisher, or a turn from an execution this session never ran. + // Read back by lookup, never by asserting what the value is. + const turn = typeof handle === "object" && handle !== null ? minted.get(handle) : undefined; + const where = turn?.coroutine; + if (turn !== undefined && where !== undefined) { + publishing.set(where, turn); + } + try { + // The single durable handoff. `consume()` runs inside this append, in + // the caller's one transition, and removes exactly this turn. + yield* publication.append(); + } catch (error) { + // Nothing was retained, so nothing may still be shown as though it is + // about to be. The turn this publication began is taken down and the + // removal announced before the failure travels on — otherwise a + // terminal overlay outlives the record it was waiting for. + if (turn !== undefined) { + retire(turn); + announce(); } - return yield* action(function (resolve) { - failures.push(resolve); - return () => { - const at = failures.indexOf(resolve); - if (at >= 0) { - failures.splice(at, 1); - } - }; - }); - }, - }; - }, - }; + throw error; + } finally { + if (where !== undefined) { + publishing.delete(where); + } + } + }, + }; + + const installation: ExecutionInstallation = { + *install(): Operation { + // At the ordinary position, not `min`. A provider installs its own + // handlers innermost and answers without delegating, so an observer + // installed there would never see the call it exists to wrap — and a + // policy installed there would be decided for, by whatever the provider + // brought with it. Outermost is where this session's own authority goes: + // it wraps the provider's stream, and it decides permission before + // anything inherited can. + yield* useAgentPromptPublisher(publisher); + yield* Agent.around({ + *prompt([text, options], next) { + // Only the turn core began in this exact scope is journal-owned work. + // A registered component calling the public `Agent.prompt()` arrives + // here having begun nothing: it is delegated untouched, shown in no + // reading, and left unable to claim any record. + const scope = yield* useScope(); + const turn = begun.get(scope); + begun.delete(scope); + const stream = yield* next(text, options); + return turn === undefined ? stream : watch(turn, stream); + }, + *requestPermission([request]) { + return yield* decide(request); + }, + }); + }, + }; + + const kernel: ReplAgentKernel = { + get reading() { + return reading; + }, + changes, + authority, + installation, + publisher, + consume(event: DurableEvent): void { + if (event.type !== "yield" || event.description.type !== AGENT_PROMPT) { + return; + } + const turn = publishing.get(event.coroutineId); + if (turn === undefined) { + // An `agent_prompt` appended where this process observed no turn. The + // session has already been admitted, so there is nothing left to refuse + // atomically: the owner is terminated instead of guessing which reading + // this record replaced. + throw fail( + new ReplAgentCorrelationError( + "an agent turn was recorded that this session never observed, so its live view " + + "cannot be reconciled with the journal.", + ), + ); + } + retire(turn); + project(); + }, + announce, + get failed(): Operation { + return { + *[Symbol.iterator]() { + if (failure !== undefined) { + return failure; + } + return yield* action(function (resolve) { + failures.push(resolve); + return () => { + const at = failures.indexOf(resolve); + if (at >= 0) { + failures.splice(at, 1); + } + }; + }); + }, + }; + }, + }; + + try { + yield* provide(kernel); + } finally { + // Dropped, never called: a held `settle` would answer a request nobody + // decided, and a waiting `failed` resolver would report a failure that + // never happened. Both are abandoned with the scope that owned them. + requests.length = 0; + failures.length = 0; + turns.length = 0; + begun.clear(); + publishing.clear(); + // Projected, not announced. A reader still holding this owner sees a + // retired one; nothing is pushed into consumers that are themselves going + // away. + project(); + } + }); } diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index fb2c35b09..cc25c7d68 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -314,7 +314,7 @@ function* start( // owns belongs to this session's scope and dies with it. An execution // elsewhere would otherwise inherit an observer watching for a session that // is gone. - const agent = useReplAgent(permissionMode ?? "deny-all"); + const agent = yield* useReplAgent(permissionMode ?? "deny-all"); function reproject(): void { const next = projectRepl(retained, selection); diff --git a/packages/cli/tests/repl-agent-execution.test.ts b/packages/cli/tests/repl-agent-execution.test.ts index 3b9a47532..3d2d60fcc 100644 --- a/packages/cli/tests/repl-agent-execution.test.ts +++ b/packages/cli/tests/repl-agent-execution.test.ts @@ -60,7 +60,8 @@ import { ordinaryEvaluationProfile } from "../src/evaluation-profile.ts"; import { REFERENCE_DIRECTORY } from "./fixtures/repl/reference.ts"; import { openReplSession, submitReplEntry } from "../src/repl/session.ts"; import type { ReplSession } from "../src/repl/session.ts"; -import type { ReplAgentReading } from "../src/repl/agent.ts"; +import { useReplAgent } from "../src/repl/agent.ts"; +import type { ReplAgentKernel, ReplAgentReading } from "../src/repl/agent.ts"; import type { ReplAgentPermission } from "../src/repl/model.ts"; import type { ReplExecution } from "../src/repl/journal.ts"; @@ -1330,6 +1331,67 @@ describe("P3 — whole-session teardown is structured cancellation", () => { expect(session.model.turns).toEqual([]); expect(audited(session)).toEqual([]); }); + + it("P3: releasing the owning scope retires the agent owner and wakes nobody", function* () { + const holder = execution(); + const stub = createStub({ + one: { permission: { toolCallId: "call-1", kind: "execute" }, deltas: ["reply"] }, + }); + const [owner, dispose] = createScope(yield* useScope()); + const held = withResolvers(); + owner.run(function* () { + yield* useStub(stub); + held.resolve(opened(yield* start(holder, ONE_PROMPT, "approve-reads"))); + yield* sleep(DEADLOCK_MS); + }); + const session = yield* held.operation; + yield* reported(session, "a pending request", (reading) => reading.requests.length === 1); + const pending = session.agent.requests[0]!; + + yield* until(dispose()); + yield* sleep(0); + + // The owner is retired: nothing it was presenting is presented any more. + expect(session.agent.turns).toEqual([]); + expect(session.agent.requests).toEqual([]); + // Retiring answered nothing. The held request was abandoned with its + // scope, not decided on the way out. + expect(stub.outcomes.has("call-1")).toBe(false); + expect(session.permissions.choose(pending.key, "once")).toBe(false); + expect(stub.outcomes.has("call-1")).toBe(false); + // Nothing durable was written by any of it. + expect(appends(yield* holder.stream.readAll())).toEqual([]); + }); + + it("P3: retiring the owner wakes nobody who was waiting on its failure", function* () { + // The owner acquired directly, so what is under test is the resource's own + // lifetime rather than a session's use of it. + const [owner, dispose] = createScope(yield* useScope()); + const held = withResolvers(); + owner.run(function* () { + held.resolve(yield* useReplAgent("deny-all")); + yield* sleep(DEADLOCK_MS); + }); + const kernel = yield* held.operation; + + // Waiting from outside the scope that is about to go away, so this task + // outlives the disposal and can say whether anything woke it. + let woken = false; + yield* spawn(function* () { + yield* kernel.failed; + woken = true; + }); + yield* sleep(0); + + yield* until(dispose()); + yield* sleep(0); + + // Released, not failed: there was no failure, so the waiter is left + // exactly as it was rather than told about one. + expect(woken).toBe(false); + expect(kernel.reading.turns).toEqual([]); + expect(kernel.reading.requests).toEqual([]); + }); }); describe("P4 — a request without one live owner fails the session", () => {