Skip to content

Commit 9c3da94

Browse files
committed
ref(cli): Flatten JSONL event envelope
Project domain fragments directly onto the public `--output jsonl` wire shape instead of wrapping them in `{type:"fragment", fragment}`. The event discriminator is derived mechanically from the fragment's `kind` and `fragment` fields (lowercased `<kind>.<fragment>`) and all remaining fields pass through unchanged, so new fragments surface automatically without mapping work.
1 parent a4baa6f commit 9c3da94

4 files changed

Lines changed: 185 additions & 5 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { toCliJsonlEvent } from '../jsonl-event.ts';
3+
import type { AnyFragment } from '../../types/domain-fragments.ts';
4+
5+
describe('toCliJsonlEvent', () => {
6+
it('derives the event name from kind and fragment', () => {
7+
const fragment: AnyFragment = {
8+
kind: 'build-result',
9+
fragment: 'build-summary',
10+
operation: 'BUILD',
11+
status: 'SUCCEEDED',
12+
durationMs: 3421,
13+
};
14+
15+
expect(toCliJsonlEvent(fragment)).toEqual({
16+
event: 'build-result.build-summary',
17+
operation: 'BUILD',
18+
status: 'SUCCEEDED',
19+
durationMs: 3421,
20+
});
21+
});
22+
23+
it('lowercases the event discriminator without touching payload casing', () => {
24+
const fragment = {
25+
kind: 'Build-Result',
26+
fragment: 'Build-Stage',
27+
operation: 'BUILD',
28+
stage: 'COMPILING',
29+
message: 'Compiling CalculatorApp',
30+
} as unknown as AnyFragment;
31+
32+
expect(toCliJsonlEvent(fragment)).toEqual({
33+
event: 'build-result.build-stage',
34+
operation: 'BUILD',
35+
stage: 'COMPILING',
36+
message: 'Compiling CalculatorApp',
37+
});
38+
});
39+
40+
it('passes invocation request payloads through untouched', () => {
41+
const fragment: AnyFragment = {
42+
kind: 'build-result',
43+
fragment: 'invocation',
44+
operation: 'BUILD',
45+
request: {
46+
scheme: 'CalculatorApp',
47+
workspacePath: 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace',
48+
configuration: 'Debug',
49+
platform: 'iOS Simulator',
50+
simulatorName: 'iPhone 17',
51+
},
52+
};
53+
54+
expect(toCliJsonlEvent(fragment)).toEqual({
55+
event: 'build-result.invocation',
56+
operation: 'BUILD',
57+
request: {
58+
scheme: 'CalculatorApp',
59+
workspacePath: 'example_projects/iOS_Calculator/CalculatorApp.xcworkspace',
60+
configuration: 'Debug',
61+
platform: 'iOS Simulator',
62+
simulatorName: 'iPhone 17',
63+
},
64+
});
65+
});
66+
67+
it('maps compiler diagnostics preserving severity and rawLine', () => {
68+
const fragment: AnyFragment = {
69+
kind: 'build-result',
70+
fragment: 'compiler-diagnostic',
71+
operation: 'BUILD',
72+
severity: 'warning',
73+
message: 'unused variable',
74+
location: '/repo/App.swift:12:5',
75+
rawLine: '/repo/App.swift:12:5: warning: unused variable',
76+
};
77+
78+
expect(toCliJsonlEvent(fragment)).toEqual({
79+
event: 'build-result.compiler-diagnostic',
80+
operation: 'BUILD',
81+
severity: 'warning',
82+
message: 'unused variable',
83+
location: '/repo/App.swift:12:5',
84+
rawLine: '/repo/App.swift:12:5: warning: unused variable',
85+
});
86+
});
87+
88+
it('maps test failures with full context', () => {
89+
const fragment: AnyFragment = {
90+
kind: 'test-result',
91+
fragment: 'test-failure',
92+
operation: 'TEST',
93+
target: 'CalculatorAppTests',
94+
suite: 'CalculatorAppTests',
95+
test: 'testA',
96+
message: 'XCTAssertEqual failed',
97+
location: '/repo/Tests/CalculatorAppTests.swift:14',
98+
durationMs: 12,
99+
};
100+
101+
expect(toCliJsonlEvent(fragment)).toEqual({
102+
event: 'test-result.test-failure',
103+
operation: 'TEST',
104+
target: 'CalculatorAppTests',
105+
suite: 'CalculatorAppTests',
106+
test: 'testA',
107+
message: 'XCTAssertEqual failed',
108+
location: '/repo/Tests/CalculatorAppTests.swift:14',
109+
durationMs: 12,
110+
});
111+
});
112+
113+
it('maps build-run phase fragments', () => {
114+
const fragment: AnyFragment = {
115+
kind: 'build-run-result',
116+
fragment: 'phase',
117+
phase: 'boot-simulator',
118+
status: 'started',
119+
};
120+
121+
expect(toCliJsonlEvent(fragment)).toEqual({
122+
event: 'build-run-result.phase',
123+
phase: 'boot-simulator',
124+
status: 'started',
125+
});
126+
});
127+
128+
it('maps transcript process-line fragments', () => {
129+
const fragment: AnyFragment = {
130+
kind: 'transcript',
131+
fragment: 'process-line',
132+
stream: 'stdout',
133+
line: 'CompileSwift normal arm64 /repo/App.swift\n',
134+
};
135+
136+
expect(toCliJsonlEvent(fragment)).toEqual({
137+
event: 'transcript.process-line',
138+
stream: 'stdout',
139+
line: 'CompileSwift normal arm64 /repo/App.swift\n',
140+
});
141+
});
142+
143+
it('maps runtime status fragments', () => {
144+
const fragment: AnyFragment = {
145+
kind: 'infrastructure',
146+
fragment: 'status',
147+
level: 'info',
148+
message: 'Starting work',
149+
};
150+
151+
expect(toCliJsonlEvent(fragment)).toEqual({
152+
event: 'infrastructure.status',
153+
level: 'info',
154+
message: 'Starting work',
155+
});
156+
});
157+
});

src/cli/__tests__/register-tool-commands.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -489,8 +489,8 @@ describe('registerToolCommands', () => {
489489
).resolves.toBeDefined();
490490

491491
expect(stdoutChunks.join('')).toBe(
492-
`${JSON.stringify({ type: 'fragment', fragment: { kind: 'presentation', fragment: 'status', level: 'info', message: 'Starting work' } })}\n` +
493-
`${JSON.stringify({ type: 'fragment', fragment: { kind: 'presentation', fragment: 'artifact', name: 'Build Log', path: '/tmp/build.log' } })}\n`,
492+
`${JSON.stringify({ event: 'presentation.status', level: 'info', message: 'Starting work' })}\n` +
493+
`${JSON.stringify({ event: 'presentation.artifact', name: 'Build Log', path: '/tmp/build.log' })}\n`,
494494
);
495495
});
496496

@@ -536,7 +536,11 @@ describe('registerToolCommands', () => {
536536

537537
expect(observedSessionFragmentCount).toBe(1);
538538
expect(stdoutChunks.join('')).toBe(
539-
`${JSON.stringify({ type: 'fragment', fragment: streamedFragment })}\n`,
539+
`${JSON.stringify({
540+
event: 'transcript.process-line',
541+
stream: 'stderr',
542+
line: 'Build Log: /tmp/build.log\n',
543+
})}\n`,
540544
);
541545
});
542546

src/cli/jsonl-event.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { AnyFragment } from '../types/domain-fragments.ts';
2+
3+
/**
4+
* Convert a domain fragment into the public `--output jsonl` wire shape.
5+
*
6+
* The envelope is a single `event` discriminator derived mechanically from
7+
* the fragment's internal `kind` and `fragment` discriminators:
8+
*
9+
* event = `<kind>.<fragment>` (lowercased)
10+
*
11+
* All remaining fields pass through unchanged so the public shape is a pure
12+
* projection of the domain model. New fragments surface automatically with
13+
* no mapping work — contributors only need to keep `kind` / `fragment`
14+
* discriminator values lowercase-kebab.
15+
*/
16+
export function toCliJsonlEvent(fragment: AnyFragment): Record<string, unknown> {
17+
const { kind, fragment: type, ...rest } = fragment;
18+
return { event: `${kind}.${type}`.toLowerCase(), ...rest };
19+
}

src/cli/register-tool-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from './session-defaults.ts';
1919
import { createRenderSession } from '../rendering/render.ts';
2020
import { toStructuredEnvelope } from '../utils/structured-output-envelope.ts';
21+
import { toCliJsonlEvent } from './jsonl-event.ts';
2122

2223
export interface RegisterToolCommandsOptions {
2324
workspaceRoot: string;
@@ -354,8 +355,7 @@ function registerToolSubcommand(
354355
const writeJsonlFragment =
355356
outputFormat === 'jsonl'
356357
? (fragment: AnyFragment) => {
357-
const line = { type: 'fragment' as const, fragment };
358-
process.stdout.write(JSON.stringify(line) + '\n');
358+
process.stdout.write(JSON.stringify(toCliJsonlEvent(fragment)) + '\n');
359359
}
360360
: undefined;
361361
const handlerContext = createBufferedHandlerContext(session, {

0 commit comments

Comments
 (0)