The quickstart journey
The complete first-session program the Quickstart guide walks through: spawn a host, complete the handshake, start a session, run a turn and read it as it streams, answer an approval, cancel a turn, close the host, and reload the session in a new process.
Every step of this program runs against a release-built host on every change to the SDK, the harness or these docs. A step that stops behaving as the protocol specifies fails that run rather than being quietly documented as working.
The code below is the source that runs, not a retelling of it. It is reproduced unchanged except that comments citing the repository it is developed in — issue numbers, internal file paths, specification section numbers — are removed, because they resolve nothing for you. No executable line differs.
host.ts
Section titled “host.ts”/** * The harness helpers the journey shares with every cookbook recipe live in * the shared cookbook kit: the isolated host environment, the owned drain * window, bounded waits, and the typed readers for untyped result members. * The journey talks to the host through the SDK's own `MuseClient`, so it * needs none of the kit's raw-connection `Host` recorder. */
export { HOST_DRAIN_WINDOW_MS, STUB_VIEW_CURSOR, arrayAt, drainQuietly, equals, isolatedHostEnv, objectAt, stringAt, within,} from "@muse-code/sdk-cookbook/kit";journey.ts
Section titled “journey.ts”/** * The `@muse-code/sdk` first-session journey, end to end, against a release-built * `muse serve`. * * * 1. **Every assertion states the spec-correct behavior.** Where today's host * is wrong, the segment carries an `expectBlock` naming the open issue. * Nothing here pins a known-wrong result as golden — a journey that froze * today's resume state would become the thing that blocks the fix. * 2. **An expect-block cannot rot.** It names the issues, and it names the * SIGNATURE of the failure those issues cause. A failure that does not * match the signature is a real failure, not an excused one. A segment * that starts passing while blocked FAILS the journey so it gets promoted. * * Rule 2 is what surfaced that — the journey reported all six blocks * `unblocked` the first time it ran against a host with a provider * configured. Rule 1 is why the promotion was a deletion and not a rewrite: * no assertion had been bent to match the blocked behavior, so there was * nothing to unbend. * * It is written against the SDK facade an application uses — * `MuseClient.spawn`, `startSession`, `session.run`, `sendUserTurn` with * `items()`, `onApproval`, `turn.cancel()`, `resumeSession`, `close` — never * the raw connection, so the first program a reader copies teaches that * layer. * * The journey has two modes. {@link runConfiguredJourney} is the ACCEPTANCE * mode: it seeds `JourneyOptions.home` with a HOME pointing at a loopback fake * first-party endpoint (`provider.ts`), so the host really runs a model and * every segment is exercised. {@link runJourney} with no seeded `home` is the * credential-free degradation path — still supported, never the acceptance * artifact. * * Read `README.md` next to this file for the same journey in plain words. */
import { mkdtemp } from "node:fs/promises";import { tmpdir } from "node:os";import { join } from "node:path";
import { EXPECTED_SCHEMA_FINGERPRINT, MspError, MuseClient } from "@muse-code/sdk";import type { ApprovalFailure, FoldedItem, Session, TurnOutcome } from "@muse-code/sdk";
import { HOST_DRAIN_WINDOW_MS, STUB_VIEW_CURSOR, arrayAt, drainQuietly, equals, isolatedHostEnv, objectAt, stringAt, within,} from "./host.js";import { startConfiguredProvider } from "./provider.js";import { runJourney as runKitJourney } from "./segments.js";import type { JourneyReport, Segment } from "./segments.js";
/** * How this journey identifies itself to the host's session/audit attribution. * Named at the spawn site rather than defaulted in the kit, so a cookbook * recipe can never inherit the quickstart's identity by omission (T-24225-6). */const QUICKSTART_CLIENT_INFO = { name: "muse_sdk_quickstart", version: "0.0.0" };
/** * The artifact the approval prompt asks for. The provider-configured mode * routes its ONE scripted `bash` tool call on this name plus the tool's own, * so the discriminator and the prompt can never drift apart. */const APPROVAL_ARTIFACT = "approved.txt";
/** The prompt every turn segment sends. Plain, deterministic, harmless. */const PROMPT = "Reply with the single word: hello";/** The single word {@link PROMPT} asks for, and what the fake provider replies. */const REPLY_TEXT = "hello";/** A prompt that must make the agent ask permission before touching the disk. */const APPROVAL_PROMPT = `Create a file called ${APPROVAL_ARTIFACT} containing the word yes`;
const HANDSHAKE_BUDGET_MS = 30_000;const COMMAND_BUDGET_MS = 30_000;const STREAM_BUDGET_MS = 60_000;const CLOSE_BUDGET_MS = 30_000;
export interface JourneyOptions { /** Absolute path to the release-built binary under test. */ readonly museBin: string; /** * Isolated HOME. Defaults to a fresh temp dir, which is the CREDENTIAL-FREE * degradation path: with no provider configured the host cannot run a model, * so the six model-dependent segments fail for real. The acceptance run uses * {@link runConfiguredJourney}, which seeds this with a HOME pointing at a * loopback fake first-party endpoint. */ readonly home?: string; /** Workspace root a session is started in. Defaults to a fresh temp dir. */ readonly workspaceRoot?: string;}
/** Everything the segments read and write as the journey proceeds. */interface Context { readonly museBin: string; readonly home: string; readonly workspaceRoot: string; /** The client segments 1-7 share; closed before the resume segments. */ client?: MuseClient; /** The session started by `session-new` and resumed later. */ session?: Session; sessionId?: string; /** The resume client, opened by `resume` and closed by `terminate`. */ resumeClient?: MuseClient; /** The `session/resume` result the resume segments all read. */ resumed?: Record<string, unknown>;}
/** * One completed `agentMessage` item: its text and the phase the model * declared on the commit (`"commentary"` or `"finalAnswer"`). An absent * `messagePhase` is an older host, which served final answers only * (the host declares it; an absent or unknown phase reads as a final * answer). */interface CompletedAgentMessage { readonly itemId: string; readonly text: string; readonly messagePhase: string;}
/** * Every completed `agentMessage` a turn served, in first-opened order. A turn * holds several — commentary before and between tool calls, then the final * answer — and no position is special: the LAST one is not "the answer" * (the phase labels a message, its position does not). Show them all, * each with its phase, once the turn has ended. */function completedAgentMessages(items: readonly FoldedItem[]): CompletedAgentMessage[] { const messages: CompletedAgentMessage[] = []; for (const item of items) { if (item.kind !== "agentMessage" || item.status !== "completed") continue; messages.push({ itemId: item.itemId, text: item.text ?? "", messagePhase: item.messagePhase === "commentary" ? "commentary" : "finalAnswer", }); } return messages;}
function requireClient(context: Context): MuseClient { const client = context.client; if (client === undefined) throw new Error("no live client: an earlier segment did not finish"); return client;}
function requireSession(context: Context): Session { const session = context.session; if (session === undefined) throw new Error("no session: `session-new` did not finish"); return session;}
function requireSessionId(context: Context): string { const sessionId = context.sessionId; if (sessionId === undefined) throw new Error("no session: `session-new` did not finish"); return sessionId;}
/** * Spawn `muse serve` and complete the handshake in one call. The first two * options are all an application needs; the rest are what this journey adds * because it tests one specific build in a throwaway HOME. */async function spawnClient(context: Context): Promise<MuseClient> { return await MuseClient.spawn({ args: ["serve"], clientInfo: QUICKSTART_CLIENT_INFO, // Omit `museBin` and the SDK runs the first `muse` on your PATH; the // journey names the binary under test instead. museBin: context.museBin, cwd: context.workspaceRoot, env: isolatedHostEnv(context.home), shutdownTimeoutMs: HOST_DRAIN_WINDOW_MS, });}
/** Fails with the host's own reason text when a turn ends any other way. */function requireTerminal(outcome: TurnOutcome, expected: string): void { if (outcome.kind !== "completed") { throw new Error(`the turn ended ${JSON.stringify(outcome.kind)}, expected terminal ${JSON.stringify(expected)}`); } const terminal = outcome.params.terminal; if (terminal !== expected) { const reason = outcome.params.reason; throw new Error( `turn/completed terminal was ${JSON.stringify(terminal)}, expected ${JSON.stringify(expected)}` + (typeof reason === "string" ? `; host reason: ${reason}` : ""), ); }}
const SEGMENTS: ReadonlyArray<Segment<Context>> = [ { id: "spawn", title: "Spawn the release-built host and keep it alive", async run(context) { // `MuseClient.spawn` spawns and handshakes in one step. Spawn is proven // by the handshake answering at all: a host that dies on launch (closed // SDK gate = exit 5, bad config = exit 3) rejects here with // `MuseHostStartError`, carrying its exit and stderr, instead of a hang. context.client = await within( "the host's spawn and handshake", HANDSHAKE_BUDGET_MS, spawnClient(context), ); }, }, { id: "handshake", title: "The handshake result describes the host and matches the SDK's schema pin", async run(context) { const client = requireClient(context); const result = client.initializeResult as unknown as Record<string, unknown>; const serverInfo = objectAt(result, "serverInfo", "initialize result"); stringAt(serverInfo, "name", "initialize serverInfo"); stringAt(serverInfo, "version", "initialize serverInfo"); stringAt(result, "museHome", "initialize result"); stringAt(result, "sessionDurability", "initialize result"); const schema = objectAt(result, "schema", "initialize result"); // A mismatch is a warning, never an error, so this equality is what // binds the served schema to the one the SDK pins. equals( stringAt(schema, "fingerprint", "initialize schema"), EXPECTED_SCHEMA_FINGERPRINT, "the served schema fingerprint vs the fingerprint @muse-code/sdk pins", ); }, }, { id: "session-new", title: "Start a new session in the workspace", async run(context) { const client = requireClient(context); const session = await within( "the session/start reply", COMMAND_BUDGET_MS, client.startSession({ workspaceRoot: context.workspaceRoot }), ); const opening = session.opening; if (opening?.verb !== "session/start") { throw new Error("the session did not open through session/start"); } const result = opening.result as unknown as Record<string, unknown>; const summary = objectAt(result, "session", "session/start result"); equals(summary["sessionId"], session.sessionId, "the started session id"); equals(summary["status"], "idle", "a fresh session's status"); equals( summary["workspaceRoot"], context.workspaceRoot, "the session's workspace root", ); // A DEFAULT start writes no durable fact, so the served fold has no // view events yet and the cursor is pinned to exactly the // before-genesis `""`. The exact pin also rejects the pre-Seam-C stub // sentinel and any wrongly-minted non-empty cursor. equals(result["viewCursor"], "", "a default start's before-genesis view cursor");
context.session = session; context.sessionId = session.sessionId; }, }, { id: "session-effective-model", title: "The new session names the provider and model it will actually use", async run(context) { const opening = requireSession(context).opening; if (opening === undefined) throw new Error("the session carries no opening result"); const summary = opening.result.session as unknown as Record<string, unknown>; stringAt(summary, "providerId", "the started session"); stringAt(summary, "modelId", "the started session"); }, }, { id: "turn", title: "Send a prompt and receive the agent's answer as a stream", async run(context) { const session = requireSession(context); // One call: send the prompt, wait for the server to end the turn, and // get back its outcome, final answer and items. const result = await within("the turn", STREAM_BUDGET_MS, session.run(PROMPT)); requireTerminal(result.outcome, "completed");
// Every completed agent message, each with its phase: the turn may // narrate ("commentary") before and between tool calls and then answer // ("finalAnswer"), and the last item is not "the answer". An open enum, // so a phase this client does not know still decodes as a final answer. const messages = completedAgentMessages(result.items); const shown = messages.map((message) => `[${message.messagePhase}] ${message.text}`); if (!messages.some((message) => message.text.length > 0)) { throw new Error( `the turn completed without a non-empty agentMessage item; saw ${JSON.stringify(shown)}`, ); } // The answer must have arrived incrementally, not in one final lump. // The fold accumulates text only from `item/delta` frames, so a // non-empty accumulation IS the streaming evidence. if (!messages.some((message) => (session.fold.items.accumulated(message.itemId) ?? "") !== "")) { throw new Error("the turn completed without streaming a single item/delta"); } }, }, { id: "approval", title: "Answer the agent's permission request and see it resolved", async run(context) { const session = requireSession(context); const failures: ApprovalFailure[] = []; // Register before the turn. The SDK sends only a choice the request // offered; anything else is refused and reported to `onApprovalError`. session.onApprovalError((failure) => failures.push(failure)); session.onApproval(() => ({ choiceId: "allow_once" }));
// Send the turn yourself to read its items as they stream in. const turn = await within( "the turn/start ack", COMMAND_BUDGET_MS, session.sendUserTurn({ input: [{ type: "text", text: APPROVAL_PROMPT }] }), ); const kinds: string[] = []; await within("the approval turn's items", STREAM_BUDGET_MS, (async () => { for await (const item of turn.items()) kinds.push(String(item.kind)); })()); requireTerminal(await turn.completed, "completed");
if (failures.length > 0) { throw new Error(`the approval round trip failed: ${JSON.stringify(failures)}`); } if (!kinds.includes("toolCall")) { throw new Error(`the turn never streamed the tool call it asked about; kinds were ${JSON.stringify(kinds)}`); } // The authoritative outcome is the server's `approval/resolved`, folded // into the session, never the decision's ack. if (session.fold.resolvedApprovals().length === 0) { throw new Error("the turn ended without an approval/resolved: it never asked, or the decision never landed"); } }, }, { id: "cancel", title: "Cancel a turn while it is running", // The block's second arm excused an `already_terminal` race the retired // logged-out provider caused by failing every turn in milliseconds; the // provider-configured mode removes that race at the source by keeping the // turn in flight (see `provider.ts` `HOLD_MS`) rather than re-importing // the excuse. async run(context) { const session = requireSession(context); const turn = await within( "the turn/start ack", COMMAND_BUDGET_MS, session.sendUserTurn({ input: [{ type: "text", text: PROMPT }] }), ); let ended = false; void turn.completed.then(() => (ended = true), () => (ended = true)); // The first item (the prompt itself) folds once the turn runs, so the // cancel below lands on a running turn, never a queued one. await within("the cancel turn's first item", STREAM_BUDGET_MS, (async () => { for await (const _item of turn.items()) break; })()); // A turn that already reached a terminal state cannot prove cancellation. if (ended) { throw new Error(`the turn ended (${JSON.stringify(await turn.completed)}) before it could be cancelled`); }
// A request, not an outcome: the turn is over when the server says so. await within("the turn/cancel ack", COMMAND_BUDGET_MS, turn.cancel()); const outcome = await within("the cancelled turn's end", STREAM_BUDGET_MS, turn.completed); requireTerminal(outcome, "cancelled"); }, }, { id: "resume", title: "Close the host, reopen it, and load the same session with its state", async run(context) { // The session lease belongs to the live host, so the first one has to // drain cleanly before a second can load the session. That drain is // itself the spec-14990 Scenario-4.4 contract: stdin closed, orderly // drain, exit 0. const first = requireClient(context); await within("the first host's orderly drain", CLOSE_BUDGET_MS, first.close()); equals((await first.exit).kind, "cleanShutdown", "the first host's exit after stdin EOF"); context.client = undefined;
const sessionId = requireSessionId(context); const client = await within( "the resume host's spawn and handshake", HANDSHAKE_BUDGET_MS, spawnClient(context), ); context.resumeClient = client;
const session = await within( "the session/resume reply", COMMAND_BUDGET_MS, client.resumeSession({ sessionId, excludeItems: false }), ); const opening = session.opening; if (opening?.verb !== "session/resume") { throw new Error("the session did not open through session/resume"); } const resumed = opening.result as unknown as Record<string, unknown>; context.resumed = resumed;
const summary = objectAt(resumed, "session", "session/resume result"); equals(summary["sessionId"], sessionId, "the resumed session id"); equals( summary["workspaceRoot"], context.workspaceRoot, "the resumed workspace root", ); const cursor = stringAt(resumed, "viewCursor", "session/resume result"); if (cursor === STUB_VIEW_CURSOR) { throw new Error(`session/resume returned the pre-Seam-C stub cursor ${cursor}`); } const history = objectAt(resumed, "history", "session/resume result"); equals(history["mode"], "inline", "the resumed history mode for an include-items resume"); arrayAt(history, "items", "session/resume history"); arrayAt(resumed, "pendingRequests", "session/resume result"); }, }, { id: "resume-effective-model", title: "The resumed session still names the provider and model it will use", async run(context) { const resumed = context.resumed; if (resumed === undefined) throw new Error("`resume` did not finish"); const summary = objectAt(resumed, "session", "session/resume result"); stringAt(summary, "providerId", "resumed session"); stringAt(summary, "modelId", "resumed session"); }, }, { id: "resume-history", title: "The resumed history carries the turn that already happened", async run(context) { const resumed = context.resumed; if (resumed === undefined) throw new Error("`resume` did not finish"); const history = objectAt(resumed, "history", "session/resume result"); const items = arrayAt(history, "items", "session/resume history"); const kinds = items.map((item) => item !== null && typeof item === "object" ? (item as Record<string, unknown>)["kind"] : undefined, ); if (!kinds.includes("userMessage")) { throw new Error( `resumed history has no userMessage; kinds were ${JSON.stringify(kinds)}`, ); } if (!kinds.includes("agentMessage")) { throw new Error( `resumed history has no agentMessage; kinds were ${JSON.stringify(kinds)}`, ); } }, }, { id: "resume-rejects-unknown-cursor", title: "Resuming from a cursor that never existed is refused", async run(context) { const client = context.resumeClient; if (client === undefined) throw new Error("`resume` did not finish"); const sessionId = requireSessionId(context); // A cursor for this session at an impossible sequence. No host can // ever have minted it. const impossible = `v6:${sessionId}:999999`; try { await within( "the refused session/resume reply", COMMAND_BUDGET_MS, client.resumeSession({ sessionId, cursor: impossible, excludeItems: false }), ); } catch (error) { // ONLY the pinned refusal counts. A bare `instanceof MspError` lets // any unrelated protocol error (invalidParams, internal, a lease // failure) read as a correct refusal, so this segment would stay // green on MspError-shaped breakage once the expect-block comes off. // The shape is `notFound` / -32011. if (error instanceof MspError && error.kind === "notFound") return; throw error; } throw new Error(`session/resume accepted a cursor that never existed (${impossible})`); }, }, { id: "terminate", title: "Close stdin and let the host drain and exit cleanly", async run(context) { const client = context.resumeClient; if (client === undefined) throw new Error("`resume` did not finish"); await within("the host's orderly drain and exit", CLOSE_BUDGET_MS, client.close()); equals((await client.exit).kind, "cleanShutdown", "the SDK's exit classification"); context.resumeClient = undefined; }, },];
/** Segment ids in run order. Exported so tests can pin the journey's shape. */export const SEGMENT_IDS: readonly string[] = SEGMENTS.map((segment) => segment.id);
/** * Expect-blocked segment id → its issue numbers, in run order. * * The list is empty today, so the section is absent — a heading that promises * a list of gaps and delivers the word "nothing" is scaffolding, not * disclosure. * * When a fix lands, the ONE required edit is deleting the segment's * `expectBlock`; every test then goes green while the README still calls the * segment broken. When a block RETURNS, the journey stays green — * expect-blocked is a pass — while the README still says nothing is wrong. * Neither may be silent, so a block cannot arrive or leave without the README * saying so. */export const EXPECT_BLOCKED: ReadonlyArray<{ readonly id: string; readonly issues: readonly number[];}> = SEGMENTS.filter((segment) => segment.expectBlock !== undefined).map((segment) => ({ id: segment.id, issues: (segment.expectBlock as { issues: readonly number[] }).issues,}));
/** What a provider-configured run observed, beyond the segment report. */export interface ConfiguredJourneyResult { readonly report: JourneyReport; /** The loopback endpoint the host was pointed at. */ readonly baseUrl: string; /** How many times the host fetched the model catalog. */ readonly catalogGets: number; /** * How many scripted tool calls the endpoint served. The harness's own * contract is AT MOST ONE — see `provider.ts`. The acceptance test asserts * it, because serving a second one silently parks the `cancel` segment. */ readonly scriptedToolCalls: number;}
export interface ConfiguredJourneyOptions { /** Absolute path to the release-built binary under test. */ readonly museBin: string; /** Workspace root a session is started in. Defaults to a fresh temp dir. */ readonly workspaceRoot?: string;}
/** * The ACCEPTANCE mode: the same twelve segments, against a host whose HOME * already has a provider configured. * * The provider is the loopback fake first-party endpoint from `provider.ts` — * no live provider, no API key, nothing off `127.0.0.1` — so this is the mode * CI runs. Every segment is required; none is expect-blocked. * * {@link runJourney} without a seeded `home` remains supported as the * credential-free degradation path. It is NOT the acceptance artifact: a host * with no provider cannot run a model, so its six model-dependent segments * fail for real and say so. */export async function runConfiguredJourney( options: ConfiguredJourneyOptions,): Promise<ConfiguredJourneyResult> { const provider = await startConfiguredProvider({ // `"bash"` because serve composes exec's managed shell tool family: the // model-visible tool is `bash`, not the legacy raw `shell` this // discriminator matched before. scriptedToolCallWhen: [APPROVAL_ARTIFACT, `"bash"`], scriptedToolCallCommand: `printf yes > ${APPROVAL_ARTIFACT}`, replyText: REPLY_TEXT, }); try { const report = await runJourney({ museBin: options.museBin, home: provider.home, ...(options.workspaceRoot === undefined ? {} : { workspaceRoot: options.workspaceRoot }), }); return { report, baseUrl: provider.baseUrl, catalogGets: provider.catalogGets(), scriptedToolCalls: provider.scriptedToolCalls(), }; } finally { await provider.close(); }}
export async function runJourney(options: JourneyOptions): Promise<JourneyReport> { const context: Context = { museBin: options.museBin, home: options.home ?? (await mkdtemp(join(tmpdir(), "muse-quickstart-home-"))), workspaceRoot: options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), "muse-quickstart-ws-"))), }; // The loop, the always-run teardown, and the summary are the kit's // (`runJourney`), so this journey and every cookbook recipe cannot drift on // the part that leaks a spawned host when a segment throws (T-24225-6). return await runKitJourney(SEGMENTS, context, async (owned) => { const { client, resumeClient } = owned; if (client !== undefined) await drainQuietly("the host", CLOSE_BUDGET_MS, () => client.close()); if (resumeClient !== undefined) { await drainQuietly("the resume host", CLOSE_BUDGET_MS, () => resumeClient.close()); } });}main.ts
Section titled “main.ts”/** * Run the journey and print the segment report. * * node dist/src/main.js --bin <path-to-release-built-muse> * node dist/src/main.js --bin <path-to-release-built-muse> --no-provider * * The default is the provider-configured (acceptance) mode: the harness starts * a loopback fake first-party endpoint and points the host's HOME at it, so * every one of the twelve segments runs for real. Nothing leaves `127.0.0.1` * and no API key is used. * * `--no-provider` selects the credential-free degradation path: no provider is * configured, so the host cannot run a model and the six model-dependent * segments fail. That run is useful for isolating the segments that need no * model; it is NOT the acceptance artifact. * * Exit 0 means every required segment passed and every expect-block is still * accurate. Exit 1 means a required segment failed, or a blocked segment * started passing and now needs promoting. */
import { runConfiguredJourney, runJourney } from "./journey.js";import { formatReport } from "./segments.js";
function argument(name: string): string | undefined { const index = process.argv.indexOf(name); if (index === -1) return undefined; return process.argv[index + 1];}
const museBin = argument("--bin") ?? process.env["MUSE_BIN"];if (museBin === undefined || museBin.length === 0) { process.stderr.write( "usage: node dist/src/main.js --bin <path-to-release-built-muse> [--no-provider]\n" + " (or set MUSE_BIN). Build it with:\n" + " install muse, then: MUSE_BIN=$(command -v muse)\n", ); process.exit(2);}
if (process.argv.includes("--no-provider")) { const report = await runJourney({ museBin }); process.stdout.write(`${formatReport(report)}\n`); process.stdout.write( "\nmode=credential-free (no provider configured) — this is the degradation path, not the acceptance run\n", ); process.exit(report.ok ? 0 : 1);}
const configured = await runConfiguredJourney({ museBin });process.stdout.write(`${formatReport(configured.report)}\n`);process.stdout.write( `\nmode=provider-configured baseUrl=${configured.baseUrl}` + ` catalogGets=${String(configured.catalogGets)}` + ` scriptedToolCalls=${String(configured.scriptedToolCalls)}\n`,);process.exit(configured.report.ok ? 0 : 1);provider.ts
Section titled “provider.ts”/** * The journey's provider-configured mode: a loopback fake first-party endpoint * plus the `HOME` that points `muse serve` at it. * * It serves the same two routes with the same bodies, and the `HOME` it seeds * carries the same two files with the same shape: * * `GET <base>/muse-code/models` -> one visible, dated row, so the host's * `pick_default_model_from` yields a model. * `POST <base>/responses` -> a text SSE turn, or ONE scripted `bash` * tool call (see below). * `$HOME/.config/muse/settings.json` -> `endpoint_transport` at that base URL * with `auth: "bearer"`. * `$HOME/.config/muse/auth.json` -> a stored credential in the `meta` slot. * * Nothing here reaches the network: the listener binds `127.0.0.1:0` and the * stored credential is a fixed dummy the fake never checks. There is no live * provider and no API key, so this mode is safe to run in CI on every PR — it * is what lets the journey assert the turn, approval and cancel segments for * real instead of expect-blocking them. * * The credential-free mode (no `home` seeded) is still supported and is the * documented no-provider degradation path; see README.md. */
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";import { createServer } from "node:http";import type { Server } from "node:http";import { tmpdir } from "node:os";import { join } from "node:path";
/** The catalog's single model id. */const FAKE_MODEL_ID = "fake-model";
/** * The stored credential. It is LOAD-BEARING that one exists at all — a * keyless rig makes the "credentialed" arm credential-independent — but its * value is never checked, so it is a fixed literal and not a secret. */const DUMMY_API_KEY = "quickstart-journey-dummy-key";
export interface FakeProviderOptions { /** * Substrings that appear together in the completion request body of the ONE * turn that should get the scripted tool call, and in no other request. */ readonly scriptedToolCallWhen: readonly string[]; /** The command the scripted `bash` tool call asks to run. */ readonly scriptedToolCallCommand: string; /** The text every other completion answers with. */ readonly replyText: string;}
/** A running fake endpoint and the `HOME` configured to talk to it. */export interface ConfiguredProvider { /** The seeded `HOME` to hand `JourneyOptions.home`. */ readonly home: string; /** The loopback base URL the seeded `HOME` points at. */ readonly baseUrl: string; /** How many times the model catalog was fetched. */ catalogGets(): number; /** * How many scripted tool calls were served. The harness's own contract is * that this is AT MOST ONE: after the approval segment's turn, its artifact * name is in the replayed history of every later turn, so a content match * with no once-only guard hands the `cancel` segment a tool call too and * parks it on an approval nobody answers. */ scriptedToolCalls(): number; /** Stop the listener and drop every connection it is still holding. */ close(): Promise<void>;}
/** * How long each completion is held open between its first delta and its * `response.completed`. * * The `cancel` segment asserts that a RUNNING turn can be cancelled. An * endpoint that answers instantly turns that into a race the journey loses * intermittently: the turn reaches terminal before `turn/cancel` is admitted * and the host rejects the cancel with `already_terminal`. That race is * precisely the excuse the retired expect-block used to carry, so the fake * removes it at the source instead of re-importing it — the turn is * unambiguously in flight when the cancel lands. * * The value is slack, but it is NOT free slack: a serve turn drives the * reminder plugins' child sessions through this same endpoint, so a segment * waits on several holds in series. Measured on the release host, the `turn` * segment costs about five holds end to end. 3s therefore buys a ~30x margin * over the ~100ms window the host leaves on its own, while keeping `turn` near * 15s against its 60s `STREAM_BUDGET_MS` — a 5s hold would put `turn` at ~25s, * 42% of that budget, trading a cancel race for a timeout race. */const HOLD_MS = 3_000;
function sse(value: unknown): string { return `data: ${JSON.stringify(value)}\n\n`;}
/** One visible, dated row so the host's default-model pick yields a model. */function catalogBody(): string { return JSON.stringify({ object: "list", data: [ { id: FAKE_MODEL_ID, object: "model", metadata: { "muse-code": { release_date: "2026-01-01", is_hidden: false, limit: { context: 1_000_000, output: 1024 }, }, }, }, ], });}
function responseFrame(id: string, status: string, extra: Record<string, unknown> = {}): unknown { return { id, object: "response", model: FAKE_MODEL_ID, status, output: [], ...extra };}
/** The head of a completion: created, then the one content-bearing event. */function completionHead(text: string): string { return ( sse({ type: "response.created", sequence_number: 1, response: responseFrame("resp_journey_text", "in_progress"), }) + sse({ type: "response.output_text.delta", sequence_number: 2, output_index: 0, item_id: "msg_journey_text", content_index: 0, delta: text, }) );}
/** * The head of the scripted `bash` tool call. */function toolCallHead(callId: string, command: string): string { return ( sse({ type: "response.created", sequence_number: 1, response: responseFrame("resp_journey_tool", "in_progress"), }) + sse({ type: "response.function_call_arguments.done", sequence_number: 2, output_index: 0, item_id: `fc_${callId}`, name: "bash", call_id: callId, arguments: JSON.stringify({ command, description: "Write the approval artifact" }), }) );}
function completionTail(id: string): string { return sse({ type: "response.completed", sequence_number: 3, response: responseFrame(id, "completed", { usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, }), });}
/** * Start the loopback endpoint and seed a `HOME` that points at it. * * The caller owns the returned handle and MUST `close()` it; the listener and * its hold timers keep the process alive otherwise. */export async function startConfiguredProvider( options: FakeProviderOptions,): Promise<ConfiguredProvider> { const catalog = catalogBody(); let catalogGets = 0; let scriptedToolCalls = 0; /** Every in-flight hold, so `close()` can never be blocked by one. */ const holds = new Set<NodeJS.Timeout>();
const server: Server = createServer((request, response) => { const chunks: Buffer[] = []; request.on("data", (chunk: Buffer) => chunks.push(chunk)); request.on("end", () => { const body = Buffer.concat(chunks).toString("utf8"); if (request.method === "GET" && (request.url ?? "").endsWith("/muse-code/models")) { catalogGets += 1; response.writeHead(200, { "content-type": "application/json" }); response.end(catalog); return; }
// Once-only, content-routed: see `scriptedToolCallWhen` and // `scriptedToolCalls` above for why both halves are load-bearing. const isScripted = scriptedToolCalls === 0 && options.scriptedToolCallWhen.every((needle) => body.includes(needle));
let head: string; let responseId: string; if (isScripted) { scriptedToolCalls += 1; responseId = "resp_journey_tool"; head = toolCallHead(`call_journey_${String(scriptedToolCalls)}`, options.scriptedToolCallCommand); } else { responseId = "resp_journey_text"; head = completionHead(options.replyText); }
response.writeHead(200, { "content-type": "text/event-stream" }); response.write(head); const hold = setTimeout(() => { holds.delete(hold); // A cancelled turn aborts the request, so the socket is often already // gone by now. Finishing a dead response is a no-op, not an error. if (!response.writableEnded) response.end(completionTail(responseId)); }, HOLD_MS); holds.add(hold); }); // An aborted request (the cancel path) must not reach the process as an // unhandled error event. request.on("error", () => undefined); response.on("error", () => undefined); });
await new Promise<void>((resolve) => { server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); if (address === null || typeof address === "string") { server.close(); throw new Error("the fake provider endpoint did not bind a TCP port"); } const baseUrl = `http://127.0.0.1:${String(address.port)}`;
const home = await mkdtemp(join(tmpdir(), "muse-quickstart-provider-home-")); const configDir = join(home, ".config", "muse"); await mkdir(configDir, { recursive: true }); await writeFile( join(configDir, "settings.json"), `${JSON.stringify({ schema_version: 1, endpoint_transport: { base_url: baseUrl, auth: "bearer" } }, null, 2)}\n`, ); await writeFile( join(configDir, "auth.json"), `${JSON.stringify({ schema_version: 1, providers: { meta: { api_key: DUMMY_API_KEY } } })}\n`, );
return { home, baseUrl, catalogGets: () => catalogGets, scriptedToolCalls: () => scriptedToolCalls, close: async () => { for (const hold of holds) clearTimeout(hold); holds.clear(); // Sockets the host left open would keep `close` pending forever. server.closeAllConnections(); await new Promise<void>((resolve) => { server.close(() => { resolve(); }); }); }, };}segments.ts
Section titled “segments.ts”/** * The expect-block contract now lives in the shared cookbook kit so the * quickstart journey and every cookbook recipe classify outcomes with the * same code. This module re-exports it unchanged: the journey's import * surface, semantics, and report format are exactly what they were when the * contract lived in this file. */
export { classify, formatReport, runJourney, runSegment, summarize,} from "@muse-code/sdk-cookbook/kit";export type { ExpectBlock, JourneyReport, Segment, SegmentOutcome, SegmentResult,} from "@muse-code/sdk-cookbook/kit";