Skip to content

Commit 8a808dc

Browse files
committed
feat: resolve thoughtstream empty state and hitl metrics to 100% coverage
1 parent 6bab82c commit 8a808dc

7 files changed

Lines changed: 197 additions & 45 deletions

File tree

backend/app/agent/agents/resolver.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ async def await_approval(state: AgentState, config: dict | None = None) -> dict:
260260
# Track when HITL was requested
261261
tracker = get_tracker()
262262
metrics = tracker.get_request(state["thread_id"])
263-
if metrics:
263+
if metrics and not metrics.hitl_requested_at:
264264
metrics.hitl_requested_at = time.time()
265265

266266
decision = interrupt({

backend/tests/test_agent_nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,6 +1141,7 @@ async def test_dict_decision_approved(self):
11411141
state["proposed_action"] = {"type": "refund", "description": "Refund $50"}
11421142

11431143
mock_metrics = MagicMock()
1144+
mock_metrics.hitl_requested_at = None
11441145
with patch("app.agent.agents.resolver.interrupt", return_value={"approved": True, "reason": ""}), \
11451146
patch("app.agent.agents.resolver.get_tracker") as mock_tracker:
11461147
mock_tracker.return_value.get_request.return_value = mock_metrics

frontend/src/components/AnimatedNumber.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,6 @@ export default function AnimatedNumber({ value, duration = 800, format }: Animat
4343
}
4444
};
4545

46-
if (animationFrame.current) {
47-
cancelAnimationFrame(animationFrame.current);
48-
}
4946
animationFrame.current = requestAnimationFrame(animate);
5047

5148
return () => {

frontend/src/components/MetricsPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,13 +266,15 @@ export default function MetricsPanel({ metrics, onCacheCleared, onOpenTraces }:
266266

267267
{/* Provider split bar */}
268268
<div className="metric-card py-2 px-3">
269-
<div className="flex items-center gap-2 mb-2" style={{ flexWrap: "nowrap", overflow: "hidden" }}>
269+
<div className="flex items-center gap-3 mb-2 w-full overflow-hidden">
270270
{Object.entries(providers).map(([label, { count, color, icon }]) => {
271271
const pct = ((count / modelTotal) * 100);
272272
return (
273-
<span key={label} className="flex items-center gap-1 text-xs font-semibold" style={{ color, flexShrink: 0, whiteSpace: "nowrap" }}>
274-
{icon} {label} <AnimatedNumber value={pct} format={(v) => Math.round(v) + "%"} />
275-
</span>
273+
<div key={label} className="flex items-center gap-1 text-xs font-semibold min-w-0" style={{ color }}>
274+
<span className="shrink-0">{icon}</span>
275+
<span className="truncate">{label}</span>
276+
<span className="shrink-0"><AnimatedNumber value={pct} format={(v) => Math.round(v) + "%"} /></span>
277+
</div>
276278
);
277279
})}
278280
</div>

frontend/src/components/ThoughtStream.tsx

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -137,51 +137,87 @@ export default function ThoughtStream({ thoughts, status }: ThoughtStreamProps)
137137

138138
{/* Single scroll for all content */}
139139
<div className="flex-1 overflow-y-auto pl-6 pr-4 pb-6 space-y-2" style={{ scrollbarGutter: "stable" }}>
140-
{thoughts.length === 0 ? (
141-
<div className="px-3 py-2.5">
142-
{status === "processing" ? (
143-
<div className="flex items-center gap-2">
144-
<span className="text-sm" style={{ color: "var(--aegis-text-muted)" }}>Agent is thinking</span>
145-
<div className="typing-indicator" style={{ padding: 0 }}>
140+
141+
{/* 1. Empty States (No thoughts generated yet) */}
142+
{thoughts.length === 0 && (
143+
<div className="px-3 py-2.5 flex flex-col gap-3">
144+
{status === "processing" && (
145+
<>
146+
<p className="text-sm" style={{ color: "var(--aegis-text-muted)" }}>
147+
Agent is thinking
148+
</p>
149+
<div className="px-3 typing-indicator" data-testid="typing-indicator">
146150
<span /><span /><span />
147151
</div>
148-
</div>
149-
) : (
152+
</>
153+
)}
154+
{status === "error" && (
155+
<p className="text-sm" style={{ color: "var(--aegis-error)" }}>
156+
✗ An error occurred while connecting to the agent.
157+
</p>
158+
)}
159+
{status === "completed" && (
160+
<p className="text-sm" style={{ color: "var(--aegis-text-muted)" }}>
161+
Agent completed the task without generating any output.
162+
</p>
163+
)}
164+
{status === "cached" && (
165+
<p className="text-sm" style={{ color: "var(--aegis-text-muted)" }}>
166+
Loaded from cache. Processing skipped.
167+
</p>
168+
)}
169+
{(status === "idle" || status === "awaiting_approval") && (
150170
<p className="text-sm" style={{ color: "var(--aegis-text-muted)" }}>
151171
Submit a support ticket to see the agent&apos;s thought process...
152172
</p>
153173
)}
154174
</div>
155-
) : (
156-
thoughts.map((step, i) => {
157-
const { agent, message } = parseAgentName(step);
158-
const agentStyle = agent ? AGENT_COLORS[agent] : null;
159-
const agentIcon = agent ? AGENT_ICONS[agent] : null;
160-
const displayMessage = devMode ? message : simplifyForUser(message);
161-
162-
return (
163-
<div key={i} className="thought-step flex items-start gap-3 px-3 py-2.5 rounded-lg transition-all hover:bg-white/2" style={{ animationDelay: `${i * 80}ms` }}>
164-
<span className={`text-lg font-bold shrink-0 ${getColor(step)}`}>
165-
{getIcon(step)}
166-
</span>
167-
<div className="flex items-start gap-2 flex-1 min-w-0">
168-
{devMode && agentStyle && (
169-
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider shrink-0 border ${agentStyle.bg} ${agentStyle.text} ${agentStyle.border}`}>
170-
<span>{agentIcon}</span>
171-
{agent}
172-
</span>
173-
)}
174-
<span className="text-sm leading-relaxed" style={{ fontFamily: devMode ? "var(--font-mono)" : "inherit", color: "var(--aegis-text)" }}>
175-
{displayMessage}
175+
)}
176+
177+
{/* 2. Thought Stream (List of generated thoughts) */}
178+
{thoughts.length > 0 && thoughts.map((step, i) => {
179+
const { agent, message } = parseAgentName(step);
180+
const agentStyle = agent ? AGENT_COLORS[agent] : null;
181+
const agentIcon = agent ? AGENT_ICONS[agent] : null;
182+
const displayMessage = devMode ? message : simplifyForUser(message);
183+
184+
return (
185+
<div key={i} className="thought-step flex items-start gap-3 px-3 py-2.5 rounded-lg transition-all hover:bg-white/2" style={{ animationDelay: `${i * 80}ms` }}>
186+
<span className={`text-lg font-bold shrink-0 ${getColor(step)}`}>
187+
{getIcon(step)}
188+
</span>
189+
<div className="flex items-start gap-2 flex-1 min-w-0">
190+
{devMode && agentStyle && (
191+
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wider shrink-0 border ${agentStyle.bg} ${agentStyle.text} ${agentStyle.border}`}>
192+
<span>{agentIcon}</span>
193+
{agent}
176194
</span>
177-
</div>
195+
)}
196+
<span className="text-sm leading-relaxed" style={{ fontFamily: devMode ? "var(--font-mono)" : "inherit", color: "var(--aegis-text)" }}>
197+
{displayMessage}
198+
</span>
178199
</div>
179-
);
180-
})
181-
)}
182-
{status === "processing" && thoughts.length > 0 && (
183-
<div className="typing-indicator" data-testid="typing-indicator">
184-
<span /><span /><span />
200+
</div>
201+
);
202+
})}
203+
204+
{/* 3. Trailing Status Indicators (Appended to the bottom of the stream) */}
205+
{thoughts.length > 0 && (
206+
<div className="pt-2">
207+
{status === "processing" && (
208+
<div className="px-3 typing-indicator" data-testid="typing-indicator">
209+
<span /><span /><span />
210+
</div>
211+
)}
212+
{status === "awaiting_approval" && (
213+
<div className="px-3 py-2 flex items-center gap-2 text-sm" style={{ color: "var(--aegis-warning)" }}>
214+
<span className="relative flex h-2 w-2">
215+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75" style={{ backgroundColor: "var(--aegis-warning)" }}></span>
216+
<span className="relative inline-flex rounded-full h-2 w-2" style={{ backgroundColor: "var(--aegis-warning)" }}></span>
217+
</span>
218+
Waiting for your input...
219+
</div>
220+
)}
185221
</div>
186222
)}
187223
</div>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { render, screen, act } from "@testing-library/react";
3+
import AnimatedNumber from "../AnimatedNumber";
4+
5+
describe("AnimatedNumber", () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
// Mock requestAnimationFrame to work predictably with fake timers
9+
let time = 0;
10+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
11+
return setTimeout(() => {
12+
time += 16;
13+
cb(time);
14+
}, 16) as unknown as number;
15+
});
16+
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((id: number) => {
17+
clearTimeout(id);
18+
});
19+
});
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks();
23+
vi.useRealTimers();
24+
});
25+
26+
it("renders initial value", () => {
27+
render(<AnimatedNumber value={25} />);
28+
expect(screen.getByText("25")).toBeInTheDocument();
29+
});
30+
31+
it("animates from start to end value", () => {
32+
const { rerender } = render(<AnimatedNumber value={0} duration={800} />);
33+
expect(screen.getByText("0")).toBeInTheDocument();
34+
35+
// Change value to trigger animation
36+
rerender(<AnimatedNumber value={100} duration={800} />);
37+
38+
act(() => {
39+
vi.advanceTimersByTime(400); // Advance halfway
40+
});
41+
42+
const intermediateText = document.body.textContent;
43+
const num = parseInt(intermediateText || "0", 10);
44+
// It shouldn't be exactly 0 or 100 halfway through
45+
expect(num).toBeGreaterThan(0);
46+
expect(num).toBeLessThan(100);
47+
48+
act(() => {
49+
vi.advanceTimersByTime(500); // Advance past duration
50+
});
51+
52+
expect(screen.getByText("100")).toBeInTheDocument();
53+
});
54+
55+
it("cancels animation frame on unmount", () => {
56+
const { rerender, unmount } = render(<AnimatedNumber value={0} duration={800} />);
57+
58+
// Trigger animation
59+
rerender(<AnimatedNumber value={100} duration={800} />);
60+
act(() => {
61+
vi.advanceTimersByTime(16); // Start animation
62+
});
63+
64+
// Unmount while animating should trigger cancelAnimationFrame
65+
const cancelSpy = vi.spyOn(window, "cancelAnimationFrame");
66+
unmount();
67+
68+
expect(cancelSpy).toHaveBeenCalled();
69+
});
70+
71+
it("uses custom format function", () => {
72+
render(<AnimatedNumber value={50.5} format={(val) => `~${val.toFixed(1)}~`} />);
73+
expect(screen.getByText("~50.5~")).toBeInTheDocument();
74+
});
75+
76+
it("handles changing value while already animating", () => {
77+
const { rerender } = render(<AnimatedNumber value={0} duration={800} />);
78+
79+
rerender(<AnimatedNumber value={100} duration={800} />);
80+
act(() => {
81+
vi.advanceTimersByTime(100); // Partially through animation
82+
});
83+
84+
// Interrupt with a new value
85+
rerender(<AnimatedNumber value={50} duration={800} />);
86+
87+
act(() => {
88+
vi.advanceTimersByTime(1000); // Finish animation
89+
});
90+
91+
expect(screen.getByText("50")).toBeInTheDocument();
92+
});
93+
});

frontend/src/components/__tests__/ThoughtStream.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,32 @@ import ThoughtStream from "../ThoughtStream";
55

66
describe("ThoughtStream", () => {
77
// ── Empty State ──
8-
it("shows placeholder when no thoughts", () => {
8+
it("shows placeholder when no thoughts and not processing", () => {
99
render(<ThoughtStream thoughts={[]} status="idle" />);
1010
expect(screen.getByText(/submit a support ticket/i)).toBeInTheDocument();
11+
expect(screen.queryByTestId("typing-indicator")).not.toBeInTheDocument();
12+
});
13+
14+
it("shows 'Agent is thinking...' when processing but no thoughts", () => {
15+
render(<ThoughtStream thoughts={[]} status="processing" />);
16+
expect(screen.getByText(/agent is thinking/i)).toBeInTheDocument();
17+
expect(screen.getByTestId("typing-indicator")).toBeInTheDocument();
18+
expect(screen.queryByText(/submit a support ticket/i)).not.toBeInTheDocument();
19+
});
20+
21+
it("shows error state when status is error but no thoughts", () => {
22+
render(<ThoughtStream thoughts={[]} status="error" />);
23+
expect(screen.getByText(/an error occurred/i)).toBeInTheDocument();
24+
});
25+
26+
it("shows completed state when status is completed but no thoughts", () => {
27+
render(<ThoughtStream thoughts={[]} status="completed" />);
28+
expect(screen.getByText(/agent completed the task without generating any output/i)).toBeInTheDocument();
29+
});
30+
31+
it("shows cached state when status is cached but no thoughts", () => {
32+
render(<ThoughtStream thoughts={[]} status="cached" />);
33+
expect(screen.getByText(/loaded from cache/i)).toBeInTheDocument();
1134
});
1235

1336
// ── Rendering Thoughts ──

0 commit comments

Comments
 (0)