@kuralle-syrinx/cli 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fixture.ts ADDED
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Fixture sidecar loading for `syrinx turn --in <fixture.json>`. Mirrors the
4
+ // shape written by apps/studio/src/lib/fixture-export.ts ("Save as fixture") and
5
+ // the check already proven in examples/02-hello-voice-headless/scripts/replay-fixture.ts:
6
+ // honour the recorded capture config, refuse to replay when it cannot be
7
+ // satisfied rather than silently changing the answer.
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
13
+
14
+ export const FIXTURE_FORMAT = "syrinx.fixture.v1";
15
+
16
+ export interface FixtureSidecar {
17
+ readonly format: string;
18
+ readonly turnId?: string;
19
+ readonly expectedTranscript?: string;
20
+ readonly agentText?: string;
21
+ readonly audioFile: string;
22
+ readonly audio: { readonly sampleRateHz: number; readonly channels: number; readonly encoding: string };
23
+ readonly capture?: { readonly inputSampleRateHz?: number };
24
+ }
25
+
26
+ export interface LoadedFixture {
27
+ readonly sidecarPath: string;
28
+ readonly sidecar: FixtureSidecar;
29
+ /** The paired WAV file this fixture replays — prefers the sibling "captured.wav" the Studio actually wrote. */
30
+ readonly wavPath: string;
31
+ }
32
+
33
+ /**
34
+ * Parse and validate a fixture sidecar JSON file, refusing (CliError, USAGE) on
35
+ * anything this CLI cannot honestly replay: an unrecognised format, or capture
36
+ * conditions (sample rate / channel count) that would silently change the
37
+ * transcript if replayed as-is.
38
+ */
39
+ export function loadFixture(sidecarPath: string): LoadedFixture {
40
+ let raw: string;
41
+ try {
42
+ raw = readFileSync(sidecarPath, "utf8");
43
+ } catch (err) {
44
+ throw new CliError(EXIT_CODES.USAGE, `fixture not found: ${sidecarPath}`, { cause: String(err) });
45
+ }
46
+
47
+ let sidecar: FixtureSidecar;
48
+ try {
49
+ sidecar = JSON.parse(raw) as FixtureSidecar;
50
+ } catch (err) {
51
+ throw new CliError(EXIT_CODES.USAGE, `fixture is not valid JSON: ${sidecarPath}`, { cause: String(err) });
52
+ }
53
+
54
+ if (sidecar.format !== FIXTURE_FORMAT) {
55
+ throw new CliError(EXIT_CODES.USAGE, `unsupported fixture format "${sidecar.format}" (expected "${FIXTURE_FORMAT}")`);
56
+ }
57
+ // The sidecar exists so replay conditions can be checked rather than assumed —
58
+ // replaying 24 kHz audio against a 16 kHz-mono expectation changes the transcript.
59
+ if (sidecar.audio.sampleRateHz !== 16000 || sidecar.audio.channels !== 1) {
60
+ throw new CliError(
61
+ EXIT_CODES.USAGE,
62
+ `this replay path needs mono 16 kHz audio; fixture is ${String(sidecar.audio.channels)}ch @ ${String(sidecar.audio.sampleRateHz)} Hz`,
63
+ );
64
+ }
65
+
66
+ const dir = dirname(sidecarPath);
67
+ const sibling = join(dir, "captured.wav");
68
+ let wavPath: string;
69
+ try {
70
+ readFileSync(sibling);
71
+ wavPath = sibling;
72
+ } catch {
73
+ wavPath = join(dir, sidecar.audioFile);
74
+ }
75
+
76
+ return { sidecarPath, sidecar, wavPath };
77
+ }
78
+
79
+ /** Compare on words, not punctuation — STT capitalises and punctuates inconsistently. */
80
+ export function normalizeTranscript(text: string): string {
81
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
82
+ }
83
+
84
+ export interface TranscriptAssertion {
85
+ readonly expectedTranscript: string;
86
+ readonly actualTranscript: string;
87
+ readonly match: boolean;
88
+ }
89
+
90
+ export function assertTranscript(expectedTranscript: string, actualTranscript: string): TranscriptAssertion {
91
+ return {
92
+ expectedTranscript,
93
+ actualTranscript,
94
+ match: normalizeTranscript(expectedTranscript) === normalizeTranscript(actualTranscript),
95
+ };
96
+ }
package/src/help.ts ADDED
@@ -0,0 +1,70 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { EXIT_CODE_TABLE } from "./exit-codes.js";
4
+
5
+ const EXIT_CODE_LINES = EXIT_CODE_TABLE.map((row) => ` ${String(row.code)} ${row.name.padEnd(9)} ${row.meaning}`).join("\n");
6
+
7
+ export const HELP_TEXT = `syrinx — the agent-facing CLI for a Syrinx voice agent.
8
+
9
+ Not a console: no REPL, no chat loop, no microphone. Every command is a single
10
+ deterministic invocation with a documented exit code, meant to be run by a
11
+ coding agent (or a script), not a human at a prompt.
12
+
13
+ This CLI brings no providers of its own — no Deepgram, no Cartesia, no OpenAI.
14
+ --agent points it at YOUR agent module; that module owns its own providers.
15
+
16
+ USAGE
17
+ syrinx <command> [options]
18
+
19
+ AGENT RESOLUTION (--agent, required for turn and text)
20
+ --agent <module>[#namedExport]
21
+ <module> is a path (resolved relative to cwd unless absolute) to your
22
+ own code; the export must be a zero-arg factory returning a
23
+ VoiceAgentSession (or a Promise of one) — exactly the contract
24
+ examples/02-hello-voice-headless/scripts/dev-server.ts's --agent flag
25
+ uses. Omit #export for a default export (or a "createSession" export).
26
+ Resolution failures (module not found, no such export, export not
27
+ callable) exit USAGE and name the callable exports that were found.
28
+ If the export IS callable but throws when invoked, that exits CONFIG —
29
+ most plausibly the agent's own module is missing something it needs
30
+ (an env var, a key file, ...).
31
+
32
+ COMMANDS
33
+ turn --in <fixture.wav|fixture.json> --agent <module>#<export> [options]
34
+ Run one turn through the resolved agent and report the transcript, the
35
+ reply, and per-stage timings. Given a fixture.json sidecar (produced by
36
+ the Studio's "Save as fixture"), honours its recorded capture config
37
+ and, when the sidecar carries an expected transcript, asserts the
38
+ replay matches it.
39
+ --in <path> required. fixture.wav or fixture.json
40
+ --agent <module>#<export> required.
41
+ --session-dir <dir> where turn artifacts are written (default: a temp dir)
42
+ --json emit one parseable JSON object on stdout
43
+
44
+ text "<message>" --agent <module>#<export> [options]
45
+ Send a typed turn (no STT, no microphone) to the resolved agent and
46
+ report the reply.
47
+ --agent <module>#<export> required.
48
+ --json emit one parseable JSON object on stdout
49
+
50
+ doctor [--agent <module>#<export>] [--json]
51
+ Report what is configured: the Node runtime, whether
52
+ @kuralle-syrinx/core is installed and its version, and — informational
53
+ only — which well-known provider keys are present in the environment
54
+ (never their values). This CLI does not require any specific provider
55
+ key; pass --agent to check whether a SPECIFIC agent module resolves.
56
+ Always exits 0 — this command diagnoses, it does not assert.
57
+
58
+ --help, -h show this help
59
+ --version, -v print the CLI version
60
+
61
+ OUTPUT
62
+ --json is a first-class output mode: every command supports it and emits a
63
+ single parseable JSON object on stdout. Diagnostics (warnings, e.g. a
64
+ version-skew notice) always go to stderr, never stdout, so --json output
65
+ stays parseable. Without --json, commands print a short human-readable
66
+ summary to stdout and errors to stderr.
67
+
68
+ EXIT CODES
69
+ ${EXIT_CODE_LINES}
70
+ `;
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Bin entry point. The shebang is injected by the esbuild banner at build time
4
+ // (see scripts/build.mjs) so the raw TypeScript source stays a plain module.
5
+
6
+ import { main } from "./cli.js";
7
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
8
+
9
+ /**
10
+ * Report a failure the way the contract promises: one line on stderr, the
11
+ * documented code on exit. Never a stack trace — stdout may already hold a
12
+ * `--json` object, and a stack after it corrupts what a caller parses.
13
+ * A stack is only useful for an unexpected internal fault, which is what
14
+ * INTERNAL means.
15
+ */
16
+ function fail(err: unknown): number {
17
+ if (err instanceof CliError) {
18
+ console.error(err.message);
19
+ return err.exitCode;
20
+ }
21
+ console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
22
+ return EXIT_CODES.INTERNAL;
23
+ }
24
+
25
+ // A rejection that escapes the command — e.g. a listener that settles after the
26
+ // command already returned — must not print a stack over a written JSON object.
27
+ process.on("unhandledRejection", (reason) => {
28
+ process.exitCode = fail(reason);
29
+ });
30
+
31
+ main(process.argv.slice(2))
32
+ .then((code) => {
33
+ process.exitCode = code;
34
+ })
35
+ .catch((err: unknown) => {
36
+ process.exitCode = fail(err);
37
+ });
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
9
+ import { runTextCommand } from "./text-command.js";
10
+
11
+ const HERE = dirname(fileURLToPath(import.meta.url));
12
+ const FIXTURES = join(HERE, "__fixtures__");
13
+
14
+ describe("runTextCommand", () => {
15
+ it("resolves --agent and drives a real turn end to end, with no live provider", async () => {
16
+ const result = await runTextCommand({ message: "hi", agentSpec: `${join(FIXTURES, "good-text-agent.mjs")}#createSession` });
17
+ expect(result.reply).toBe("Hello from the fake agent.");
18
+ expect(result.agent).toContain("createSession");
19
+ });
20
+
21
+ it("refuses an unresolvable --agent as USAGE", async () => {
22
+ const err = await runTextCommand({ message: "hi", agentSpec: join(FIXTURES, "does-not-exist.mjs") }).catch((e: unknown) => e);
23
+ expect(err).toBeInstanceOf(CliError);
24
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
25
+ });
26
+
27
+ it("reports CONFIG when the agent module throws constructing a session", async () => {
28
+ const err = await runTextCommand({ message: "hi", agentSpec: join(FIXTURES, "throwing-agent.mjs") }).catch((e: unknown) => e);
29
+ expect(err).toBeInstanceOf(CliError);
30
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.CONFIG);
31
+ });
32
+ });
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // `syrinx text "<message>" --agent <module>#<export>` — resolves the caller's
4
+ // own agent and drives one typed turn through it. The CLI brings no reasoner.
5
+
6
+ import { resolveAgentFactory } from "./agent-resolve.js";
7
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
8
+ import { driveText, type TextTurnResult } from "./text-turn.js";
9
+
10
+ export interface RunTextCommandOptions {
11
+ readonly message: string;
12
+ readonly agentSpec: string;
13
+ readonly cwd?: string;
14
+ }
15
+
16
+ export interface TextCommandResult extends TextTurnResult {
17
+ readonly agent: string;
18
+ }
19
+
20
+ export async function runTextCommand(opts: RunTextCommandOptions): Promise<TextCommandResult> {
21
+ const resolvedAgent = await resolveAgentFactory(opts.agentSpec, opts.cwd ?? process.cwd());
22
+
23
+ let result: TextTurnResult;
24
+ try {
25
+ result = await driveText({ session: resolvedAgent.factory, message: opts.message });
26
+ } catch (err) {
27
+ if (err instanceof CliError) throw err;
28
+ throw new CliError(EXIT_CODES.BACKEND, `text turn failed: ${err instanceof Error ? err.message : String(err)}`);
29
+ }
30
+
31
+ return { ...result, agent: resolvedAgent.label };
32
+ }
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { VoiceAgentSession } from "@kuralle-syrinx/core";
4
+ import { FakeBridge, type FakeBridgeConfig } from "@kuralle-syrinx/test";
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
8
+ import { driveText } from "./text-turn.js";
9
+
10
+ function buildBridgeOnlySession(scriptedEvents: FakeBridgeConfig["scriptedEvents"]): VoiceAgentSession {
11
+ const session = new VoiceAgentSession({ plugins: { bridge: { scriptedEvents } } });
12
+ session.registerPlugin("bridge", new FakeBridge());
13
+ return session;
14
+ }
15
+
16
+ describe("driveText", () => {
17
+ it("returns the concatenated reply and a ttft from the first delta (no live provider)", async () => {
18
+ const session = buildBridgeOnlySession([{ kind: "text", delta: "It is " }, { kind: "text", delta: "seventy degrees." }, { kind: "done" }]);
19
+ const result = await driveText({ session, message: "what's the weather?" });
20
+ expect(result.reply).toBe("It is seventy degrees.");
21
+ expect(result.toolCalls).toBe(0);
22
+ expect(result.totalMs).toBeGreaterThanOrEqual(0);
23
+ });
24
+
25
+ it("counts tool-call parts", async () => {
26
+ const session = buildBridgeOnlySession([
27
+ { kind: "tool_call", id: "1", name: "lookup", args: {} },
28
+ { kind: "text", delta: "done" },
29
+ { kind: "done" },
30
+ ]);
31
+ const result = await driveText({ session, message: "hi" });
32
+ expect(result.toolCalls).toBe(1);
33
+ expect(result.reply).toBe("done");
34
+ });
35
+
36
+ it("accepts a zero-arg session factory (the --agent contract)", async () => {
37
+ const factory = (): VoiceAgentSession => buildBridgeOnlySession([{ kind: "text", delta: "ok" }, { kind: "done" }]);
38
+ const result = await driveText({ session: factory, message: "hi" });
39
+ expect(result.reply).toBe("ok");
40
+ });
41
+
42
+ it("times out as a BACKEND CliError if the agent never finishes", async () => {
43
+ // No "done" event scripted, and no text either — agent_finished never fires.
44
+ const session = buildBridgeOnlySession([]);
45
+ const err = await driveText({ session, message: "hi", timeoutMs: 50 }).catch((e: unknown) => e);
46
+ expect(err).toBeInstanceOf(CliError);
47
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.BACKEND);
48
+ });
49
+ });
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // `syrinx text "<message>"` — a typed turn against an already-built
4
+ // VoiceAgentSession (the same `--agent`-supplied session `driveTurn` runs
5
+ // audio through). Pushes the real shipped path — `user.text_received` — the
6
+ // same packet examples/02-hello-voice-headless/scripts/run-realtime-gemini-sendtext-smoke.ts
7
+ // drives, so a typed turn exercises exactly the pipeline a spoken one would,
8
+ // minus STT. This module never constructs a reasoner/provider itself; the
9
+ // session (and everything it can do) is entirely the caller's.
10
+
11
+ import { randomUUID } from "node:crypto";
12
+
13
+ import { Route, type VoiceAgentSession, type VoiceAgentSessionEvents } from "@kuralle-syrinx/core";
14
+
15
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
16
+ import type { SessionFactory } from "./turn-runner.js";
17
+
18
+ const DEFAULT_TIMEOUT_MS = 60_000;
19
+
20
+ export interface DriveTextOptions {
21
+ /** An already-registered VoiceAgentSession, or a factory producing one. Built by the caller — this module never constructs a reasoner. */
22
+ readonly session: VoiceAgentSession | SessionFactory;
23
+ readonly message: string;
24
+ readonly timeoutMs?: number;
25
+ }
26
+
27
+ export interface TextTurnResult {
28
+ readonly reply: string;
29
+ readonly ttftMs: number;
30
+ readonly totalMs: number;
31
+ readonly toolCalls: number;
32
+ }
33
+
34
+ async function resolveSession(session: VoiceAgentSession | SessionFactory): Promise<VoiceAgentSession> {
35
+ return typeof session === "function" ? session() : session;
36
+ }
37
+
38
+ /** Send one typed turn to an already-built session and report the reply. */
39
+ export async function driveText(opts: DriveTextOptions): Promise<TextTurnResult> {
40
+ const session = await resolveSession(opts.session);
41
+ const contextId = randomUUID();
42
+
43
+ const t0 = performance.now();
44
+ let ttftMs = -1;
45
+ let reply = "";
46
+ let toolCalls = 0;
47
+
48
+ const on = <K extends keyof VoiceAgentSessionEvents>(event: K, handler: VoiceAgentSessionEvents[K]): void => {
49
+ session.on(event, handler);
50
+ };
51
+
52
+ const finished = new Promise<void>((resolveFinished, reject) => {
53
+ const timeout = setTimeout(() => {
54
+ reject(new CliError(EXIT_CODES.BACKEND, `agent did not finish within ${String(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)}ms`));
55
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
56
+
57
+ on("agent_text_delta", (event) => {
58
+ if (event.turnId !== contextId) return;
59
+ if (ttftMs < 0) ttftMs = performance.now() - t0;
60
+ reply += event.delta;
61
+ });
62
+ on("agent_tool_call", (event) => {
63
+ if (event.turnId !== contextId) return;
64
+ toolCalls += 1;
65
+ });
66
+ on("agent_finished", (event) => {
67
+ if (event.turnId !== contextId) return;
68
+ clearTimeout(timeout);
69
+ resolveFinished();
70
+ });
71
+ on("error", (event) => {
72
+ clearTimeout(timeout);
73
+ reject(new CliError(EXIT_CODES.BACKEND, `agent failed: ${event.message} (${event.stage}/${event.category})`));
74
+ });
75
+ });
76
+
77
+ // `session.start()` below can throw before `await finished` is ever reached — an
78
+ // init failure raises BOTH. In that case the `error` handler still rejects this
79
+ // promise, and with nothing awaiting it Node reports an unhandled rejection and
80
+ // prints a stack trace AFTER the command has already written its clean `--json`
81
+ // object. Attaching a no-op handler marks it handled; `await finished` below still
82
+ // observes the rejection, because `.catch()` returns a new promise and does not
83
+ // consume this one.
84
+ void finished.catch(() => {});
85
+
86
+ try {
87
+ await session.start();
88
+ session.bus.push(Route.Main, {
89
+ kind: "user.text_received",
90
+ contextId,
91
+ timestampMs: Date.now(),
92
+ text: opts.message,
93
+ });
94
+ await finished;
95
+ } catch (err) {
96
+ if (err instanceof CliError) throw err;
97
+ throw new CliError(EXIT_CODES.BACKEND, `agent failed: ${err instanceof Error ? err.message : String(err)}`);
98
+ } finally {
99
+ await session.close().catch(() => {});
100
+ }
101
+
102
+ return {
103
+ reply,
104
+ ttftMs: ttftMs < 0 ? 0 : Math.round(ttftMs),
105
+ totalMs: Math.round(performance.now() - t0),
106
+ toolCalls,
107
+ };
108
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+
10
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
11
+ import { runTurnCommand } from "./turn-command.js";
12
+
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ const FIXTURES = join(HERE, "__fixtures__");
15
+ const UNUSED_AGENT_SPEC = "unresolved-if-reached#nope"; // fixture validation must reject before this is ever imported
16
+
17
+ let root: string;
18
+ beforeEach(async () => {
19
+ root = await mkdtemp(join(tmpdir(), "syrinx-cli-turn-cmd-"));
20
+ });
21
+ afterEach(async () => {
22
+ await rm(root, { recursive: true, maxRetries: 3, force: true }).catch(() => {});
23
+ });
24
+
25
+ describe("runTurnCommand — fixture validation runs before agent resolution", () => {
26
+ it("refuses an unsupported fixture format as USAGE, without ever resolving --agent", async () => {
27
+ const p = join(root, "bad-format.json");
28
+ await writeFile(
29
+ p,
30
+ JSON.stringify({ format: "not.the.right.format", audioFile: "captured.wav", audio: { sampleRateHz: 16000, channels: 1, encoding: "pcm_s16le" } }),
31
+ );
32
+ const err = await runTurnCommand({ inputPath: p, agentSpec: UNUSED_AGENT_SPEC }).catch((e: unknown) => e);
33
+ expect(err).toBeInstanceOf(CliError);
34
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
35
+ expect((err as CliError).message).toContain("unsupported fixture format"); // proves the FIXTURE check fired, not agent resolution
36
+ });
37
+
38
+ it("refuses a capture-config mismatch as USAGE", async () => {
39
+ const p = join(root, "bad-rate.json");
40
+ await writeFile(
41
+ p,
42
+ JSON.stringify({
43
+ format: "syrinx.fixture.v1",
44
+ audioFile: "captured.wav",
45
+ audio: { sampleRateHz: 24000, channels: 1, encoding: "pcm_s16le" },
46
+ expectedTranscript: "hello there",
47
+ }),
48
+ );
49
+ const err = await runTurnCommand({ inputPath: p, agentSpec: UNUSED_AGENT_SPEC }).catch((e: unknown) => e);
50
+ expect(err).toBeInstanceOf(CliError);
51
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
52
+ expect((err as CliError).message).toContain("mono 16 kHz");
53
+ });
54
+
55
+ it("refuses a missing fixture file as USAGE", async () => {
56
+ const err = await runTurnCommand({ inputPath: join(root, "does-not-exist.json"), agentSpec: UNUSED_AGENT_SPEC }).catch((e: unknown) => e);
57
+ expect(err).toBeInstanceOf(CliError);
58
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
59
+ expect((err as CliError).message).toContain("fixture not found");
60
+ });
61
+ });
62
+
63
+ describe("runTurnCommand — --agent resolution", () => {
64
+ it("refuses an unresolvable --agent module as USAGE once the fixture itself is fine", async () => {
65
+ const wavPath = join(root, "input.wav");
66
+ await writeFile(wavPath, Buffer.from([]));
67
+ const err = await runTurnCommand({ inputPath: wavPath, agentSpec: join(FIXTURES, "does-not-exist.mjs") }).catch((e: unknown) => e);
68
+ expect(err).toBeInstanceOf(CliError);
69
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.USAGE);
70
+ });
71
+
72
+ it("reports CONFIG when the --agent module resolves but throws constructing a session", async () => {
73
+ const wavPath = join(root, "input.wav");
74
+ await writeFile(wavPath, Buffer.from([]));
75
+ const err = await runTurnCommand({ inputPath: wavPath, agentSpec: join(FIXTURES, "throwing-agent.mjs") }).catch((e: unknown) => e);
76
+ expect(err).toBeInstanceOf(CliError);
77
+ expect((err as CliError).exitCode).toBe(EXIT_CODES.CONFIG);
78
+ expect((err as CliError).message).toContain("FAKE_AGENT_API_KEY");
79
+ });
80
+ });
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // `syrinx turn --in <fixture.wav|fixture.json> --agent <module>#<export>` —
4
+ // resolves the caller's own agent (the CLI brings no providers) and drives it
5
+ // through the shared, provider-agnostic turn driver, mirroring
6
+ // examples/02-hello-voice-headless/scripts/replay-fixture.ts's fixture-replay
7
+ // contract but as a first-class CLI verb with --json output and typed exit
8
+ // codes instead of a throwaway script's console.log/process.exit.
9
+
10
+ import { mkdtempSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { extname, join, resolve } from "node:path";
13
+
14
+ import { resolveAgentFactory } from "./agent-resolve.js";
15
+ import { CliError, EXIT_CODES } from "./exit-codes.js";
16
+ import { assertTranscript, loadFixture, type TranscriptAssertion } from "./fixture.js";
17
+ import { driveTurn, type PerTurnMetrics } from "./turn-runner.js";
18
+
19
+ export interface RunTurnCommandOptions {
20
+ readonly inputPath: string;
21
+ readonly agentSpec: string;
22
+ readonly sessionDir?: string;
23
+ readonly cwd?: string;
24
+ }
25
+
26
+ export interface TurnCommandResult {
27
+ readonly input: string;
28
+ readonly agent: string;
29
+ readonly sessionDir: string;
30
+ readonly transcript: string;
31
+ readonly reply: string;
32
+ readonly timings: PerTurnMetrics;
33
+ readonly durationMs: number;
34
+ readonly assertion: TranscriptAssertion | null;
35
+ }
36
+
37
+ export async function runTurnCommand(opts: RunTurnCommandOptions): Promise<TurnCommandResult> {
38
+ // Validate the input itself before resolving the agent: a malformed/unreplayable
39
+ // fixture is a usage problem an agent should see regardless of whether the
40
+ // --agent module even resolves.
41
+ const inputPath = resolve(opts.inputPath);
42
+ const isFixtureSidecar = extname(inputPath).toLowerCase() === ".json";
43
+ const fixture = isFixtureSidecar ? loadFixture(inputPath) : undefined;
44
+ const wavPath = fixture ? fixture.wavPath : inputPath;
45
+
46
+ const resolvedAgent = await resolveAgentFactory(opts.agentSpec, opts.cwd ?? process.cwd());
47
+ const sessionDir = opts.sessionDir ?? mkdtempSync(join(tmpdir(), "syrinx-turn-"));
48
+
49
+ let result;
50
+ try {
51
+ result = await driveTurn({
52
+ session: resolvedAgent.factory,
53
+ inputWavPath: wavPath,
54
+ sessionDir,
55
+ });
56
+ } catch (err) {
57
+ if (err instanceof CliError) throw err;
58
+ throw new CliError(EXIT_CODES.BACKEND, `turn failed: ${err instanceof Error ? err.message : String(err)}`);
59
+ }
60
+
61
+ let assertion: TranscriptAssertion | null = null;
62
+ if (fixture?.sidecar.expectedTranscript !== undefined) {
63
+ assertion = assertTranscript(fixture.sidecar.expectedTranscript, result.finalTranscript);
64
+ if (!assertion.match) {
65
+ throw new CliError(EXIT_CODES.ASSERTION, "replayed fixture's transcript drifted from the expected transcript", {
66
+ assertion,
67
+ });
68
+ }
69
+ }
70
+
71
+ return {
72
+ input: inputPath,
73
+ agent: resolvedAgent.label,
74
+ sessionDir: result.sessionDir,
75
+ transcript: result.finalTranscript,
76
+ reply: result.agentReply,
77
+ timings: result.metrics,
78
+ durationMs: result.durationMs,
79
+ assertion,
80
+ };
81
+ }