Skip to content
Developer Preview

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.

/**
* 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";
/**
* 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());
}
});
}
/**
* 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);
/**
* 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();
});
});
},
};
}
/**
* 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";