Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions dashboard/src/__tests__/StallBadge.guard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* __tests__/StallBadge.guard.test.tsx — Issue #4802 (Argus review feedback):
* always-conditional component integration guard.
*
* StallBadge must return null when the payload has no useful stall data,
* to prevent a misleading "Stalled" pill from rendering for healthy sessions.
* Mirrors the SendContinueButton L36 pattern.
*/

import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { StallBadge } from '../components/session/StallBadge';

describe('Issue #4802: StallBadge always-conditional guard (Argus review)', () => {
it('returns null when payload is empty (no stall data)', () => {
const { container } = render(<StallBadge payload={{}} />);
expect(container.firstChild).toBeNull();
});

it('returns null when payload has only undefined fields', () => {
const { container } = render(
<StallBadge payload={{ errorClass: undefined, recoveryDisabled: undefined }} />,
);
expect(container.firstChild).toBeNull();
});

it('returns null when all counters are 0 (Path 2 default state)', () => {
const { container } = render(
<StallBadge
payload={{ recoveryAttemptCount: 0, recoveryMaxAttempts: 0, recoveryDisabled: false }}
/>,
);
expect(container.firstChild).toBeNull();
});

it('renders when errorClass is present (Path 2 with typed payload)', () => {
const { container } = render(
<StallBadge payload={{ errorClass: 'transient_5xx' }} />,
);
expect(container.firstChild).not.toBeNull();
});

it('renders when recoveryDisabled is true (kill-switch overlay)', () => {
const { container } = render(
<StallBadge payload={{ recoveryDisabled: true }} />,
);
expect(container.firstChild).not.toBeNull();
});

it('renders when recovery counter is non-zero', () => {
const { container } = render(
<StallBadge payload={{ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 }} />,
);
expect(container.firstChild).not.toBeNull();
});
});
93 changes: 93 additions & 0 deletions dashboard/src/__tests__/StallBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* __tests__/StallBadge.test.tsx — Issue #4802: typed stall pill.
*
* Path 2 defensive: tests cover both typed payload (post-F-9) and missing
* fields (pre-F-9) scenarios. The badge must render gracefully when the
* typed payload isn't yet wired to the SSE bus.
*/

import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StallBadge } from '../components/session/StallBadge';
import type { StallEventPayload } from '../api/schemas';

describe('Issue #4802: StallBadge', () => {
it('returns null when payload is empty (always-conditional guard, Argus review feedback)', () => {
// Empty payload has no useful stall data: no errorClass, no kill-switch,
// no recovery counter. StallBadge must NOT render a misleading
// 'Stalled' pill for healthy sessions. Mirrors SendContinueButton L36.
const { container } = render(<StallBadge payload={{}} />);
expect(container.firstChild).toBeNull();
});

it('renders typed errorClass label when present', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
};
render(<StallBadge payload={payload} />);
expect(screen.getByText('Transient 5xx')).toBeDefined();
});

it('renders JSONL Stall label for jsonl_stall class', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'jsonl_stall',
};
render(<StallBadge payload={payload} />);
expect(screen.getByText('JSONL Stall')).toBeDefined();
});

it('renders sub-label "X/Y (auto-recovering…)" when not exhausted', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
recoveryAttemptCount: 3,
recoveryMaxAttempts: 5,
};
render(<StallBadge payload={payload} />);
expect(screen.getByText('Transient 5xx')).toBeDefined();
expect(screen.getByText('3/5 (auto-recovering…)')).toBeDefined();
});

it('renders sub-label "X/Y — intervention required" when exhausted', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
recoveryAttemptCount: 5,
recoveryMaxAttempts: 5,
};
render(<StallBadge payload={payload} />);
expect(screen.getByText('5/5 — intervention required')).toBeDefined();
});

it('hides sub-label when max is 0 (Path 2 default)', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
recoveryAttemptCount: 0,
recoveryMaxAttempts: 0,
};
const { container } = render(<StallBadge payload={payload} />);
expect(screen.getByText('Transient 5xx')).toBeDefined();
expect(container.textContent).not.toContain('0/0');
});

it('renders kill-switch overlay when recoveryDisabled', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
recoveryAttemptCount: 2,
recoveryMaxAttempts: 5,
recoveryDisabled: true,
};
render(<StallBadge payload={payload} />);
// Kill-switch icon has aria-label "Auto-recovery paused (operator kill-switch)"
expect(screen.getByLabelText('Auto-recovery paused (operator kill-switch)')).toBeDefined();
});

it('marks data-stall-exhausted when cap is reached', () => {
const payload: Partial<StallEventPayload> = {
errorClass: 'transient_5xx',
recoveryAttemptCount: 5,
recoveryMaxAttempts: 5,
};
const { container } = render(<StallBadge payload={payload} />);
const badge = container.querySelector('[data-stall-exhausted]');
expect(badge).not.toBeNull();
});
});
127 changes: 127 additions & 0 deletions dashboard/src/__tests__/stallClassLabels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* __tests__/stallClassLabels.test.ts — Issue #4802: stall label + state helpers.
*
* Path 2 defensive: tests cover both typed payload (post-F-9) and missing
* fields (pre-F-9) scenarios. The renderer must degrade gracefully when the
* typed payload isn't yet wired to the SSE bus.
*/

import { describe, it, expect } from 'vitest';
import {
STALL_CLASS_LABELS,
STALL_GENERIC_LABEL,
formatStallClassLabel,
formatStallSubLabel,
isRecoveryExhausted,
isRecoveryDisabled,
formatStallTooltip,
} from '../utils/stallClassLabels';

describe('Issue #4802: stall label utilities', () => {
describe('formatStallClassLabel', () => {
it('maps each ErrorClass to a display label', () => {
for (const [key, expected] of Object.entries(STALL_CLASS_LABELS)) {
expect(formatStallClassLabel(key as never)).toBe(expected);
}
});

it('returns generic label for missing errorClass (Path 2 default)', () => {
expect(formatStallClassLabel(undefined)).toBe(STALL_GENERIC_LABEL);
expect(formatStallClassLabel(null)).toBe(STALL_GENERIC_LABEL);
});

it('returns generic label for unknown string', () => {
// Runtime guard at server side rejects these; renderer is defensive.
expect(formatStallClassLabel('5xx_529' as never)).toBe(STALL_GENERIC_LABEL);
});
});

describe('isRecoveryExhausted', () => {
it('returns true when attempt >= max (both > 0)', () => {
expect(isRecoveryExhausted({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe(true);
expect(isRecoveryExhausted({ recoveryAttemptCount: 6, recoveryMaxAttempts: 5 })).toBe(true);
});

it('returns false when attempt < max', () => {
expect(isRecoveryExhausted({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe(false);
});

it('returns false when attempt < max', () => {
expect(isRecoveryExhausted({ recoveryAttemptCount: 1, recoveryMaxAttempts: 5 })).toBe(false);
});

it('returns false when max is 0 (Path 2 default — unknown)', () => {
expect(isRecoveryExhausted({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBe(false);
expect(isRecoveryExhausted({})).toBe(false);
});

it('returns false when only max is set, attempt is missing', () => {
expect(isRecoveryExhausted({ recoveryMaxAttempts: 5 })).toBe(false);
});
});

describe('formatStallSubLabel', () => {
it('returns "X/Y (auto-recovering…)" when not exhausted', () => {
expect(formatStallSubLabel({ recoveryAttemptCount: 3, recoveryMaxAttempts: 5 })).toBe(
'3/5 (auto-recovering…)',
);
});

it('returns "X/Y — intervention required" when exhausted', () => {
expect(formatStallSubLabel({ recoveryAttemptCount: 5, recoveryMaxAttempts: 5 })).toBe(
'5/5 — intervention required',
);
});

it('returns null when max is 0 (Path 2 default — sub-label hidden)', () => {
expect(formatStallSubLabel({ recoveryAttemptCount: 0, recoveryMaxAttempts: 0 })).toBeNull();
expect(formatStallSubLabel({})).toBeNull();
});
});

describe('isRecoveryDisabled', () => {
it('returns true when recoveryDisabled === true', () => {
expect(isRecoveryDisabled({ recoveryDisabled: true })).toBe(true);
});

it('returns false when recoveryDisabled is false or missing (Path 2 default)', () => {
expect(isRecoveryDisabled({ recoveryDisabled: false })).toBe(false);
expect(isRecoveryDisabled({})).toBe(false);
});
});

describe('formatStallTooltip', () => {
it('composes metadata-only tooltip (no transcript text)', () => {
const tooltip = formatStallTooltip({
errorClass: 'transient_5xx',
statusCode: 529,
lastErrorAt: '2026-06-22T12:00:00.000Z',
stallDurationMs: 600000, // 10 minutes
recoveryAttemptCount: 3,
recoveryMaxAttempts: 5,
});
expect(tooltip).toContain('Transient 5xx');
expect(tooltip).toContain('529');
expect(tooltip).toContain('2026-06-22T12:00:00.000Z');
expect(tooltip).toContain('10m');
expect(tooltip).toContain('3/5');
expect(tooltip).not.toContain('detail'); // F-6 redaction discipline
});

it('omits statusCode for non-transient_5xx classes', () => {
const tooltip = formatStallTooltip({
errorClass: 'jsonl_stall',
statusCode: 529, // would be invalid in real payload, but defensive
recoveryAttemptCount: 1,
recoveryMaxAttempts: 5,
});
expect(tooltip).toContain('JSONL Stall');
expect(tooltip).not.toContain('529');
});

it('handles missing fields gracefully (Path 2 default)', () => {
const tooltip = formatStallTooltip({});
expect(tooltip).toBe('Stalled'); // just the generic label
});
});
});
69 changes: 67 additions & 2 deletions dashboard/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,10 @@ const SSEEventTypes = z.enum([
'subagent_stop',
'verification',
'permission_denied',
'status.stall.typed',
]);

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

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

Expand Down Expand Up @@ -440,3 +449,59 @@ export const WsInboundMessageSchema = z.discriminatedUnion('type', [
WsStreamMessageSchema,
WsErrorMessageSchema,
]);

// ── Stall Event Payload (Issue #4802) ────────────────────────────

/**
* Bounded ErrorClass enum mirroring server `src/stall-events.ts`.
* Renderer maps these to the dashboard pill label. Adding a new value is a
* schema PR that gets reviewed — schema drift cannot grow unchecked.
*
* Server-side isErrorClass() rejects unknown values, defending against
* prompt-injection inputs that try to inject new errorClass values.
*/
export const ErrorClassSchema = z.enum([
'transient_5xx',
'permission_timeout',
'jsonl_stall',
'thinking_stall',
'unknown_stall',
'extended_working',
]);

/** TypeScript type for the ErrorClass bounded enum (derived from ErrorClassSchema). */
export type ErrorClass = z.infer<typeof ErrorClassSchema>;

/**
* Zod schema for StallEventPayload, mirroring server `src/stall-events.ts`.
*
* Path 2 defensive defaults: all fields are `.optional()` with safe fallbacks.
* - Pre-F-9 (typed payload not yet wired to SSE bus): fields are missing, the
* renderer falls back to a generic "Stalled" pill with no sub-label or
* AC3b button. Safe default.
* - Post-F-9 (typed payload wired to SSE bus): fields populate, full pill
* (errorClass label + sub-label + AC3b button) renders.
*
* `recoveryExhausted` is NOT in the server's StallEventPayload yet — the
* renderer computes "exhausted" locally from
* `recoveryAttemptCount >= recoveryMaxAttempts` (when both > 0).
*/
export const StallEventPayloadSchema = z.object({
errorClass: ErrorClassSchema.optional(),
statusCode: z.number().int().min(100).max(599).optional(),
lastErrorAt: z.string().optional(),
stallDurationMs: z.number().nonnegative().optional(),
recoveryAttemptCount: z.number().int().nonnegative().optional(),
recoveryMaxAttempts: z.number().int().nonnegative().optional(),
recoveryDisabled: z.boolean().optional(),
}).transform((p) => ({
errorClass: p.errorClass,
statusCode: p.statusCode,
lastErrorAt: p.lastErrorAt,
stallDurationMs: p.stallDurationMs ?? 0,
recoveryAttemptCount: p.recoveryAttemptCount ?? 0,
recoveryMaxAttempts: p.recoveryMaxAttempts ?? 0,
recoveryDisabled: p.recoveryDisabled ?? false,
}));

export type StallEventPayload = z.infer<typeof StallEventPayloadSchema>;
Loading
Loading