@xdelivered/emberflow 0.2.0 → 0.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/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +185 -14
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/package.json +27 -6
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +189 -13
- package/server/nodeRun.test.ts +189 -0
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +383 -312
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-CAIDjNhv.css +1 -0
- package/studio-dist/assets/index-CGwEx82J.js +65 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-model-process/SKILL.md +82 -9
- package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { useEffect,
|
|
2
|
-
import { ArrowUpIcon,
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { ArrowUpIcon, SparklesIcon, XIcon } from 'lucide-react';
|
|
3
3
|
import { Badge } from '@/components/ui/badge';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
5
|
import { cn } from '@/lib/utils';
|
|
6
6
|
import { useBuilderStore } from '../store/builderStore';
|
|
7
|
-
import
|
|
8
|
-
import { renderAgentMarkdown } from './agentMarkdown';
|
|
7
|
+
import { AgentStream } from './AgentStream';
|
|
9
8
|
|
|
10
9
|
|
|
11
10
|
const statusDot: Record<string, string> = {
|
|
@@ -14,167 +13,13 @@ const statusDot: Record<string, string> = {
|
|
|
14
13
|
error: 'bg-destructive',
|
|
15
14
|
};
|
|
16
15
|
|
|
17
|
-
/** A command's status → a small glyph colour. */
|
|
18
|
-
const cmdDot: Record<string, string> = {
|
|
19
|
-
in_progress: 'bg-highlight animate-pulse',
|
|
20
|
-
completed: 'bg-success/70',
|
|
21
|
-
failed: 'bg-destructive',
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/** The `emberflow` CLI operation commands the agent uses (run/inspect/delete). */
|
|
25
|
-
const EMBERFLOW_OPS = [
|
|
26
|
-
'run', 'get-workflow', 'list-workflows', 'list-nodes', 'node-schema', 'list-environments',
|
|
27
|
-
'create', 'delete', 'rename', 'validate', 'publish', 'save',
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
/** If a shell command invokes the emberflow CLI bin with an operation command,
|
|
31
|
-
* pull out the operation + its target id so it can render as a distinct step. */
|
|
32
|
-
function parseEmberflowOp(command?: string): { op: string; target?: string } | null {
|
|
33
|
-
if (!command || !/emberflow\.mjs\b/.test(command)) return null;
|
|
34
|
-
const after = command.split(/emberflow\.mjs\b/)[1] ?? '';
|
|
35
|
-
const toks = after.trim().split(/\s+/).filter(Boolean);
|
|
36
|
-
const op = toks.find((t) => EMBERFLOW_OPS.includes(t));
|
|
37
|
-
if (!op) return null;
|
|
38
|
-
const target = toks.slice(toks.indexOf(op) + 1).find((t) => !t.startsWith('-'));
|
|
39
|
-
return { op, target };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A run of consecutive shell commands, collapsed to a single quiet line
|
|
44
|
-
* ("Ran N commands") that expands to the individual commands + their output.
|
|
45
|
-
* A failed command in the group forces it open and tints it. This is the big
|
|
46
|
-
* de-noiser: a single edit can run 30 inspection commands — nobody wants 30
|
|
47
|
-
* rows, they want the reasoning with the mechanics tucked underneath.
|
|
48
|
-
*/
|
|
49
|
-
function CommandGroup({ commands }: { commands: AgentEvent[] }) {
|
|
50
|
-
const anyFailed = commands.some((c) => c.commandStatus === 'failed');
|
|
51
|
-
const [open, setOpen] = useState(anyFailed);
|
|
52
|
-
const running = commands.some((c) => c.commandStatus === 'in_progress');
|
|
53
|
-
const label = commands.length === 1 ? '1 command' : `${commands.length} commands`;
|
|
54
|
-
return (
|
|
55
|
-
<div className="my-0.5">
|
|
56
|
-
<button
|
|
57
|
-
type="button"
|
|
58
|
-
onClick={() => setOpen((o) => !o)}
|
|
59
|
-
className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
|
|
60
|
-
>
|
|
61
|
-
<ChevronRightIcon
|
|
62
|
-
className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')}
|
|
63
|
-
/>
|
|
64
|
-
<span
|
|
65
|
-
className={cn(
|
|
66
|
-
'size-1.5 shrink-0 rounded-full',
|
|
67
|
-
running ? cmdDot.in_progress : anyFailed ? cmdDot.failed : cmdDot.completed,
|
|
68
|
-
)}
|
|
69
|
-
/>
|
|
70
|
-
<span className={cn('text-[11px]', anyFailed ? 'text-destructive/90' : 'text-muted-foreground/70')}>
|
|
71
|
-
{running ? 'Running' : 'Ran'} {label}
|
|
72
|
-
</span>
|
|
73
|
-
</button>
|
|
74
|
-
{open && (
|
|
75
|
-
<div className="ml-[18px] mt-0.5 space-y-0.5 border-l border-border/50 pl-2">
|
|
76
|
-
{commands.map((c, k) => (
|
|
77
|
-
<div key={k}>
|
|
78
|
-
<div className="flex items-center gap-1.5">
|
|
79
|
-
<span className={cn('size-1 shrink-0 rounded-full', cmdDot[c.commandStatus ?? 'in_progress'])} />
|
|
80
|
-
<span className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground/80">{c.command}</span>
|
|
81
|
-
{c.commandStatus === 'failed' && c.exitCode != null && (
|
|
82
|
-
<span className="ml-auto shrink-0 font-mono text-[9px] text-destructive">exit {c.exitCode}</span>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
{(c.commandStatus === 'failed' || open) && c.output && c.output.trim() && (
|
|
86
|
-
<pre className="mt-0.5 ml-3 max-h-32 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/70">
|
|
87
|
-
{c.output}
|
|
88
|
-
</pre>
|
|
89
|
-
)}
|
|
90
|
-
</div>
|
|
91
|
-
))}
|
|
92
|
-
</div>
|
|
93
|
-
)}
|
|
94
|
-
</div>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** An MCP tool call the agent made against the runner (run/list/delete/etc.) —
|
|
99
|
-
* visually distinct from a shell command (MCP-blue, a "MCP" chip), expandable
|
|
100
|
-
* to its result. This is the operational channel: files for edits, MCP for
|
|
101
|
-
* actions like running the operation or deleting it. */
|
|
102
|
-
function McpCall({ e }: { e: AgentEvent }) {
|
|
103
|
-
const [open, setOpen] = useState(false);
|
|
104
|
-
const status = e.mcpStatus ?? 'completed';
|
|
105
|
-
const failed = status === 'failed';
|
|
106
|
-
const running = status === 'in_progress';
|
|
107
|
-
return (
|
|
108
|
-
<div className="my-0.5">
|
|
109
|
-
<button
|
|
110
|
-
type="button"
|
|
111
|
-
onClick={() => setOpen((o) => !o)}
|
|
112
|
-
className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
|
|
113
|
-
>
|
|
114
|
-
<ChevronRightIcon
|
|
115
|
-
className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')}
|
|
116
|
-
/>
|
|
117
|
-
<span className={cn('size-1.5 shrink-0 rounded-full', running ? 'animate-pulse bg-highlight' : failed ? 'bg-destructive' : 'bg-[#8fb8d8]')} />
|
|
118
|
-
<ZapIcon className="size-3 shrink-0 text-[#8fb8d8]" />
|
|
119
|
-
<span className="shrink-0 rounded border border-[#8fb8d8]/40 px-1 font-mono text-[8.5px] uppercase tracking-wide text-[#8fb8d8]">
|
|
120
|
-
MCP
|
|
121
|
-
</span>
|
|
122
|
-
<span className={cn('min-w-0 truncate font-mono text-[11px]', failed ? 'text-destructive/90' : 'text-foreground/85')}>
|
|
123
|
-
{e.mcpServer}·{e.mcpTool}
|
|
124
|
-
</span>
|
|
125
|
-
<span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/60">{running ? '…' : status}</span>
|
|
126
|
-
</button>
|
|
127
|
-
{open && e.output && e.output.trim() && (
|
|
128
|
-
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/75">
|
|
129
|
-
{e.output}
|
|
130
|
-
</pre>
|
|
131
|
-
)}
|
|
132
|
-
</div>
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** An `emberflow` CLI operation the agent ran (run/inspect/delete against the
|
|
137
|
-
* live runner) — surfaced as a distinct operation step, expandable to its
|
|
138
|
-
* output. This is the agent's operational channel: files for edits, CLI ops
|
|
139
|
-
* for running/inspecting/removing. */
|
|
140
|
-
function OpCall({ op, target, e }: { op: string; target?: string; e: AgentEvent }) {
|
|
141
|
-
const [open, setOpen] = useState(false);
|
|
142
|
-
const status = e.commandStatus ?? 'completed';
|
|
143
|
-
const failed = status === 'failed' || (e.exitCode != null && e.exitCode !== 0);
|
|
144
|
-
const running = status === 'in_progress';
|
|
145
|
-
return (
|
|
146
|
-
<div className="my-0.5">
|
|
147
|
-
<button
|
|
148
|
-
type="button"
|
|
149
|
-
onClick={() => setOpen((o) => !o)}
|
|
150
|
-
className="group flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left transition-colors hover:bg-secondary/50"
|
|
151
|
-
>
|
|
152
|
-
<ChevronRightIcon className={cn('size-3 shrink-0 text-muted-foreground/50 transition-transform', open && 'rotate-90')} />
|
|
153
|
-
<span className={cn('size-1.5 shrink-0 rounded-full', running ? 'animate-pulse bg-highlight' : failed ? 'bg-destructive' : 'bg-[#8fb8d8]')} />
|
|
154
|
-
<ZapIcon className="size-3 shrink-0 text-[#8fb8d8]" />
|
|
155
|
-
<span className="shrink-0 rounded border border-[#8fb8d8]/40 px-1 font-mono text-[8.5px] uppercase tracking-wide text-[#8fb8d8]">
|
|
156
|
-
OP
|
|
157
|
-
</span>
|
|
158
|
-
<span className={cn('min-w-0 truncate font-mono text-[11px]', failed ? 'text-destructive/90' : 'text-foreground/85')}>
|
|
159
|
-
{op}{target ? ` · ${target}` : ''}
|
|
160
|
-
</span>
|
|
161
|
-
<span className="ml-auto shrink-0 text-[9.5px] text-muted-foreground/60">{running ? '…' : failed ? 'failed' : 'ok'}</span>
|
|
162
|
-
</button>
|
|
163
|
-
{open && e.output && e.output.trim() && (
|
|
164
|
-
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded border border-border/50 bg-background/40 p-1.5 font-mono text-[10px] leading-relaxed whitespace-pre-wrap text-muted-foreground/75">
|
|
165
|
-
{e.output}
|
|
166
|
-
</pre>
|
|
167
|
-
)}
|
|
168
|
-
</div>
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
16
|
/**
|
|
173
17
|
* Docked panel that streams a studio-triggered coding-agent run. Presents it
|
|
174
18
|
* the way Codex/Claude present their own work: the agent's reasoning is the
|
|
175
19
|
* content (comfortable prose), commands collapse to quiet one-liners you can
|
|
176
|
-
* expand, and background diagnostics (MCP/hook noise) are tucked into a count
|
|
177
|
-
* Then the diff with a Revert button once
|
|
20
|
+
* expand, and background diagnostics (MCP/hook noise) are tucked into a count
|
|
21
|
+
* (all via the shared <AgentStream>). Then the diff with a Revert button once
|
|
22
|
+
* the run finishes.
|
|
178
23
|
*/
|
|
179
24
|
export function AgentConsole({ onDismiss }: { onDismiss: () => void }) {
|
|
180
25
|
const agentRun = useBuilderStore((s) => s.agentRun);
|
|
@@ -212,76 +57,6 @@ export function AgentConsole({ onDismiss }: { onDismiss: () => void }) {
|
|
|
212
57
|
setInstruction('');
|
|
213
58
|
};
|
|
214
59
|
|
|
215
|
-
// Partition the raw stream into what's worth showing vs. noise. The agent's
|
|
216
|
-
// ⚠ diagnostics (an MCP server failing to auth, a hook firing) are real but
|
|
217
|
-
// not what the user is watching for — collapse them to a count.
|
|
218
|
-
const { blocks, noiseCount } = useMemo(() => {
|
|
219
|
-
// Fold the raw stream into display blocks: reasoning prose and error lines
|
|
220
|
-
// stand alone; consecutive commands collapse into one CommandGroup. Started/
|
|
221
|
-
// done are structural; ⚠ diagnostics are noise → a count.
|
|
222
|
-
type Block =
|
|
223
|
-
| { kind: 'message' | 'error' | 'approval' | 'mcp'; e: AgentEvent }
|
|
224
|
-
| { kind: 'op'; e: AgentEvent; op: string; target?: string }
|
|
225
|
-
| { kind: 'commands'; commands: AgentEvent[] };
|
|
226
|
-
const blocks: Block[] = [];
|
|
227
|
-
let noiseCount = 0;
|
|
228
|
-
let group: AgentEvent[] | null = null;
|
|
229
|
-
const flush = (): void => {
|
|
230
|
-
if (group && group.length) blocks.push({ kind: 'commands', commands: group });
|
|
231
|
-
group = null;
|
|
232
|
-
};
|
|
233
|
-
for (const e of agentRun?.events ?? []) {
|
|
234
|
-
// An `emberflow` CLI operation (run/inspect/delete) — surface it as a
|
|
235
|
-
// distinct "operation" step, not a generic shell command.
|
|
236
|
-
const opInfo = e.type === 'command' ? parseEmberflowOp(e.command) : null;
|
|
237
|
-
if (e.type === 'command' && opInfo) {
|
|
238
|
-
flush();
|
|
239
|
-
const last = blocks[blocks.length - 1];
|
|
240
|
-
if (last && last.kind === 'op' && last.e.command === e.command && last.e.commandStatus === 'in_progress') {
|
|
241
|
-
last.e = e;
|
|
242
|
-
} else {
|
|
243
|
-
blocks.push({ kind: 'op', e, op: opInfo.op, target: opInfo.target });
|
|
244
|
-
}
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (e.type === 'command') {
|
|
248
|
-
group ??= [];
|
|
249
|
-
// Each command streams as a start (in_progress) then a complete
|
|
250
|
-
// (completed/failed) event with the same text — collapse the pair into
|
|
251
|
-
// one entry carrying the final status + output, so counts are accurate.
|
|
252
|
-
const last = group[group.length - 1];
|
|
253
|
-
if (last && last.command === e.command && last.commandStatus === 'in_progress') {
|
|
254
|
-
group[group.length - 1] = e;
|
|
255
|
-
} else {
|
|
256
|
-
group.push(e);
|
|
257
|
-
}
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
flush();
|
|
261
|
-
if (e.type === 'mcp') {
|
|
262
|
-
// MCP calls stream in_progress → completed for the same tool; collapse
|
|
263
|
-
// the pair so the completed status/result replaces the pending one.
|
|
264
|
-
const last = blocks[blocks.length - 1];
|
|
265
|
-
if (last && last.kind === 'mcp' && last.e.mcpTool === e.mcpTool && last.e.mcpStatus === 'in_progress') {
|
|
266
|
-
last.e = e;
|
|
267
|
-
} else {
|
|
268
|
-
blocks.push({ kind: 'mcp', e });
|
|
269
|
-
}
|
|
270
|
-
continue;
|
|
271
|
-
}
|
|
272
|
-
if (e.type === 'started' || e.type === 'done') continue;
|
|
273
|
-
if (e.type === 'message' && e.text?.startsWith('⚠')) {
|
|
274
|
-
noiseCount += 1;
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
if (e.type === 'message') blocks.push({ kind: 'message', e });
|
|
278
|
-
else if (e.type === 'error') blocks.push({ kind: 'error', e });
|
|
279
|
-
else if (e.type === 'approval-request') blocks.push({ kind: 'approval', e });
|
|
280
|
-
}
|
|
281
|
-
flush();
|
|
282
|
-
return { blocks, noiseCount };
|
|
283
|
-
}, [agentRun?.events]);
|
|
284
|
-
|
|
285
60
|
const finished = agentRun != null && agentRun.status !== 'running';
|
|
286
61
|
|
|
287
62
|
return (
|
|
@@ -342,55 +117,7 @@ export function AgentConsole({ onDismiss }: { onDismiss: () => void }) {
|
|
|
342
117
|
</p>
|
|
343
118
|
</div>
|
|
344
119
|
)}
|
|
345
|
-
{agentRun &&
|
|
346
|
-
<div className="flex items-center gap-2 text-[12px] text-muted-foreground/70">
|
|
347
|
-
<span className="size-1.5 animate-pulse rounded-full bg-highlight" />
|
|
348
|
-
Thinking…
|
|
349
|
-
</div>
|
|
350
|
-
)}
|
|
351
|
-
{blocks.map((b, i) => {
|
|
352
|
-
if (b.kind === 'commands') return <CommandGroup key={i} commands={b.commands} />;
|
|
353
|
-
if (b.kind === 'op') return <OpCall key={i} op={b.op} target={b.target} e={b.e} />;
|
|
354
|
-
if (b.kind === 'mcp') return <McpCall key={i} e={b.e} />;
|
|
355
|
-
if (b.kind === 'message') {
|
|
356
|
-
// The reasoning: the star of the panel — comfortable, readable prose.
|
|
357
|
-
// Render the markdown subset (bold/code/lists/headings). The renderer
|
|
358
|
-
// owns block structure, so whitespace-pre-wrap stays OFF here.
|
|
359
|
-
return (
|
|
360
|
-
<div key={i} className="space-y-1.5">
|
|
361
|
-
{renderAgentMarkdown(b.e.text ?? '')}
|
|
362
|
-
</div>
|
|
363
|
-
);
|
|
364
|
-
}
|
|
365
|
-
if (b.kind === 'error') {
|
|
366
|
-
return (
|
|
367
|
-
<p key={i} className="text-[12px] leading-relaxed text-destructive">
|
|
368
|
-
{b.e.text ?? 'The agent run failed.'}
|
|
369
|
-
</p>
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
return (
|
|
373
|
-
<p key={i} className="text-[12px] text-yellow-500">
|
|
374
|
-
Approval requested{b.e.text ? `: ${b.e.text}` : ''}
|
|
375
|
-
</p>
|
|
376
|
-
);
|
|
377
|
-
})}
|
|
378
|
-
|
|
379
|
-
{/* Live working indicator once there's content but the run's still going. */}
|
|
380
|
-
{!finished && blocks.length > 0 && (
|
|
381
|
-
<div className="flex items-center gap-2 pt-1 text-[11px] text-muted-foreground/60">
|
|
382
|
-
<span className="size-1.5 animate-pulse rounded-full bg-highlight" />
|
|
383
|
-
Working…
|
|
384
|
-
</div>
|
|
385
|
-
)}
|
|
386
|
-
|
|
387
|
-
{/* Background diagnostics, tucked away — acknowledged, not shouting. */}
|
|
388
|
-
{noiseCount > 0 && (
|
|
389
|
-
<div className="flex items-center gap-1.5 pt-1 text-[10.5px] text-muted-foreground/45">
|
|
390
|
-
<TerminalIcon className="size-3" />
|
|
391
|
-
{noiseCount} background {noiseCount === 1 ? 'diagnostic' : 'diagnostics'} hidden
|
|
392
|
-
</div>
|
|
393
|
-
)}
|
|
120
|
+
{agentRun && <AgentStream events={agentRun.events} running={running} />}
|
|
394
121
|
</div>
|
|
395
122
|
|
|
396
123
|
{finished && agentRun && (agentRun.diff !== undefined || agentRun.status === 'error') && (
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
+
import { AgentStream, partitionAgentEvents } from './AgentStream';
|
|
4
|
+
import type { AgentEvent } from '../store/agentClient';
|
|
5
|
+
|
|
6
|
+
/** A representative stream: reasoning prose, a pair of shell commands (each
|
|
7
|
+
* streamed as in_progress→completed), an emberflow CLI op, an MCP call, and a
|
|
8
|
+
* ⚠ background diagnostic that should fold into a count. */
|
|
9
|
+
const FIXTURE: AgentEvent[] = [
|
|
10
|
+
{ type: 'started' },
|
|
11
|
+
{ type: 'message', text: 'Reading the **ground truth** first.' },
|
|
12
|
+
{ type: 'command', command: 'ls -la', commandStatus: 'in_progress' },
|
|
13
|
+
{ type: 'command', command: 'ls -la', commandStatus: 'completed', output: 'a\nb' },
|
|
14
|
+
{ type: 'command', command: 'cat package.json', commandStatus: 'in_progress' },
|
|
15
|
+
{ type: 'command', command: 'cat package.json', commandStatus: 'completed', output: '{}' },
|
|
16
|
+
{ type: 'message', text: '⚠ an MCP server failed to authenticate' },
|
|
17
|
+
{
|
|
18
|
+
type: 'command',
|
|
19
|
+
command: 'node /x/bin/emberflow.mjs list-workflows',
|
|
20
|
+
commandStatus: 'completed',
|
|
21
|
+
output: 'default/hello',
|
|
22
|
+
},
|
|
23
|
+
{ type: 'mcp', mcpServer: 'emberflow', mcpTool: 'run_operation', mcpStatus: 'completed', output: 'ok' },
|
|
24
|
+
{ type: 'message', text: 'All done.' },
|
|
25
|
+
{ type: 'done' },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
describe('partitionAgentEvents', () => {
|
|
29
|
+
it('folds the stream: prose blocks, one collapsed command group, an op step, an mcp step, noise counted', () => {
|
|
30
|
+
const { blocks, noiseCount } = partitionAgentEvents(FIXTURE);
|
|
31
|
+
// ⚠ diagnostic folded away, not rendered as a message block.
|
|
32
|
+
expect(noiseCount).toBe(1);
|
|
33
|
+
// The two shell commands collapse into ONE command group (in_progress+
|
|
34
|
+
// completed pairs merged), with the emberflow op + mcp surfaced separately.
|
|
35
|
+
const kinds = blocks.map((b) => b.kind);
|
|
36
|
+
expect(kinds).toEqual(['message', 'commands', 'op', 'mcp', 'message']);
|
|
37
|
+
const group = blocks.find((b) => b.kind === 'commands');
|
|
38
|
+
expect(group?.kind === 'commands' && group.commands).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('drops structural started/done events', () => {
|
|
42
|
+
const { blocks } = partitionAgentEvents([{ type: 'started' }, { type: 'done' }]);
|
|
43
|
+
expect(blocks).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('AgentStream', () => {
|
|
48
|
+
it('renders reasoning prose and the collapsed command count + hidden-diagnostics line', () => {
|
|
49
|
+
const out = renderToStaticMarkup(<AgentStream events={FIXTURE} running={false} />);
|
|
50
|
+
expect(out).toContain('ground truth');
|
|
51
|
+
expect(out).toContain('Ran 2 commands');
|
|
52
|
+
expect(out).toContain('1 background diagnostic hidden');
|
|
53
|
+
// The emberflow op renders as its own OP step.
|
|
54
|
+
expect(out).toContain('list-workflows');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('failed command group: stays collapsed with a destructive header, output hidden until click', () => {
|
|
58
|
+
const failedStream: AgentEvent[] = [
|
|
59
|
+
{ type: 'command', command: 'npm run build', commandStatus: 'in_progress' },
|
|
60
|
+
{
|
|
61
|
+
type: 'command',
|
|
62
|
+
command: 'npm run build',
|
|
63
|
+
commandStatus: 'failed',
|
|
64
|
+
exitCode: 1,
|
|
65
|
+
output: 'RAW-FAILURE-DUMP {"name":"pkg","dependencies":{}}',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
const out = renderToStaticMarkup(<AgentStream events={failedStream} running={false} />);
|
|
69
|
+
// Same collapsed affordance as a successful group…
|
|
70
|
+
expect(out).toContain('Ran 1 command');
|
|
71
|
+
// …with the failure signal on the header (destructive dot + tinted label).
|
|
72
|
+
expect(out).toContain('bg-destructive');
|
|
73
|
+
expect(out).toContain('text-destructive/90');
|
|
74
|
+
// The raw output does NOT dump into the stream — it's behind the click.
|
|
75
|
+
expect(out).not.toContain('RAW-FAILURE-DUMP');
|
|
76
|
+
expect(out).not.toContain('npm run build');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('shows a Thinking… indicator while running with no blocks yet', () => {
|
|
80
|
+
const out = renderToStaticMarkup(<AgentStream events={[{ type: 'started' }]} running={true} />);
|
|
81
|
+
expect(out).toContain('Thinking…');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('shows a Working… indicator while running with content present', () => {
|
|
85
|
+
const out = renderToStaticMarkup(<AgentStream events={FIXTURE} running={true} />);
|
|
86
|
+
expect(out).toContain('Working…');
|
|
87
|
+
});
|
|
88
|
+
});
|