Skip to content

Commit 570fa86

Browse files
OneStepAt4timeDaedalus
andauthored
feat(dashboard): typed stall pill + Send continue button (#4802) (#4804)
* feat(dashboard): typed stall pill + Send continue button (#4802 Cycle-1.5/1.7) - Zod StallEventPayloadSchema (api/schemas.ts): mirrors server src/stall-events.ts bounded ErrorClass enum + 7 typed fields. Path 2 defensive: all fields .optional() with safe defaults. Added 'status.stall.typed' to SSEEventTypes enum for the new typed stall event. - StallBadge (components/session/StallBadge.tsx): renders pill from typed payload. Color: amber for transient_5xx, red for others. Sub-label: 'X/Y (auto-recovering...)' or 'X/Y — intervention required'. Kill-switch overlay icon when recoveryDisabled. - SendContinueButton (components/session/SendContinueButton.tsx): AC3b button gated on (1) typed payload present, (2) recovery exhausted (attempt >= max), (3) kill-switch NOT engaged. Calls POST /v1/sessions/:id/resume. - stallClassLabels utility: formatStallClassLabel, isRecoveryExhausted, isRecoveryDisabled, formatStallSubLabel, formatStallTooltip. - useStore: added stallMap + setStallMap + clearStallEntry. Path 2 default (empty map) until F-9 wires the typed payload to the SSE bus. - SessionHeader: added StallBadge next to SessionStateBadge. - SessionDetailPage: added SendContinueButton after PauseControlBar. Tests: 44 passing (16 stallClassLabels + 8 StallBadge + 20 schemas). Closes AC2 (visibility pill) and AC3b (Send continue button, gated on recoveryExhausted). AC3a (auto-recovery) is Hep's #4803. AC4 (telemetry) is Hep's #4803. F-9 follow-up wires the typed payload to emit sites; until then, the renderer falls back to generic 'Stalled' pill (Path 2 default). Close-format (per issuecomment-4767577518 PM canonical re-anchoring): Resolved by PR #4803 + PR #<F-9> + PR <daedalus> Audit-trail anchors cited in PR body: 4767501614 (3-PR correction, my self-correction) 4767516114 (precision follow-up) 4767577518 (PM canonical re-anchoring, single source of truth) 4767621205 (PM matrix correction, Option B cell) * fix(dashboard): StallBadge always-conditional guard (Argus review feedback) - StallBadge: return null when payload has no useful stall data (no errorClass AND no recoveryDisabled AND no recovery counter). Mirrors SendContinueButton L36 pattern. Prevents misleading 'Stalled' pill from rendering for healthy sessions on every page load. - SessionHeader: gate at callsite with {stallPayload && <StallBadge .../>} in addition to the component guard (defense in depth). - schemas.ts: inline TODO marking removal point for the 'as unknown as z.ZodType<SessionSSEEvent>' cast once F-9 lands and backend's SessionSSEEvent type includes 'status.stall.typed'. - Tests: added StallBadge.guard.test.tsx (6 new tests) for the always-conditional guard; updated obsolete test in StallBadge.test.tsx that expected the old 'render generic Stalled' behavior. Total: 50 tests passing (was 44). --------- Co-authored-by: Daedalus <daedalus@aegis.local>
1 parent 1807466 commit 570fa86

10 files changed

Lines changed: 679 additions & 2 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* __tests__/StallBadge.guard.test.tsx — Issue #4802 (Argus review feedback):
3+
* always-conditional component integration guard.
4+
*
5+
* StallBadge must return null when the payload has no useful stall data,
6+
* to prevent a misleading "Stalled" pill from rendering for healthy sessions.
7+
* Mirrors the SendContinueButton L36 pattern.
8+
*/
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { render } from '@testing-library/react';
12+
import { StallBadge } from '../components/session/StallBadge';
13+
14+
describe('Issue #4802: StallBadge always-conditional guard (Argus review)', () => {
15+
it('returns null when payload is empty (no stall data)', () => {
16+
const { container } = render(<StallBadge payload={{}} />);
17+
expect(container.firstChild).toBeNull();
18+
});
19+
20+
it('returns null when payload has only undefined fields', () => {
21+
const { container } = render(
22+
<StallBadge payload={{ errorClass: undefined, recoveryDisabled: undefined }} />,
23+
);
24+
expect(container.firstChild).toBeNull();
25+
});
26+
27+
it('returns null when all counters are 0 (Path 2 default state)', () => {
28+
const { container } = render(
29+
<StallBadge
30+
payload={{ recoveryAttemptCount: 0, recoveryMaxAttempts: 0, recoveryDisabled: false }}
31+
/>,
32+
);
33+
expect(container.firstChild).toBeNull();
34+
});
35+
36+
it('renders when errorClass is present (Path 2 with typed payload)', () => {
37+
const { container } = render(
38+
<StallBadge payload={{ errorClass: 'transient_5xx' }} />,
39+
);
40+
expect(container.firstChild).not.toBeNull();
41+
});
42+
43+
it('renders when recoveryDisabled is true (kill-switch overlay)', () => {
44+
const { container } = render(
45+
<StallBadge payload={{ recoveryDisabled: true }} />,
46+
);
47+
expect(container.firstChild).not.toBeNull();
48+
});
49+
50+
it('renders when recovery counter is non-zero', () => {
51+
const { container } = render(
52+
<StallBadge payload={{ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 }} />,
53+
);
54+
expect(container.firstChild).not.toBeNull();
55+
});
56+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* __tests__/StallBadge.test.tsx — Issue #4802: typed stall pill.
3+
*
4+
* Path 2 defensive: tests cover both typed payload (post-F-9) and missing
5+
* fields (pre-F-9) scenarios. The badge must render gracefully when the
6+
* typed payload isn't yet wired to the SSE bus.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { render, screen } from '@testing-library/react';
11+
import { StallBadge } from '../components/session/StallBadge';
12+
import type { StallEventPayload } from '../api/schemas';
13+
14+
describe('Issue #4802: StallBadge', () => {
15+
it('returns null when payload is empty (always-conditional guard, Argus review feedback)', () => {
16+
// Empty payload has no useful stall data: no errorClass, no kill-switch,
17+
// no recovery counter. StallBadge must NOT render a misleading
18+
// 'Stalled' pill for healthy sessions. Mirrors SendContinueButton L36.
19+
const { container } = render(<StallBadge payload={{}} />);
20+
expect(container.firstChild).toBeNull();
21+
});
22+
23+
it('renders typed errorClass label when present', () => {
24+
const payload: Partial<StallEventPayload> = {
25+
errorClass: 'transient_5xx',
26+
};
27+
render(<StallBadge payload={payload} />);
28+
expect(screen.getByText('Transient 5xx')).toBeDefined();
29+
});
30+
31+
it('renders JSONL Stall label for jsonl_stall class', () => {
32+
const payload: Partial<StallEventPayload> = {
33+
errorClass: 'jsonl_stall',
34+
};
35+
render(<StallBadge payload={payload} />);
36+
expect(screen.getByText('JSONL Stall')).toBeDefined();
37+
});
38+
39+
it('renders sub-label "X/Y (auto-recovering…)" when not exhausted', () => {
40+
const payload: Partial<StallEventPayload> = {
41+
errorClass: 'transient_5xx',
42+
recoveryAttemptCount: 3,
43+
recoveryMaxAttempts: 5,
44+
};
45+
render(<StallBadge payload={payload} />);
46+
expect(screen.getByText('Transient 5xx')).toBeDefined();
47+
expect(screen.getByText('3/5 (auto-recovering…)')).toBeDefined();
48+
});
49+
50+
it('renders sub-label "X/Y — intervention required" when exhausted', () => {
51+
const payload: Partial<StallEventPayload> = {
52+
errorClass: 'transient_5xx',
53+
recoveryAttemptCount: 5,
54+
recoveryMaxAttempts: 5,
55+
};
56+
render(<StallBadge payload={payload} />);
57+
expect(screen.getByText('5/5 — intervention required')).toBeDefined();
58+
});
59+
60+
it('hides sub-label when max is 0 (Path 2 default)', () => {
61+
const payload: Partial<StallEventPayload> = {
62+
errorClass: 'transient_5xx',
63+
recoveryAttemptCount: 0,
64+
recoveryMaxAttempts: 0,
65+
};
66+
const { container } = render(<StallBadge payload={payload} />);
67+
expect(screen.getByText('Transient 5xx')).toBeDefined();
68+
expect(container.textContent).not.toContain('0/0');
69+
});
70+
71+
it('renders kill-switch overlay when recoveryDisabled', () => {
72+
const payload: Partial<StallEventPayload> = {
73+
errorClass: 'transient_5xx',
74+
recoveryAttemptCount: 2,
75+
recoveryMaxAttempts: 5,
76+
recoveryDisabled: true,
77+
};
78+
render(<StallBadge payload={payload} />);
79+
// Kill-switch icon has aria-label "Auto-recovery paused (operator kill-switch)"
80+
expect(screen.getByLabelText('Auto-recovery paused (operator kill-switch)')).toBeDefined();
81+
});
82+
83+
it('marks data-stall-exhausted when cap is reached', () => {
84+
const payload: Partial<StallEventPayload> = {
85+
errorClass: 'transient_5xx',
86+
recoveryAttemptCount: 5,
87+
recoveryMaxAttempts: 5,
88+
};
89+
const { container } = render(<StallBadge payload={payload} />);
90+
const badge = container.querySelector('[data-stall-exhausted]');
91+
expect(badge).not.toBeNull();
92+
});
93+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* __tests__/stallClassLabels.test.ts — Issue #4802: stall label + state helpers.
3+
*
4+
* Path 2 defensive: tests cover both typed payload (post-F-9) and missing
5+
* fields (pre-F-9) scenarios. The renderer must degrade gracefully when the
6+
* typed payload isn't yet wired to the SSE bus.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import {
11+
STALL_CLASS_LABELS,
12+
STALL_GENERIC_LABEL,
13+
formatStallClassLabel,
14+
formatStallSubLabel,
15+
isRecoveryExhausted,
16+
isRecoveryDisabled,
17+
formatStallTooltip,
18+
} from '../utils/stallClassLabels';
19+
20+
describe('Issue #4802: stall label utilities', () => {
21+
describe('formatStallClassLabel', () => {
22+
it('maps each ErrorClass to a display label', () => {
23+
for (const [key, expected] of Object.entries(STALL_CLASS_LABELS)) {
24+
expect(formatStallClassLabel(key as never)).toBe(expected);
25+
}
26+
});
27+
28+
it('returns generic label for missing errorClass (Path 2 default)', () => {
29+
expect(formatStallClassLabel(undefined)).toBe(STALL_GENERIC_LABEL);
30+
expect(formatStallClassLabel(null)).toBe(STALL_GENERIC_LABEL);
31+
});
32+
33+
it('returns generic label for unknown string', () => {
34+
// Runtime guard at server side rejects these; renderer is defensive.
35+
expect(formatStallClassLabel('5xx_529' as never)).toBe(STALL_GENERIC_LABEL);
36+
});
37+
});
38+
39+
describe('isRecoveryExhausted', () => {
40+
it('returns true when attempt >= max (both > 0)', () => {
41+
expect(isRecoveryExhausted({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe(true);
42+
expect(isRecoveryExhausted({ recoveryAttemptCount: 6, recoveryMaxAttempts: 5 })).toBe(true);
43+
});
44+
45+
it('returns false when attempt < max', () => {
46+
expect(isRecoveryExhausted({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe(false);
47+
});
48+
49+
it('returns false when attempt < max', () => {
50+
expect(isRecoveryExhausted({ recoveryAttemptCount: 1, recoveryMaxAttempts: 5 })).toBe(false);
51+
});
52+
53+
it('returns false when max is 0 (Path 2 default — unknown)', () => {
54+
expect(isRecoveryExhausted({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBe(false);
55+
expect(isRecoveryExhausted({})).toBe(false);
56+
});
57+
58+
it('returns false when only max is set, attempt is missing', () => {
59+
expect(isRecoveryExhausted({ recoveryMaxAttempts: 5 })).toBe(false);
60+
});
61+
});
62+
63+
describe('formatStallSubLabel', () => {
64+
it('returns "X/Y (auto-recovering…)" when not exhausted', () => {
65+
expect(formatStallSubLabel({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe(
66+
'3/5 (auto-recovering…)',
67+
);
68+
});
69+
70+
it('returns "X/Y — intervention required" when exhausted', () => {
71+
expect(formatStallSubLabel({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe(
72+
'5/5 — intervention required',
73+
);
74+
});
75+
76+
it('returns null when max is 0 (Path 2 default — sub-label hidden)', () => {
77+
expect(formatStallSubLabel({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBeNull();
78+
expect(formatStallSubLabel({})).toBeNull();
79+
});
80+
});
81+
82+
describe('isRecoveryDisabled', () => {
83+
it('returns true when recoveryDisabled === true', () => {
84+
expect(isRecoveryDisabled({ recoveryDisabled: true })).toBe(true);
85+
});
86+
87+
it('returns false when recoveryDisabled is false or missing (Path 2 default)', () => {
88+
expect(isRecoveryDisabled({ recoveryDisabled: false })).toBe(false);
89+
expect(isRecoveryDisabled({})).toBe(false);
90+
});
91+
});
92+
93+
describe('formatStallTooltip', () => {
94+
it('composes metadata-only tooltip (no transcript text)', () => {
95+
const tooltip = formatStallTooltip({
96+
errorClass: 'transient_5xx',
97+
statusCode: 529,
98+
lastErrorAt: '2026-06-22T12:00:00.000Z',
99+
stallDurationMs: 600000, // 10 minutes
100+
recoveryAttemptCount: 3,
101+
recoveryMaxAttempts: 5,
102+
});
103+
expect(tooltip).toContain('Transient 5xx');
104+
expect(tooltip).toContain('529');
105+
expect(tooltip).toContain('2026-06-22T12:00:00.000Z');
106+
expect(tooltip).toContain('10m');
107+
expect(tooltip).toContain('3/5');
108+
expect(tooltip).not.toContain('detail'); // F-6 redaction discipline
109+
});
110+
111+
it('omits statusCode for non-transient_5xx classes', () => {
112+
const tooltip = formatStallTooltip({
113+
errorClass: 'jsonl_stall',
114+
statusCode: 529, // would be invalid in real payload, but defensive
115+
recoveryAttemptCount: 1,
116+
recoveryMaxAttempts: 5,
117+
});
118+
expect(tooltip).toContain('JSONL Stall');
119+
expect(tooltip).not.toContain('529');
120+
});
121+
122+
it('handles missing fields gracefully (Path 2 default)', () => {
123+
const tooltip = formatStallTooltip({});
124+
expect(tooltip).toBe('Stalled'); // just the generic label
125+
});
126+
});
127+
});

dashboard/src/api/schemas.ts

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,9 +368,10 @@ const SSEEventTypes = z.enum([
368368
'subagent_stop',
369369
'verification',
370370
'permission_denied',
371+
'status.stall.typed',
371372
]);
372373

373-
export const SessionSSEEventDataSchema: z.ZodType<SessionSSEEvent> = z.object({
374+
export const SessionSSEEventDataSchema = z.object({
374375
event: SSEEventTypes,
375376
sessionId: z.string(),
376377
timestamp: z.string(),
@@ -380,7 +381,15 @@ export const SessionSSEEventDataSchema: z.ZodType<SessionSSEEvent> = z.object({
380381
}).transform((event) => ({
381382
...event,
382383
data: event.data ?? {},
383-
}));
384+
})) as unknown as z.ZodType<SessionSSEEvent>;
385+
// TODO(#4802 F-9 follow-up): Remove the `as unknown as z.ZodType<SessionSSEEvent>`
386+
// cast once the backend's `SessionSSEEvent` type (src/api-contracts.ts) includes
387+
// `'status.stall.typed'` in its `event` field union. At that point, F-9 has
388+
// wired `buildStallEventPayload()` into the 12 emit sites, the typed stall
389+
// payload flows in the wire, and the Zod enum in this file matches the
390+
// backend's TypeScript union. The cast was needed because the new
391+
// `'status.stall.typed'` event name was added to the local Zod enum
392+
// ahead of the backend type update (forward-compatibility for the renderer).
384393

385394
// ── Global SSE Event (Issue #410) ──────────────────────────────
386395

@@ -440,3 +449,59 @@ export const WsInboundMessageSchema = z.discriminatedUnion('type', [
440449
WsStreamMessageSchema,
441450
WsErrorMessageSchema,
442451
]);
452+
453+
// ── Stall Event Payload (Issue #4802) ────────────────────────────
454+
455+
/**
456+
* Bounded ErrorClass enum mirroring server `src/stall-events.ts`.
457+
* Renderer maps these to the dashboard pill label. Adding a new value is a
458+
* schema PR that gets reviewed — schema drift cannot grow unchecked.
459+
*
460+
* Server-side isErrorClass() rejects unknown values, defending against
461+
* prompt-injection inputs that try to inject new errorClass values.
462+
*/
463+
export const ErrorClassSchema = z.enum([
464+
'transient_5xx',
465+
'permission_timeout',
466+
'jsonl_stall',
467+
'thinking_stall',
468+
'unknown_stall',
469+
'extended_working',
470+
]);
471+
472+
/** TypeScript type for the ErrorClass bounded enum (derived from ErrorClassSchema). */
473+
export type ErrorClass = z.infer<typeof ErrorClassSchema>;
474+
475+
/**
476+
* Zod schema for StallEventPayload, mirroring server `src/stall-events.ts`.
477+
*
478+
* Path 2 defensive defaults: all fields are `.optional()` with safe fallbacks.
479+
* - Pre-F-9 (typed payload not yet wired to SSE bus): fields are missing, the
480+
* renderer falls back to a generic "Stalled" pill with no sub-label or
481+
* AC3b button. Safe default.
482+
* - Post-F-9 (typed payload wired to SSE bus): fields populate, full pill
483+
* (errorClass label + sub-label + AC3b button) renders.
484+
*
485+
* `recoveryExhausted` is NOT in the server's StallEventPayload yet — the
486+
* renderer computes "exhausted" locally from
487+
* `recoveryAttemptCount >= recoveryMaxAttempts` (when both > 0).
488+
*/
489+
export const StallEventPayloadSchema = z.object({
490+
errorClass: ErrorClassSchema.optional(),
491+
statusCode: z.number().int().min(100).max(599).optional(),
492+
lastErrorAt: z.string().optional(),
493+
stallDurationMs: z.number().nonnegative().optional(),
494+
recoveryAttemptCount: z.number().int().nonnegative().optional(),
495+
recoveryMaxAttempts: z.number().int().nonnegative().optional(),
496+
recoveryDisabled: z.boolean().optional(),
497+
}).transform((p) => ({
498+
errorClass: p.errorClass,
499+
statusCode: p.statusCode,
500+
lastErrorAt: p.lastErrorAt,
501+
stallDurationMs: p.stallDurationMs ?? 0,
502+
recoveryAttemptCount: p.recoveryAttemptCount ?? 0,
503+
recoveryMaxAttempts: p.recoveryMaxAttempts ?? 0,
504+
recoveryDisabled: p.recoveryDisabled ?? false,
505+
}));
506+
507+
export type StallEventPayload = z.infer<typeof StallEventPayloadSchema>;

0 commit comments

Comments
 (0)