Skip to content

Commit 41f3544

Browse files
committed
refactor: unify rendering pipeline and daemon protocol v2
RenderSession.emit() now returns void instead of TextRenderOp, removing the dual text/event tracking pattern. CLI output modes (text, json) are handled by dedicated render sessions (cli-text, cli-json) that write to stdout directly, eliminating the printSessionOutput intermediary. Daemon protocol bumped to v2 with DaemonToolResult replacing ToolResponse on the wire. Added DaemonVersionMismatchError and auto-restart logic so CLI transparently recovers when connecting to a stale daemon. Removed postProcessToolResponse in favor of postProcessSession which operates on sessions directly across MCP, daemon, and snapshot harness boundaries.
1 parent da1da39 commit 41f3544

25 files changed

+477
-681
lines changed

src/cli/__tests__/output.test.ts

Lines changed: 12 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,125 +1,15 @@
1-
import { afterEach, describe, expect, it, vi } from 'vitest';
2-
import { printSessionOutput, type SessionOutputData } from '../output.ts';
3-
import type { PipelineEvent } from '../../types/pipeline-events.ts';
4-
5-
function makeSessionData(opts: {
6-
text: string;
7-
events?: PipelineEvent[];
8-
isError?: boolean;
9-
}): SessionOutputData {
10-
return {
11-
text: opts.text,
12-
events: opts.events ?? [],
13-
attachments: [],
14-
isError: opts.isError ?? false,
15-
};
16-
}
17-
18-
describe('printSessionOutput', () => {
19-
const originalNoColor = process.env.NO_COLOR;
20-
const originalIsTTY = process.stdout.isTTY;
21-
22-
afterEach(() => {
23-
vi.restoreAllMocks();
24-
25-
if (originalNoColor === undefined) {
26-
delete process.env.NO_COLOR;
27-
} else {
28-
process.env.NO_COLOR = originalNoColor;
29-
}
30-
31-
Object.defineProperty(process.stdout, 'isTTY', {
32-
configurable: true,
33-
value: originalIsTTY,
34-
});
35-
});
36-
37-
it('colors inline errors red and summary failures with a red marker in text output when stdout is a TTY', () => {
38-
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
39-
Object.defineProperty(process.stdout, 'isTTY', {
40-
configurable: true,
41-
value: true,
42-
});
43-
delete process.env.NO_COLOR;
44-
45-
const text = [
46-
'Failed Tests',
47-
'CalculatorAppTests',
48-
' \u2717 testCalculatorServiceFailure (0.009s)',
49-
' \u2514\u2500 XCTAssertEqual failed: ("0") is not equal to ("999")',
50-
' /tmp/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999")',
51-
'Test Summary',
52-
'error: compiler command failed with exit code 1',
53-
'\u274C Test Run test failed for scheme CalculatorApp.',
54-
].join('\n');
55-
56-
printSessionOutput(makeSessionData({ text, isError: true }));
57-
58-
const output = stdoutWrite.mock.calls.flat().join('');
59-
expect(output).toContain('Failed Tests\n');
60-
expect(output).toContain(' \u001B[31m\u2717 \u001B[0mtestCalculatorServiceFailure (0.009s)\n');
61-
expect(output).toContain(
62-
' \u2514\u2500 XCTAssertEqual failed: ("0") is not equal to ("999")\n',
63-
);
64-
expect(output).toContain(
65-
'\u001B[31m /tmp/CalculatorAppTests.swift:52: error: XCTAssertEqual failed: ("0") is not equal to ("999")\u001B[0m',
66-
);
67-
expect(output).toContain(
68-
'\u001B[31merror: compiler command failed with exit code 1\u001B[0m\n',
69-
);
70-
expect(output).toContain(
71-
'\u001B[31m\u274C \u001B[0mTest Run test failed for scheme CalculatorApp.\n',
72-
);
73-
expect(output).toContain('CalculatorAppTests\n');
74-
expect(output).toContain('Test Summary\n');
75-
expect(process.exitCode).toBe(1);
76-
});
77-
78-
it('prints session events via CLI renderer when all events are renderable', () => {
79-
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
80-
Object.defineProperty(process.stdout, 'isTTY', {
81-
configurable: true,
82-
value: true,
83-
});
84-
85-
const events: PipelineEvent[] = [
86-
{
87-
type: 'status-line',
88-
timestamp: '2026-03-18T12:00:00.000Z',
89-
level: 'success',
90-
message: 'Build succeeded',
91-
},
92-
];
93-
94-
printSessionOutput(makeSessionData({ text: 'Build succeeded', events }));
95-
96-
const output = stdoutWrite.mock.calls.flat().join('');
97-
expect(output).toContain('Build succeeded');
98-
});
99-
100-
it('prints events as JSONL in json format', () => {
101-
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
102-
103-
const events: PipelineEvent[] = [
104-
{
105-
type: 'status-line',
106-
timestamp: '2026-03-18T12:00:00.000Z',
107-
level: 'success',
108-
message: 'Build succeeded',
109-
},
110-
{
111-
type: 'next-steps',
112-
timestamp: '2026-03-18T12:00:02.000Z',
113-
steps: [{ tool: 'launch_app_sim' }],
114-
},
1+
import { describe, expect, it } from 'vitest';
2+
import { formatToolList } from '../output.ts';
3+
4+
describe('formatToolList', () => {
5+
it('formats ungrouped tool list', () => {
6+
const tools = [
7+
{ cliName: 'build', workflow: 'xcode', description: 'Build project', stateful: false },
8+
{ cliName: 'test', workflow: 'xcode', description: 'Run tests', stateful: true },
1159
];
116-
117-
printSessionOutput(makeSessionData({ text: 'Build succeeded', events }), { format: 'json' });
118-
119-
const output = stdoutWrite.mock.calls.flat().join('');
120-
const lines = output.trim().split('\n');
121-
expect(lines).toHaveLength(2);
122-
expect(JSON.parse(lines[0])).toEqual(events[0]);
123-
expect(JSON.parse(lines[1])).toEqual(events[1]);
10+
const output = formatToolList(tools);
11+
expect(output).toContain('xcode build');
12+
expect(output).toContain('xcode test');
13+
expect(output).toContain('[stateful]');
12414
});
12515
});

src/cli/daemon-client.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type DaemonRequest,
77
type DaemonResponse,
88
type DaemonMethod,
9+
type DaemonToolResult,
910
type ToolInvokeParams,
1011
type ToolInvokeResult,
1112
type DaemonStatusResult,
@@ -16,9 +17,15 @@ import {
1617
type XcodeIdeInvokeParams,
1718
type XcodeIdeInvokeResult,
1819
} from '../daemon/protocol.ts';
19-
import type { ToolResponse } from '../types/common.ts';
2020
import { getSocketPath } from '../daemon/socket-path.ts';
2121

22+
export class DaemonVersionMismatchError extends Error {
23+
constructor(message: string) {
24+
super(message);
25+
this.name = 'DaemonVersionMismatchError';
26+
}
27+
}
28+
2229
export interface DaemonClientOptions {
2330
socketPath?: string;
2431
timeout?: number;
@@ -81,7 +88,14 @@ export class DaemonClient {
8188
socket.end();
8289

8390
if (res.error) {
84-
reject(new Error(`${res.error.code}: ${res.error.message}`));
91+
if (
92+
res.error.code === 'BAD_REQUEST' &&
93+
res.error.message.startsWith('Unsupported protocol version')
94+
) {
95+
reject(new DaemonVersionMismatchError(res.error.message));
96+
} else {
97+
reject(new Error(`${res.error.code}: ${res.error.message}`));
98+
}
8599
} else {
86100
resolve(res.result as TResult);
87101
}
@@ -124,12 +138,12 @@ export class DaemonClient {
124138
/**
125139
* Invoke a tool.
126140
*/
127-
async invokeTool(tool: string, args: Record<string, unknown>): Promise<ToolResponse> {
141+
async invokeTool(tool: string, args: Record<string, unknown>): Promise<DaemonToolResult> {
128142
const result = await this.request<ToolInvokeResult>('tool.invoke', {
129143
tool,
130144
args,
131145
} satisfies ToolInvokeParams);
132-
return result.response;
146+
return result.result;
133147
}
134148

135149
/**
@@ -146,12 +160,12 @@ export class DaemonClient {
146160
async invokeXcodeIdeTool(
147161
remoteTool: string,
148162
args: Record<string, unknown>,
149-
): Promise<ToolResponse> {
163+
): Promise<DaemonToolResult> {
150164
const result = await this.request<XcodeIdeInvokeResult>('xcode-ide.invoke', {
151165
remoteTool,
152166
args,
153167
} satisfies XcodeIdeInvokeParams);
154-
return result.response as ToolResponse;
168+
return result.result;
155169
}
156170

157171
/**

src/cli/daemon-control.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { spawn } from 'node:child_process';
22
import { fileURLToPath } from 'node:url';
3-
import { dirname, resolve } from 'node:path';
3+
import { dirname, resolve, basename } from 'node:path';
44
import { existsSync } from 'node:fs';
5-
import { DaemonClient } from './daemon-client.ts';
5+
import { DaemonClient, DaemonVersionMismatchError } from './daemon-client.ts';
6+
import { readDaemonRegistryEntry } from '../daemon/daemon-registry.ts';
7+
import { removeStaleSocket } from '../daemon/socket-path.ts';
68

79
/**
810
* Default timeout for daemon startup in milliseconds.
@@ -30,6 +32,26 @@ export function getDaemonExecutablePath(): string {
3032
return resolve(buildDir, '..', 'daemon.ts');
3133
}
3234

35+
/**
36+
* Force-stop a daemon that cannot be stopped gracefully (e.g. protocol version mismatch).
37+
* Derives the workspace key from the socket path, reads the registry for the PID,
38+
* sends SIGTERM, and removes the stale socket.
39+
*/
40+
export async function forceStopDaemon(socketPath: string): Promise<void> {
41+
const workspaceKey = basename(dirname(socketPath));
42+
const entry = readDaemonRegistryEntry(workspaceKey);
43+
if (entry?.pid) {
44+
try {
45+
process.kill(entry.pid, 'SIGTERM');
46+
} catch {
47+
// Process may already be gone.
48+
}
49+
// Brief wait for the process to exit.
50+
await new Promise((resolve) => setTimeout(resolve, 500));
51+
}
52+
removeStaleSocket(socketPath);
53+
}
54+
3355
export interface StartDaemonBackgroundOptions {
3456
socketPath: string;
3557
workspaceRoot?: string;
@@ -113,7 +135,16 @@ export async function ensureDaemonRunning(opts: EnsureDaemonRunningOptions): Pro
113135

114136
const isRunning = await client.isRunning();
115137
if (isRunning) {
116-
return;
138+
try {
139+
await client.status();
140+
return;
141+
} catch (error) {
142+
if (error instanceof DaemonVersionMismatchError) {
143+
await forceStopDaemon(opts.socketPath);
144+
} else {
145+
return;
146+
}
147+
}
117148
}
118149

119150
startDaemonBackground({

0 commit comments

Comments
 (0)