Skip to content

Commit a0d58b0

Browse files
committed
CLI: make the stream renderer generic with per-endpoint event strategies
Addresses review feedback: the pretty renderer no longer sniffs event shapes in one monolith. createStreamRenderer now owns only the generic mechanics (NDJSON/ YAML serialization, the sink + mid-line tracking, end-of-stream flush) and delegates per-event rendering to an injectable StreamEventRenderer. The three schemas get dedicated strategies (answer / question / agent-response) plus a generic fallback; the generator selects one per endpoint from the SSE response's event schema (STREAM_RENDERERS), so a new/changed schema means a new strategy at the call site rather than editing the core.
1 parent d734c56 commit a0d58b0

3 files changed

Lines changed: 188 additions & 77 deletions

File tree

packages/cli/src/output.test.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
CLI_TIMEOUT_MS,
66
coerceArrayQueryParam,
77
coerceBodyFlag,
8+
createAgentResponseStreamRenderer,
9+
createAnswerStreamRenderer,
10+
createGenericStreamRenderer,
11+
createQuestionStreamRenderer,
812
createRequestTimeout,
913
createStreamRenderer,
1014
documentToText,
@@ -335,7 +339,7 @@ describe('createStreamRenderer', () => {
335339

336340
it('pretty renders a recommended-question stream as bullets', () => {
337341
const out = captureStdout(() => {
338-
const r = createStreamRenderer({ pretty: true });
342+
const r = createStreamRenderer({ pretty: true }, createQuestionStreamRenderer());
339343
r.write({ question: 'How do I start?' });
340344
r.write({ question: 'What is X?' });
341345
r.end();
@@ -345,7 +349,7 @@ describe('createStreamRenderer', () => {
345349

346350
it('pretty prints answer text incrementally, then sources and follow-ups', () => {
347351
const out = captureStdout(() => {
348-
const r = createStreamRenderer({ pretty: true });
352+
const r = createStreamRenderer({ pretty: true }, createAnswerStreamRenderer());
349353
// Each event carries the answer so far; only the new suffix prints.
350354
r.write({
351355
type: 'answer',
@@ -368,7 +372,7 @@ describe('createStreamRenderer', () => {
368372

369373
it('pretty renders a document-form answer (--format document) as text', () => {
370374
const out = captureStdout(() => {
371-
const r = createStreamRenderer({ pretty: true });
375+
const r = createStreamRenderer({ pretty: true }, createAnswerStreamRenderer());
372376
r.write({
373377
type: 'answer',
374378
answer: {
@@ -403,7 +407,7 @@ describe('createStreamRenderer', () => {
403407

404408
it('pretty renders agent-response events as concise status lines', () => {
405409
const out = captureStdout(() => {
406-
const r = createStreamRenderer({ pretty: true });
410+
const r = createStreamRenderer({ pretty: true }, createAgentResponseStreamRenderer());
407411
r.write({ type: 'response_start', messageId: 'm1' });
408412
r.write({ type: 'response_document', operation: 'insert', blocks: [{}, {}] });
409413
r.write({ type: 'response_finish', messageId: 'm1', response: {} });
@@ -413,6 +417,32 @@ describe('createStreamRenderer', () => {
413417
'[response_start]\n[response_document] insert — 2 block(s)\n[response_finish]\n',
414418
);
415419
});
420+
421+
it('generic renderer (the default) streams strings inline and blocks objects', () => {
422+
const withDefault = captureStdout(() => {
423+
const r = createStreamRenderer({ pretty: true }); // no strategy → generic
424+
r.write('hello ');
425+
r.write('world');
426+
r.end();
427+
});
428+
expect(withDefault).toBe('hello world\n');
429+
430+
const explicit = captureStdout(() => {
431+
const r = createStreamRenderer({ pretty: true }, createGenericStreamRenderer());
432+
r.write({ a: 1 });
433+
r.end();
434+
});
435+
expect(explicit).toBe('a: 1\n');
436+
});
437+
438+
it('machine formats ignore the event renderer (stay schema-agnostic)', () => {
439+
const out = captureStdout(() => {
440+
const r = createStreamRenderer({ json: true }, createAnswerStreamRenderer());
441+
r.write({ anything: true });
442+
r.end();
443+
});
444+
expect(out).toBe('{"anything":true}\n');
445+
});
416446
});
417447

418448
describe('documentToText', () => {

packages/cli/src/output.ts

Lines changed: 117 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,37 @@ export interface StreamRenderer {
377377
end(): void;
378378
}
379379

380+
// A line-oriented output target handed to StreamEventRenderers. `out` writes
381+
// inline (and may leave the cursor mid-line, e.g. incremental answer text);
382+
// `line` writes a full line. The core tracks the mid-line state so `end` can
383+
// flush a trailing newline before any end-of-stream content.
384+
export interface StreamSink {
385+
out(text: string): void;
386+
line(text: string): void;
387+
}
388+
389+
// Strategy for rendering ONE family of events in pretty mode. The core renderer
390+
// owns all the generic mechanics (machine-format serialization, the sink and its
391+
// mid-line tracking, calling `end`); a StreamEventRenderer only has to know how to
392+
// turn its own event schema into text. New/changed event schemas mean a new
393+
// strategy passed at the call site — the core never changes. See generate-commands.ts,
394+
// which selects the strategy per endpoint from the response's event-stream schema.
395+
export interface StreamEventRenderer {
396+
write(event: unknown, sink: StreamSink): void;
397+
// Flush end-of-stream content (e.g. citations). Optional; defaults to a no-op.
398+
end?(sink: StreamSink): void;
399+
}
400+
380401
// Machine formats stream one record per event so a consumer can parse the output
381402
// incrementally: NDJSON for --json (one compact object per line), a stream of
382-
// YAML documents for --yaml (each introduced by the `---` marker). Pretty mode
383-
// renders progressively (see createPrettyStreamRenderer). Format resolution — and
384-
// the piped-defaults-to-YAML behaviour — is shared with printResult.
385-
export function createStreamRenderer(options: OutputOptions): StreamRenderer {
403+
// YAML documents for --yaml (each introduced by the `---` marker) — both fully
404+
// schema-agnostic. Pretty mode delegates per-event rendering to `eventRenderer`
405+
// (a generic fallback when the caller doesn't supply one). Format resolution —
406+
// and the piped-defaults-to-YAML behaviour — is shared with printResult.
407+
export function createStreamRenderer(
408+
options: OutputOptions,
409+
eventRenderer: StreamEventRenderer = createGenericStreamRenderer(),
410+
): StreamRenderer {
386411
const format = resolveFormat(options);
387412
if (format === 'json') {
388413
return {
@@ -397,89 +422,110 @@ export function createStreamRenderer(options: OutputOptions): StreamRenderer {
397422
end: () => {},
398423
};
399424
}
400-
return createPrettyStreamRenderer();
401-
}
402-
403-
// Human-readable streaming. The three event shapes these endpoints emit are each
404-
// rendered on the fly:
405-
// - recommended-question events (`{ question }`) → one bullet per question
406-
// - answer snapshots (`{ type: 'answer', answer }`) → the answer text, printed
407-
// incrementally as the snapshot grows, then its sources as citations at end
408-
// - agent-response events (`{ type: 'response_*' }`) → a concise status line
409-
// Unknown shapes fall back to a compact block so nothing is silently dropped.
410-
function createPrettyStreamRenderer(): StreamRenderer {
411-
let printedAnswerLen = 0; // chars of the (string) answer already written
425+
412426
let midLine = false; // last write left the cursor mid-line
413-
let sources: unknown[] | null = null;
414-
let followups: unknown[] | null = null;
427+
const sink: StreamSink = {
428+
out(text) {
429+
if (text.length === 0) return;
430+
process.stdout.write(text);
431+
midLine = !text.endsWith('\n');
432+
},
433+
line(text) {
434+
process.stdout.write(`${text}\n`);
435+
midLine = false;
436+
},
437+
};
438+
return {
439+
write: (event) => eventRenderer.write(event, sink),
440+
end: () => {
441+
if (midLine) {
442+
process.stdout.write('\n');
443+
midLine = false;
444+
}
445+
eventRenderer.end?.(sink);
446+
},
447+
};
448+
}
415449

416-
const out = (s: string) => {
417-
if (s.length === 0) return;
418-
process.stdout.write(s);
419-
midLine = !s.endsWith('\n');
450+
// Fallback strategy: no schema knowledge. Strings stream inline; objects/scalars
451+
// print as a compact block. Used for any streaming endpoint without a dedicated
452+
// renderer, so a new SSE endpoint is never silently blank.
453+
export function createGenericStreamRenderer(): StreamEventRenderer {
454+
return {
455+
write(event, sink) {
456+
if (typeof event === 'string') sink.out(event);
457+
else if (isPlainObject(event)) sink.line(formatPretty(event));
458+
else sink.line(formatScalar(event));
459+
},
420460
};
421-
const line = (s: string) => {
422-
process.stdout.write(`${s}\n`);
423-
midLine = false;
461+
}
462+
463+
// `SearchAIRecommendedQuestionStream`: one `{ question }` per event → a bullet.
464+
export function createQuestionStreamRenderer(): StreamEventRenderer {
465+
return {
466+
write(event, sink) {
467+
if (isPlainObject(event) && typeof event.question === 'string') {
468+
sink.line(`• ${event.question}`);
469+
}
470+
},
424471
};
472+
}
425473

474+
// `SearchAIAnswerStream`: progressive `{ type: 'answer', answer }` snapshots. Each
475+
// event carries the answer so far, so only the new suffix is printed; the latest
476+
// snapshot's sources/follow-ups render as citations once the stream ends.
477+
export function createAnswerStreamRenderer(): StreamEventRenderer {
478+
let printedAnswerLen = 0;
479+
let sources: unknown[] | null = null;
480+
let followups: unknown[] | null = null;
426481
return {
427-
write(event: unknown): void {
428-
if (typeof event === 'string') {
429-
out(event);
482+
write(event, sink) {
483+
if (!isPlainObject(event) || event.type !== 'answer' || !isPlainObject(event.answer)) {
430484
return;
431485
}
432-
if (!isPlainObject(event)) {
433-
line(formatScalar(event));
434-
return;
486+
const answer = event.answer;
487+
if (Array.isArray(answer.sources)) sources = answer.sources;
488+
if (Array.isArray(answer.followupQuestions)) followups = answer.followupQuestions;
489+
const text = streamAnswerText(answer.answer);
490+
if (text !== undefined) {
491+
if (text.length >= printedAnswerLen) sink.out(text.slice(printedAnswerLen));
492+
else sink.out(`\n${text}`); // shrank unexpectedly: reprint whole
493+
printedAnswerLen = text.length;
435494
}
436-
// Recommended-question stream: one `{ question }` per event.
437-
if (typeof event.question === 'string' && event.type === undefined) {
438-
line(`• ${event.question}`);
495+
},
496+
end(sink) {
497+
if (sources && sources.length > 0) {
498+
sink.line('');
499+
sink.line('Sources:');
500+
for (const s of sources) sink.line(` - ${formatStreamSource(s)}`);
501+
}
502+
if (followups && followups.length > 0) {
503+
sink.line('');
504+
sink.line('Follow-up questions:');
505+
for (const q of followups) sink.line(` - ${formatScalar(q)}`);
506+
}
507+
},
508+
};
509+
}
510+
511+
// `AIStreamResponse`: a status line per event, except the JSON-object chunks,
512+
// which are raw text to concatenate.
513+
export function createAgentResponseStreamRenderer(): StreamEventRenderer {
514+
return {
515+
write(event, sink) {
516+
if (!isPlainObject(event)) {
517+
sink.line(formatScalar(event));
439518
return;
440519
}
441-
// Answer stream: progressive `{ type: 'answer', answer }` snapshots.
442-
// Each event carries the answer so far, so we print only the new
443-
// suffix; sources/followups from the latest snapshot render at end.
444-
if (event.type === 'answer' && isPlainObject(event.answer)) {
445-
const answer = event.answer;
446-
if (Array.isArray(answer.sources)) sources = answer.sources;
447-
if (Array.isArray(answer.followupQuestions)) followups = answer.followupQuestions;
448-
const text = streamAnswerText(answer.answer);
449-
if (text !== undefined) {
450-
if (text.length >= printedAnswerLen) out(text.slice(printedAnswerLen));
451-
else out(`\n${text}`); // shrank unexpectedly: reprint whole
452-
printedAnswerLen = text.length;
453-
}
520+
if (typeof event.jsonChunk === 'string') {
521+
sink.out(event.jsonChunk);
454522
return;
455523
}
456-
// Agent-response stream (AIStreamResponse): a status line per event,
457-
// except the JSON-object chunks which are raw text to concatenate.
458524
if (typeof event.type === 'string') {
459-
if (typeof event.jsonChunk === 'string') {
460-
out(event.jsonChunk);
461-
return;
462-
}
463-
line(streamStatusLine(event));
525+
sink.line(streamStatusLine(event));
464526
return;
465527
}
466-
line(formatPretty(event));
467-
},
468-
end(): void {
469-
if (midLine) {
470-
process.stdout.write('\n');
471-
midLine = false;
472-
}
473-
if (sources && sources.length > 0) {
474-
line('');
475-
line('Sources:');
476-
for (const s of sources) line(` - ${formatStreamSource(s)}`);
477-
}
478-
if (followups && followups.length > 0) {
479-
line('');
480-
line('Follow-up questions:');
481-
for (const q of followups) line(` - ${formatScalar(q)}`);
482-
}
528+
sink.line(formatPretty(event));
483529
},
484530
};
485531
}

scripts/generate-commands.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,20 @@ function isStreamingOperation(operation: Operation): boolean {
288288
return false;
289289
}
290290

291+
// The schema name of an SSE operation's event (e.g. `SearchAIAnswerStream`), read
292+
// from the `text/event-stream` response's schema `$ref`. Drives per-endpoint
293+
// renderer selection in emitStreamingCommand. Undefined if not resolvable.
294+
function streamEventSchemaName(operation: Operation): string | undefined {
295+
for (const response of Object.values(operation.responses ?? {})) {
296+
const stream = (
297+
response.content as Record<string, { schema?: SchemaObject }> | undefined
298+
)?.['text/event-stream'];
299+
const ref = stream?.schema?.$ref ?? stream?.schema?.items?.$ref;
300+
if (typeof ref === 'string') return ref.match(/\/([^/]+)$/)?.[1];
301+
}
302+
return undefined;
303+
}
304+
291305
// ─── Path parsing ───────────────────────────────────────────────────────────--
292306

293307
interface Seg {
@@ -557,6 +571,9 @@ interface Route {
557571
bodyFlags: BodyFlag[] | null;
558572
hasBody: boolean;
559573
isStreaming: boolean; // SSE endpoint → emitted via the streaming path
574+
// Schema name of the SSE event (from the text/event-stream response), used to
575+
// pick the pretty StreamEventRenderer. Undefined for non-streaming routes.
576+
streamEventSchema?: string;
560577
naming: Naming;
561578
}
562579

@@ -628,6 +645,7 @@ function buildRoutes(spec: OpenAPISpec): Route[] {
628645
bodyFlags,
629646
hasBody,
630647
isStreaming: isStreamingOperation(operation),
648+
streamEventSchema: streamEventSchemaName(operation),
631649
naming: computeNaming(
632650
upper,
633651
apiPath,
@@ -1043,6 +1061,17 @@ function emitSimpleCommand(
10431061
return lines.join('\n');
10441062
}
10451063

1064+
// Maps an SSE event schema name to the output-module StreamEventRenderer factory
1065+
// that knows how to render it in pretty mode. An unmapped schema falls back to the
1066+
// generic renderer (createStreamRenderer's default) — so a new streaming endpoint
1067+
// is never silently blank, it just gets a generic rendering until a strategy is
1068+
// added here. This is the one place endpoint↔renderer wiring lives.
1069+
const STREAM_RENDERERS: Record<string, string> = {
1070+
SearchAIAnswerStream: 'createAnswerStreamRenderer',
1071+
SearchAIRecommendedQuestionStream: 'createQuestionStreamRenderer',
1072+
AIStreamResponse: 'createAgentResponseStreamRenderer',
1073+
};
1074+
10461075
// SSE endpoint: iterate the event stream through a StreamRenderer instead of the
10471076
// buffered printResult path. A partial answer stays on screen if the stream errors
10481077
// mid-flight (renderer.end() flushes first, then the error prints); SIGINT flushes
@@ -1069,7 +1098,13 @@ function emitStreamingCommand(
10691098
if (formatQp) {
10701099
lines.push(`${I} if (options.format === undefined) query['format'] = 'markdown';`);
10711100
}
1072-
lines.push(`${I} const stream = createStreamRenderer(options);`);
1101+
// Pick the pretty renderer for this endpoint's event schema; unmapped schemas
1102+
// fall through to createStreamRenderer's generic default.
1103+
const rendererFactory = route.streamEventSchema
1104+
? STREAM_RENDERERS[route.streamEventSchema]
1105+
: undefined;
1106+
const rendererArg = rendererFactory ? `, ${rendererFactory}()` : '';
1107+
lines.push(`${I} const stream = createStreamRenderer(options${rendererArg});`);
10731108
lines.push(`${I} const timeout = createRequestTimeout();`);
10741109
lines.push(`${I} const onSigint = () => {`);
10751110
lines.push(`${I} timeout?.clear();`);
@@ -1245,7 +1280,7 @@ function emitFile(tree: GroupNode, completions: Record<string, string>): string
12451280
lines.push(`// Output formatting lives in ./output (a real, unit-tested module) rather than`);
12461281
lines.push(`// being inlined here, so the rendering logic has a single source of truth.`);
12471282
lines.push(
1248-
`import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer, createRequestTimeout, explainTimeout } from './output';`,
1283+
`import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer, createAnswerStreamRenderer, createQuestionStreamRenderer, createAgentResponseStreamRenderer, createRequestTimeout, explainTimeout } from './output';`,
12491284
);
12501285
lines.push(``);
12511286
lines.push(`export function registerGeneratedCommands(program: Command): void {`);

0 commit comments

Comments
 (0)