@seed-ship/mcp-ui-solid 6.6.1 → 6.8.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/adapters/index.d.ts +2 -1
  3. package/dist/adapters/index.d.ts.map +1 -1
  4. package/dist/adapters/macro-run.cjs +226 -0
  5. package/dist/adapters/macro-run.cjs.map +1 -0
  6. package/dist/adapters/macro-run.d.ts +65 -0
  7. package/dist/adapters/macro-run.d.ts.map +1 -0
  8. package/dist/adapters/macro-run.js +226 -0
  9. package/dist/adapters/macro-run.js.map +1 -0
  10. package/dist/adapters.cjs +3 -0
  11. package/dist/adapters.cjs.map +1 -1
  12. package/dist/adapters.d.cts +2 -1
  13. package/dist/adapters.d.ts +2 -1
  14. package/dist/adapters.js +4 -1
  15. package/dist/adapters.js.map +1 -1
  16. package/dist/mcp-ui-spec/dist/schemas.cjs +250 -1
  17. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  18. package/dist/mcp-ui-spec/dist/schemas.js +251 -2
  19. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  20. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs +2 -0
  21. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.cjs.map +1 -1
  22. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js +2 -0
  23. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js.map +1 -1
  24. package/dist/services/validation.cjs +11 -2
  25. package/dist/services/validation.cjs.map +1 -1
  26. package/dist/services/validation.d.ts.map +1 -1
  27. package/dist/services/validation.js +11 -2
  28. package/dist/services/validation.js.map +1 -1
  29. package/dist/types/index.d.ts +1 -1
  30. package/dist/types.d.cts +1 -1
  31. package/dist/types.d.ts +1 -1
  32. package/package.json +2 -2
  33. package/src/adapters/index.ts +4 -5
  34. package/src/adapters/macro-run.test.ts +293 -0
  35. package/src/adapters/macro-run.ts +362 -0
  36. package/src/services/validation.test.ts +79 -1
  37. package/src/services/validation.ts +10 -1
  38. package/src/types/index.ts +1 -1
  39. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,293 @@
1
+ /**
2
+ * MacroRun adapter tests (v6.7.0 — MacroRun Phase 2).
3
+ *
4
+ * Covers `macroRunToScratchpadState` and `macroInterrogationToChatPromptConfig`
5
+ * as pure functions: status mappings, section production, the stepper /
6
+ * split_stepper branch, embedded vs standalone interrogations, and the
7
+ * results-absent path.
8
+ */
9
+
10
+ import { describe, it, expect } from 'vitest';
11
+ import type { MacroRunV1, MacroInterrogationV1, MacroStepV1 } from '@seed-ship/mcp-ui-spec';
12
+ import { macroRunToScratchpadState, macroInterrogationToChatPromptConfig } from './macro-run';
13
+ import type { ScratchpadSection } from '../types/chat-bus';
14
+
15
+ // ─── Fixture builders ────────────────────────────────────────
16
+
17
+ function run(overrides: Partial<MacroRunV1> = {}): MacroRunV1 {
18
+ return {
19
+ schemaVersion: 'macro-run/v1',
20
+ runId: 'run_1',
21
+ macroId: 'demo-macro',
22
+ macroName: 'Demo macro',
23
+ status: 'running',
24
+ steps: [],
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ const STEPS: MacroStepV1[] = [
30
+ { id: 's1', label: 'Collect', status: 'done', durationMs: 1200 },
31
+ { id: 's2', label: 'Summarize', status: 'active' },
32
+ { id: 's3', label: 'Draft', status: 'pending' },
33
+ ];
34
+
35
+ function interrogation(overrides: Partial<MacroInterrogationV1> = {}): MacroInterrogationV1 {
36
+ return {
37
+ schemaVersion: 'macro-interrogation/v1',
38
+ interrogationId: 'int_1',
39
+ runId: 'run_1',
40
+ kind: 'choice',
41
+ title: 'Pick a direction',
42
+ options: [
43
+ { value: 'a', label: 'Option A' },
44
+ { value: 'b', label: 'Option B' },
45
+ ],
46
+ ...overrides,
47
+ };
48
+ }
49
+
50
+ const sectionTypes = (sections: ScratchpadSection[]) => sections.map((s) => s.type);
51
+
52
+ // ─── macroRunToScratchpadState — status mapping ──────────────
53
+
54
+ describe('macroRunToScratchpadState — run status → scratchpad status', () => {
55
+ const cases: Array<[MacroRunV1['status'], string]> = [
56
+ ['pending', 'loading'],
57
+ ['running', 'processing'],
58
+ ['awaiting_input', 'waiting_human'],
59
+ ['completed', 'complete'],
60
+ ['failed', 'error'],
61
+ ['aborted', 'error'],
62
+ ];
63
+ for (const [runStatus, scratchpadStatus] of cases) {
64
+ it(`maps run '${runStatus}' → scratchpad '${scratchpadStatus}'`, () => {
65
+ expect(macroRunToScratchpadState(run({ status: runStatus })).status).toBe(scratchpadStatus);
66
+ });
67
+ }
68
+ });
69
+
70
+ // ─── macroRunToScratchpadState — sections ────────────────────
71
+
72
+ describe('macroRunToScratchpadState — sections', () => {
73
+ it('running run with steps → agent_card + stepper', () => {
74
+ const state = macroRunToScratchpadState(run({ status: 'running', steps: STEPS }));
75
+ expect(state.status).toBe('processing');
76
+ expect(sectionTypes(state.sections)).toContain('agent_card');
77
+ expect(sectionTypes(state.sections)).toContain('stepper');
78
+ expect(sectionTypes(state.sections)).not.toContain('split_stepper');
79
+ });
80
+
81
+ it('maps step `failed` → stepper `error`', () => {
82
+ const state = macroRunToScratchpadState(
83
+ run({ status: 'failed', steps: [{ id: 's1', label: 'X', status: 'failed' }] })
84
+ );
85
+ const stepper = state.sections.find((s) => s.type === 'stepper');
86
+ const content = stepper?.content as { steps: Array<{ status: string }> };
87
+ expect(content.steps[0].status).toBe('error');
88
+ });
89
+
90
+ it('carries the agent card from run.agent when present', () => {
91
+ const state = macroRunToScratchpadState(
92
+ run({
93
+ status: 'running',
94
+ steps: STEPS,
95
+ agent: { id: 'researcher', name: 'Researcher', status: 'running' },
96
+ })
97
+ );
98
+ const card = state.sections.find((s) => s.type === 'agent_card');
99
+ expect((card?.content as { agentId: string }).agentId).toBe('researcher');
100
+ });
101
+
102
+ it('derives an agent card from the macro identity when run.agent is absent', () => {
103
+ const state = macroRunToScratchpadState(run({ status: 'running', steps: STEPS }));
104
+ const card = state.sections.find((s) => s.type === 'agent_card');
105
+ expect((card?.content as { agentId: string; name: string }).agentId).toBe('demo-macro');
106
+ expect((card?.content as { name: string }).name).toBe('Demo macro');
107
+ });
108
+
109
+ it('awaiting_input + pendingInterrogation → waiting_human + prompt section (choice)', () => {
110
+ const state = macroRunToScratchpadState(
111
+ run({
112
+ status: 'awaiting_input',
113
+ steps: STEPS,
114
+ pendingInterrogation: interrogation(),
115
+ })
116
+ );
117
+ expect(state.status).toBe('waiting_human');
118
+ const prompt = state.sections.find((s) => s.type === 'prompt');
119
+ expect(prompt).toBeDefined();
120
+ expect((prompt?.content as { type: string }).type).toBe('choice');
121
+ });
122
+
123
+ it('completed + results UI → complete with one section per result, mapped by type', () => {
124
+ const state = macroRunToScratchpadState(
125
+ run({
126
+ status: 'completed',
127
+ steps: STEPS,
128
+ results: [
129
+ { id: 'r1', type: 'chart', params: {} },
130
+ { id: 'r2', type: 'map', params: {} },
131
+ { id: 'r3', type: 'table', params: {} },
132
+ ],
133
+ })
134
+ );
135
+ expect(state.status).toBe('complete');
136
+ const types = sectionTypes(state.sections);
137
+ expect(types).toContain('chart');
138
+ expect(types).toContain('map');
139
+ expect(types).toContain('data_preview');
140
+ });
141
+
142
+ it('does not crash and emits no result section when results are absent', () => {
143
+ const state = macroRunToScratchpadState(run({ status: 'completed', steps: STEPS }));
144
+ expect(state.status).toBe('complete');
145
+ expect(state.sections.every((s) => !s.id.startsWith('macro-result-'))).toBe(true);
146
+ });
147
+
148
+ it('parallel steps present → split_stepper instead of stepper', () => {
149
+ const state = macroRunToScratchpadState(
150
+ run({
151
+ status: 'running',
152
+ steps: [
153
+ {
154
+ id: 'batch',
155
+ label: 'Parallel batch',
156
+ status: 'active',
157
+ parallel: [
158
+ { id: 'b1', label: 'Branch 1', status: 'done' },
159
+ { id: 'b2', label: 'Branch 2', status: 'active' },
160
+ ],
161
+ },
162
+ ],
163
+ })
164
+ );
165
+ const types = sectionTypes(state.sections);
166
+ expect(types).toContain('split_stepper');
167
+ expect(types).not.toContain('stepper');
168
+ const split = state.sections.find((s) => s.type === 'split_stepper');
169
+ expect((split?.content as { agents: unknown[] }).agents).toHaveLength(1);
170
+ });
171
+
172
+ it('failed run → status error with retryable error detail', () => {
173
+ const state = macroRunToScratchpadState(
174
+ run({
175
+ status: 'failed',
176
+ steps: STEPS,
177
+ error: { message: 'Tool timed out', code: 'TIMEOUT', retryable: true },
178
+ })
179
+ );
180
+ expect(state.status).toBe('error');
181
+ expect(state.error).toEqual({ message: 'Tool timed out', code: 'TIMEOUT', retryable: true });
182
+ });
183
+
184
+ it('aborted run → status error, never retryable', () => {
185
+ const state = macroRunToScratchpadState(
186
+ run({
187
+ status: 'aborted',
188
+ steps: STEPS,
189
+ error: { message: 'User aborted', retryable: true },
190
+ })
191
+ );
192
+ expect(state.status).toBe('error');
193
+ expect(state.error?.retryable).toBe(false);
194
+ });
195
+
196
+ it('produces a well-formed ScratchpadState (id, title, required fields)', () => {
197
+ const state = macroRunToScratchpadState(run({ status: 'running', steps: STEPS }));
198
+ expect(state.id).toBe('run_1');
199
+ expect(state.title).toBe('Demo macro');
200
+ expect(state.filters).toEqual({});
201
+ expect(state.agentMessages).toEqual([]);
202
+ });
203
+ });
204
+
205
+ // ─── macroInterrogationToChatPromptConfig ────────────────────
206
+
207
+ describe('macroInterrogationToChatPromptConfig', () => {
208
+ it('choice → ChatPromptConfig { type: "choice" } with options', () => {
209
+ const config = macroInterrogationToChatPromptConfig(interrogation({ kind: 'choice' }));
210
+ expect(config.type).toBe('choice');
211
+ expect((config.config as { options: unknown[] }).options).toHaveLength(2);
212
+ });
213
+
214
+ it('confirm → ChatPromptConfig { type: "confirm" } with labels', () => {
215
+ const config = macroInterrogationToChatPromptConfig(
216
+ interrogation({
217
+ kind: 'confirm',
218
+ message: 'Proceed?',
219
+ confirm: { confirmLabel: 'Yes', cancelLabel: 'No', variant: 'danger' },
220
+ })
221
+ );
222
+ expect(config.type).toBe('confirm');
223
+ expect(config.config).toMatchObject({
224
+ message: 'Proceed?',
225
+ confirmLabel: 'Yes',
226
+ variant: 'danger',
227
+ });
228
+ });
229
+
230
+ it('form → ChatPromptConfig { type: "form" } passing fields through', () => {
231
+ const fields = [{ name: 'tone', label: 'Tone', type: 'text' }];
232
+ const config = macroInterrogationToChatPromptConfig(interrogation({ kind: 'form', fields }));
233
+ expect(config.type).toBe('form');
234
+ expect((config.config as { fields: unknown[] }).fields).toEqual(fields);
235
+ });
236
+
237
+ it('elicitation → routed through elicitationToPromptConfig (single boolean → confirm)', () => {
238
+ const config = macroInterrogationToChatPromptConfig(
239
+ interrogation({
240
+ kind: 'elicitation',
241
+ message: 'Confirm export?',
242
+ elicitationSchema: {
243
+ type: 'object',
244
+ properties: { confirmed: { type: 'boolean', description: 'Export now' } },
245
+ },
246
+ })
247
+ );
248
+ // Single-boolean schema → elicitationToPromptConfig produces a confirm.
249
+ expect(config.type).toBe('confirm');
250
+ });
251
+
252
+ it('elicitation → form for a multi-property schema', () => {
253
+ const config = macroInterrogationToChatPromptConfig(
254
+ interrogation({
255
+ kind: 'elicitation',
256
+ message: 'Report options',
257
+ elicitationSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ tone: { type: 'string' },
261
+ maxPages: { type: 'integer' },
262
+ },
263
+ required: ['tone'],
264
+ },
265
+ })
266
+ );
267
+ expect(config.type).toBe('form');
268
+ });
269
+
270
+ it('elicitation with no usable schema → degrades to confirm, never throws', () => {
271
+ const config = macroInterrogationToChatPromptConfig(
272
+ interrogation({ kind: 'elicitation', elicitationSchema: undefined })
273
+ );
274
+ expect(config.type).toBe('confirm');
275
+ });
276
+
277
+ it('choice with no options → empty options array, does not crash', () => {
278
+ const config = macroInterrogationToChatPromptConfig(
279
+ interrogation({ kind: 'choice', options: undefined })
280
+ );
281
+ expect(config.type).toBe('choice');
282
+ expect((config.config as { options: unknown[] }).options).toEqual([]);
283
+ });
284
+
285
+ it('is usable standalone and produces the same prompt as the embedded path', () => {
286
+ const q = interrogation();
287
+ const standalone = macroInterrogationToChatPromptConfig(q);
288
+ const embedded = macroRunToScratchpadState(
289
+ run({ status: 'awaiting_input', pendingInterrogation: q })
290
+ ).sections.find((s) => s.type === 'prompt')?.content;
291
+ expect(embedded).toEqual(standalone);
292
+ });
293
+ });
@@ -0,0 +1,362 @@
1
+ /**
2
+ * MacroRun adapters — `MacroRunV1` / `MacroInterrogationV1` → MCP-UI render
3
+ * primitives.
4
+ *
5
+ * @since v6.7.0 (MacroRun Phase 2 — contract consolidated in deposium_MCPs
6
+ * `docs/2026/briefs/2026-05-22-macro-run-runtime-contract-consolidation.md`)
7
+ *
8
+ * ## Opt-in, pure
9
+ *
10
+ * Published under the dedicated subpath `@seed-ship/mcp-ui-solid/adapters` —
11
+ * never imported by the core renderer path. Every function here is a **pure
12
+ * function**: same input → same output, no `fetch`, no SSE listener, no
13
+ * persistence, no global state, no clock, no randomness.
14
+ *
15
+ * ## Scope boundary
16
+ *
17
+ * These adapters only translate the agnostic `MacroRunV1` contract (defined
18
+ * in `@seed-ship/mcp-ui-spec`) into existing MCP-UI primitives — a
19
+ * `ScratchpadState` and a `ChatPromptConfig`. They do NOT:
20
+ *
21
+ * - emit or consume any SSE event (a `macro_run_snapshot` producer is a
22
+ * separate, later goal on the producing runtime repo);
23
+ * - perform any fetch, persistence or resume;
24
+ * - know anything about a specific runtime, host, corpus or domain;
25
+ * - touch the `action:'submit'` executors, nor mix MacroRun with the
26
+ * existing tool-call action path.
27
+ *
28
+ * The host owns all of the above — it feeds a `MacroRunV1` snapshot in and
29
+ * decides where to render the result.
30
+ */
31
+
32
+ import type { MacroRunV1, MacroStepV1, MacroInterrogationV1 } from '@seed-ship/mcp-ui-spec';
33
+ import type {
34
+ ScratchpadState,
35
+ ScratchpadSection,
36
+ ChatPromptConfig,
37
+ ChoiceOption,
38
+ ConfirmPromptConfig,
39
+ AgentCardContent,
40
+ SplitStepperContent,
41
+ ElicitationRequestedSchema,
42
+ FormPromptConfig,
43
+ } from '../types/chat-bus';
44
+ import { elicitationToPromptConfig } from '../services/chat-bus';
45
+
46
+ // ─── Status mappings ─────────────────────────────────────────
47
+
48
+ /**
49
+ * Run status → top-level `ScratchpadState` status. `aborted` and `failed`
50
+ * both surface as `error` (an aborted run is additionally non-retryable —
51
+ * see `buildError`).
52
+ */
53
+ const RUN_STATUS_TO_SCRATCHPAD: Record<MacroRunV1['status'], ScratchpadState['status']> = {
54
+ pending: 'loading',
55
+ running: 'processing',
56
+ awaiting_input: 'waiting_human',
57
+ completed: 'complete',
58
+ failed: 'error',
59
+ aborted: 'error',
60
+ };
61
+
62
+ /** Run status → `AgentCardContent` status, used when the run carries no agent. */
63
+ function runStatusToAgentStatus(status: MacroRunV1['status']): AgentCardContent['status'] {
64
+ switch (status) {
65
+ case 'pending':
66
+ return 'idle';
67
+ case 'running':
68
+ return 'running';
69
+ case 'awaiting_input':
70
+ return 'waiting';
71
+ case 'completed':
72
+ return 'done';
73
+ case 'failed':
74
+ case 'aborted':
75
+ return 'error';
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Step status → stepper status. The MCP-UI stepper primitive renders
81
+ * `failed` as `error`; every other value passes through unchanged.
82
+ */
83
+ function stepToStepperStatus(
84
+ status: MacroStepV1['status']
85
+ ): 'pending' | 'active' | 'done' | 'skipped' | 'error' {
86
+ return status === 'failed' ? 'error' : status;
87
+ }
88
+
89
+ // ─── Section builders ────────────────────────────────────────
90
+
91
+ interface StepperSectionContent {
92
+ steps: Array<{
93
+ id: string;
94
+ label: string;
95
+ status: 'pending' | 'active' | 'done' | 'skipped' | 'error';
96
+ summary?: string;
97
+ duration_ms?: number;
98
+ }>;
99
+ orientation: 'horizontal' | 'vertical';
100
+ }
101
+
102
+ function buildAgentCard(run: MacroRunV1): AgentCardContent {
103
+ const agent = run.agent;
104
+ const card: AgentCardContent = {
105
+ agentId: agent?.id ?? run.macroId,
106
+ name: agent?.name ?? run.macroName ?? run.macroId,
107
+ status: agent?.status ?? runStatusToAgentStatus(run.status),
108
+ };
109
+ if (agent?.avatar) card.avatar = agent.avatar;
110
+ if (agent?.capabilities) card.capabilities = agent.capabilities;
111
+ if (agent?.currentStep) card.currentStep = agent.currentStep;
112
+ return card;
113
+ }
114
+
115
+ function buildStepperContent(steps: MacroStepV1[]): StepperSectionContent {
116
+ return {
117
+ orientation: 'horizontal',
118
+ steps: steps.map((step) => {
119
+ const out: StepperSectionContent['steps'][number] = {
120
+ id: step.id,
121
+ label: step.label,
122
+ status: stepToStepperStatus(step.status),
123
+ };
124
+ if (step.summary) out.summary = step.summary;
125
+ if (typeof step.durationMs === 'number') out.duration_ms = step.durationMs;
126
+ return out;
127
+ }),
128
+ };
129
+ }
130
+
131
+ /** Collapse a set of sub-step statuses into a single parallel-lane status. */
132
+ function laneStatus(subSteps: MacroStepV1[]): 'done' | 'active' | 'pending' | 'error' {
133
+ if (subSteps.some((s) => s.status === 'failed')) return 'error';
134
+ if (subSteps.some((s) => s.status === 'active')) return 'active';
135
+ if (subSteps.length > 0 && subSteps.every((s) => s.status === 'done' || s.status === 'skipped'))
136
+ return 'done';
137
+ return 'pending';
138
+ }
139
+
140
+ /**
141
+ * Build a `split_stepper` content from steps that carry `parallel` branches.
142
+ * Each top-level step becomes a lane: a step with `parallel` sub-steps shows
143
+ * those sub-steps; a step without falls back to a single-step lane.
144
+ */
145
+ function buildSplitStepperContent(steps: MacroStepV1[]): SplitStepperContent {
146
+ return {
147
+ agents: steps.map((step) => {
148
+ const subSteps = step.parallel && step.parallel.length > 0 ? step.parallel : [step];
149
+ return {
150
+ id: step.id,
151
+ name: step.label,
152
+ status: laneStatus(subSteps),
153
+ steps: subSteps.map((sub) => ({
154
+ id: sub.id,
155
+ label: sub.label,
156
+ status: stepToStepperStatus(sub.status),
157
+ })),
158
+ };
159
+ }),
160
+ };
161
+ }
162
+
163
+ /** Component `type` → the closest `ScratchpadSection` type for a result. */
164
+ function resultSectionType(componentType: unknown): ScratchpadSection['type'] {
165
+ switch (componentType) {
166
+ case 'chart':
167
+ return 'chart';
168
+ case 'map':
169
+ return 'map';
170
+ case 'table':
171
+ return 'data_preview';
172
+ default:
173
+ return 'data';
174
+ }
175
+ }
176
+
177
+ function buildError(run: MacroRunV1): ScratchpadState['error'] | undefined {
178
+ if (run.status !== 'failed' && run.status !== 'aborted') return undefined;
179
+ const aborted = run.status === 'aborted';
180
+ if (run.error) {
181
+ const err: NonNullable<ScratchpadState['error']> = {
182
+ message: run.error.message,
183
+ // An aborted run is never retryable, regardless of the producer flag.
184
+ retryable: aborted ? false : (run.error.retryable ?? false),
185
+ };
186
+ if (run.error.code) err.code = run.error.code;
187
+ return err;
188
+ }
189
+ return {
190
+ message: aborted ? 'Macro run aborted.' : 'Macro run failed.',
191
+ retryable: false,
192
+ };
193
+ }
194
+
195
+ // ─── macroRunToScratchpadState ───────────────────────────────
196
+
197
+ /**
198
+ * Convert a `MacroRunV1` snapshot into a `ScratchpadState`.
199
+ *
200
+ * Sections produced, in order:
201
+ * 1. `agent_card` — always (derived from `run.agent`, or from the macro
202
+ * identity when the run carries no agent, e.g. a non-interactive macro).
203
+ * 2. `stepper` — when the run has steps and none carry `parallel` branches.
204
+ * `split_stepper` instead when any step carries `parallel` (future model).
205
+ * 3. `prompt` — when `run.pendingInterrogation` is set; its content is the
206
+ * `ChatPromptConfig` produced by {@link macroInterrogationToChatPromptConfig}.
207
+ * 4. one section per `run.results` entry (`chart` / `map` / `data_preview` /
208
+ * `data`, by component type). Optional — the adapter works without results.
209
+ *
210
+ * Pure: no fetch, no SSE, no persistence. The host owns all wiring.
211
+ */
212
+ export function macroRunToScratchpadState(run: MacroRunV1): ScratchpadState {
213
+ const sections: ScratchpadSection[] = [];
214
+
215
+ // 1. Agent card — always present.
216
+ sections.push({
217
+ id: 'macro-agent',
218
+ title: 'Agent',
219
+ type: 'agent_card',
220
+ content: buildAgentCard(run),
221
+ editable: false,
222
+ source: 'agent',
223
+ });
224
+
225
+ // 2. Stepper / split_stepper — when there are steps.
226
+ if (run.steps.length > 0) {
227
+ const hasParallel = run.steps.some((s) => Array.isArray(s.parallel) && s.parallel.length > 0);
228
+ sections.push(
229
+ hasParallel
230
+ ? {
231
+ id: 'macro-split-stepper',
232
+ title: 'Progress',
233
+ type: 'split_stepper',
234
+ content: buildSplitStepperContent(run.steps),
235
+ editable: false,
236
+ source: 'agent',
237
+ }
238
+ : {
239
+ id: 'macro-stepper',
240
+ title: 'Progress',
241
+ type: 'stepper',
242
+ content: buildStepperContent(run.steps),
243
+ editable: false,
244
+ source: 'agent',
245
+ }
246
+ );
247
+ }
248
+
249
+ // 3. Pending interrogation → a `prompt` section.
250
+ if (run.pendingInterrogation) {
251
+ sections.push({
252
+ id: 'macro-prompt',
253
+ title: run.pendingInterrogation.title,
254
+ type: 'prompt',
255
+ content: macroInterrogationToChatPromptConfig(run.pendingInterrogation),
256
+ editable: false,
257
+ source: 'agent',
258
+ });
259
+ }
260
+
261
+ // 4. Result components → one section each (results are optional). A result
262
+ // is a `UIComponent`-shaped object — passthrough, read loosely (the spec
263
+ // validates the run envelope, the renderer validates each component).
264
+ const results = run.results ?? [];
265
+ results.forEach((component, index) => {
266
+ sections.push({
267
+ id: `macro-result-${String(component?.id ?? index)}`,
268
+ title: 'Result',
269
+ type: resultSectionType(component?.type),
270
+ content: component,
271
+ editable: false,
272
+ source: 'agent',
273
+ });
274
+ });
275
+
276
+ const state: ScratchpadState = {
277
+ id: run.runId,
278
+ title: run.title ?? run.macroName ?? run.macroId,
279
+ sections,
280
+ filters: {},
281
+ agentMessages: [],
282
+ status: RUN_STATUS_TO_SCRATCHPAD[run.status],
283
+ };
284
+
285
+ const error = buildError(run);
286
+ if (error) state.error = error;
287
+
288
+ return state;
289
+ }
290
+
291
+ // ─── macroInterrogationToChatPromptConfig ────────────────────
292
+
293
+ function isElicitationSchema(value: unknown): value is ElicitationRequestedSchema {
294
+ if (typeof value !== 'object' || value === null) return false;
295
+ const v = value as { type?: unknown; properties?: unknown };
296
+ return v.type === 'object' && typeof v.properties === 'object' && v.properties !== null;
297
+ }
298
+
299
+ /**
300
+ * Convert a `MacroInterrogationV1` into a `ChatPromptConfig`.
301
+ *
302
+ * - `choice` → `{ type: 'choice' }` (vertical layout).
303
+ * - `confirm` → `{ type: 'confirm' }`.
304
+ * - `form` → `{ type: 'form' }` (the opaque `fields` are passed through).
305
+ * - `elicitation` → routed through the existing `elicitationToPromptConfig()`
306
+ * helper. If the interrogation carries no usable elicitation JSON Schema,
307
+ * it degrades to a `confirm` prompt rather than throwing.
308
+ *
309
+ * Always returns a `ChatPromptConfig` — never an `ElicitationEvent` — so the
310
+ * host has a single entry point. Usable standalone or via
311
+ * {@link macroRunToScratchpadState} (which calls it for an embedded
312
+ * `pendingInterrogation`).
313
+ */
314
+ export function macroInterrogationToChatPromptConfig(q: MacroInterrogationV1): ChatPromptConfig {
315
+ switch (q.kind) {
316
+ case 'choice': {
317
+ const options: ChoiceOption[] = (q.options ?? []).map((o) => {
318
+ const opt: ChoiceOption = { value: o.value, label: o.label };
319
+ if (o.icon) opt.icon = o.icon;
320
+ if (o.description) opt.description = o.description;
321
+ if (o.metadata) opt.metadata = o.metadata;
322
+ return opt;
323
+ });
324
+ return {
325
+ type: 'choice',
326
+ title: q.title,
327
+ config: { options, layout: 'vertical' },
328
+ };
329
+ }
330
+
331
+ case 'confirm': {
332
+ const config: ConfirmPromptConfig = {};
333
+ if (q.message) config.message = q.message;
334
+ if (q.confirm?.confirmLabel) config.confirmLabel = q.confirm.confirmLabel;
335
+ if (q.confirm?.cancelLabel) config.cancelLabel = q.confirm.cancelLabel;
336
+ if (q.confirm?.variant) config.variant = q.confirm.variant;
337
+ return { type: 'confirm', title: q.title, config };
338
+ }
339
+
340
+ case 'form':
341
+ return {
342
+ type: 'form',
343
+ title: q.title,
344
+ config: { fields: (q.fields ?? []) as FormPromptConfig['fields'] },
345
+ };
346
+
347
+ case 'elicitation': {
348
+ if (isElicitationSchema(q.elicitationSchema)) {
349
+ return elicitationToPromptConfig({
350
+ message: q.message ?? q.title,
351
+ requestedSchema: q.elicitationSchema,
352
+ });
353
+ }
354
+ // No usable schema — degrade gracefully instead of crashing.
355
+ return {
356
+ type: 'confirm',
357
+ title: q.title,
358
+ config: { message: q.message ?? 'Please confirm to continue.' },
359
+ };
360
+ }
361
+ }
362
+ }