Skip to content
Open
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
7 changes: 7 additions & 0 deletions CONTINUITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Key decisions:
- React UI is primary; server should serve React build when present.
- Agent lane follows vanilla JS behavior: show all open sessions (active + idle) and sort by state rank then CPU.
- Layout uses pinned positions per agent identity; only new agents are placed; existing tiles never move; placement uses max-height bounds to prevent overlap.
- Scale placement with spatial hash buckets (cell -> occupants) and persistent per-group spiral frontier; collisions checked by bounds against bucket occupants.
- Codex prompt-like titles containing temp paths/turn markers are ignored for lane labels; fallback to repo or codex#pid.
- Agent lane now shows only active/error sessions and uses stable sort (state rank then identity) to prevent reordering.
- Active lane items now glow via `is-active` class (lane item box-shadow) to make active codex sessions visibly highlighted.
Expand Down Expand Up @@ -133,6 +134,11 @@ State:
- Tests re-run: `npm run test:unit` (149 pass), `npm run test:integration` (54 pass), `npm run test:ui` (19 pass).
- Build run after pinned layout changes: `npm run build` (pass).
- Copied edited files to clipboard via pbcopy; temp bundle at `/tmp/consensus-edited-files.txt`.
- Implemented spatial hash buckets + persistent group spiral frontier; fixed cell packing and bounds-based collision checks (public/src/lib/layout.ts).
- Renderer now keeps a single RAF loop, exposes hit list, and uses correct view transform for hit tests (public/src/hooks/useCanvasRenderer.ts).
- CanvasScene no longer rebuilds hit list; uses renderer hit list and stable render loop (public/src/components/CanvasScene.tsx).
- Obstruction detection checks only front occluders with AABB guard (public/src/hooks/useCanvasRenderer.ts).
- Tests run after layout/renderer updates: `npm run test:unit` (152 pass), `npm run test:integration` (58 pass), `npm run test:ui` (19 pass).
- Now:
- Answer user question about hot reload configuration (Vite HMR + server watch + live reload SSE).
- Answer whether any additional setup is missing when using Vite.
Expand Down Expand Up @@ -161,6 +167,7 @@ State:
- Claude CLI hooks not firing for real sessions; user reports Claude does not show active when working.
- Need to validate live Claude session while recording via agent-browser and inspect hook delivery.
- Copy edited layout/test files to clipboard via pbcopy. (DONE)
- Implement spatial hash buckets + persistent group frontier with correct cell packing and bounds checks. (DONE)
- User requested a best-practice plan (no code edits) to address review comment: `npm run dev` no longer starts Vite, so TSX client fails unless dev:client or build is run.
- Q&A answered for dev workflow: always start Vite; use CONSENSUS_UI_PORT for port; on port conflict try next port.
- Review plan requested for new P2/P3 items: Codex in-flight timeout default in codexLogs; TOML [tui] notifications insertion in cli/setup.
Expand Down
69 changes: 69 additions & 0 deletions docs/audits/test-audit-2026-02-04.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Test Audit (2026-02-04)

Scope: `tests/unit/*.test.ts`, `tests/integration/*.test.ts`.

## Summary
- Primary risk: several tests assert behavior using duplicated parsing logic or shared global state, which can mask production regressions.
- Flakiness risk: time-based loops and wall-clock `Date.now()` usage without deterministic control.
- Security/PII coverage is narrow (home dir + email only); no tests for API keys, tokens, or mixed-case patterns.
- Environment specificity: multiple tests hardcode macOS paths (`/Users/...`) without Windows/Linux equivalents.
- Readability/FP: some tests mutate global process state or depend on global singletons, reducing test purity.

## Findings (By Category)

### Production-Validation Gaps
1. `tests/unit/opencodeSessionActivity.test.ts` mirrors production parsing logic instead of exercising the real implementation. This can pass even if production code changes or regresses. Consider calling the production parser directly or exporting a shared pure helper. Lines 4-99. (Production validation)
2. `tests/unit/codexEventStore.test.ts` uses a singleton `codexEventStore` shared across tests; state leaks between tests are possible and the “thread isolation” test clears state conditionally, which is not a full reset. Lines 3, 6-162. (Production validation, FP best practice)
3. `tests/integration/opencodeActivity.test.ts` and `tests/integration/opencodeSessionActivity.test.ts` mostly validate mock HTTP responses; they do not validate retry/backoff behavior or error handling beyond status/text. This is acceptable but leaves gaps for production failure modes like partial JSON or truncated bodies. (Production validation, security)

### Hallucination/Over-Summarization Risk
1. `tests/integration/codexLogs.test.ts` validates summary fields for known events but does not assert that summaries remain empty for unknown event types or malformed payload shapes. This can allow summarizers to invent content. Consider adding tests that assert `summary.*` remains `undefined` when no known signals exist. Lines 9-120, 535-629. (Hallucination checks)
2. `tests/unit/opencodeEvents.test.ts` asserts summary fields for known events but does not test that unrelated event types do not populate summaries. Add negative tests to guard against accidental inference. Lines 31-109. (Hallucination checks)

### Security Audit Coverage
1. `tests/unit/redact.test.ts` only covers home-directory and email redaction. Missing tests for API keys, tokens (e.g. `sk-`, `sk-ant-`), bearer headers, IP addresses, and Windows paths. Add comprehensive coverage for common secrets and mixed-case patterns. Lines 5-8. (Security)
2. `tests/integration/codexLogs.test.ts` includes reasoning payloads and validates “thinking” redaction but does not assert that raw `encrypted_content` or `summary` text is never surfaced in summaries. Add explicit assertions for absence. Lines 535-566. (Security, hallucination)

### Environment-Specific Assumptions
1. `tests/integration/codexLogs.test.ts` hardcodes macOS paths (`/Users/alice/...`) when asserting redaction to `~/...`. Consider parametrizing to accept Linux (`/home/...`) and Windows (`C:\Users\...`) or using `os.homedir()` to build inputs. Lines 15-28, 54-56. (Environment specificity)
2. `tests/unit/redact.test.ts` uses `/Users/alice/...` only; no Windows/Linux path validation. Lines 5-8. (Environment specificity)

### Flakiness / Time Dependence
1. `tests/integration/codexLogs.test.ts` includes a real-time polling loop with `Date.now()` + `setTimeout`, which is sensitive to CI load and can cause flakiness. Replace with deterministic time control (mock clock) or short-circuit the loop using synthetic timestamps. Lines 631-675. (Stability)
2. Multiple tests use `Date.now()` without deterministic control for expected timestamps. Prefer fixed timestamps to keep tests hermetic. Examples: `tests/unit/claudeEvents.test.ts` (lines 8-88), `tests/integration/opencodeActivity.test.ts` (lines 24-205). (Stability)

### Readability / Naming / FP Practices
1. `tests/unit/claudeState.test.ts` has a name/expectation mismatch: “marks active” but asserts `idle`. Rename to match intent or adjust assertion. Lines 10-16. (Readability)
2. `tests/integration/codexLogs.test.ts` mutates global `Date.now` and environment variables across tests. Most are restored, but this style weakens FP isolation. Prefer injecting a clock or wrapper function into the unit under test. Lines 740-1070. (FP best practice)
3. `tests/unit/codexEventStore.test.ts` relies on mutating global store state across tests rather than using a fresh store per test (impure). Consider a factory or reset method for the event store. Lines 3-162. (FP best practice)

## File-by-File Notes

### `tests/unit/opencodeSessionActivity.test.ts`
- Lines 38-99: duplicate parsing logic; tests validate the test copy, not production behavior. Risk of false positives if production changes. (Production validation)
- Lines 105-341: good coverage of edge cases, but consider using real API parsing helper to avoid divergence. (Readability)

### `tests/unit/codexEventStore.test.ts`
- Lines 3-162: shared singleton state across tests; “thread isolation” cleanup is partial and depends on prior state. Recommend adding a reset or building a fresh store for each test. (FP best practice)

### `tests/unit/claudeState.test.ts`
- Lines 10-16: name suggests active state but asserts idle; rename test or update expectation. (Readability)

### `tests/unit/redact.test.ts`
- Lines 5-8: only tests macOS home directory and email. Add token/key/IP/redaction and Windows/Linux paths. (Security, environment)

### `tests/integration/codexLogs.test.ts`
- Lines 15-28: macOS-specific paths; add cross-platform coverage. (Environment)
- Lines 535-566: redaction test does not assert that sensitive fields are not leaked. (Security)
- Lines 631-675: time-based loop can be flaky; use deterministic clock. (Stability)
- Lines 740-1070: `Date.now` and env mutation; consider clock injection for FP isolation. (FP)

### `tests/integration/opencodeActivity.test.ts` and `tests/integration/opencodeSessionActivity.test.ts`
- Time-dependent `Date.now()` values (lines 24-205 / 41-120). Deterministic timestamps would improve stability. (Stability)

## Recommendations
- Extract shared parsing helpers in production code and import them in tests to avoid logic drift.
- Introduce a test-only clock wrapper or dependency injection to eliminate `Date.now` and sleep loops.
- Add redaction tests for API keys, bearer tokens, IPs, and Windows/Linux paths.
- Add negative tests to ensure summaries remain empty for unknown event types.
- Use fresh instances or reset methods for shared singleton stores to keep tests pure.
182 changes: 14 additions & 168 deletions public/src/components/CanvasScene.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,10 @@
import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react';
import type { AgentSnapshot, ViewState } from '../types';
import { useCanvasRenderer } from '../hooks/useCanvasRenderer';
import { createLayoutState, updateLayout, getCoordinate } from '../lib/layout';
import { agentIdentity, keyForAgent } from '../lib/format';
import { pointInDiamond, pointInQuad, isoToScreen } from '../lib/iso';
import { createLayoutState, updateLayout } from '../lib/layout';
import { agentIdentity } from '../lib/format';
import { Tooltip } from './Tooltip';

const TILE_W = 96;
const TILE_H = 48;
const ROOF_SCALE = 0.28;
const ROOF_HIT_SCALE = 0.44;
const MARKER_SCALE = 0.36;
const MARKER_OFFSET = TILE_H * 0.6;

interface HitItem {
x: number;
y: number;
roofY: number;
roofW: number;
roofH: number;
roofHitW: number;
roofHitH: number;
height: number;
agent: AgentSnapshot;
key: string;
markerY?: number;
}

interface CanvasSceneProps {
agents: AgentSnapshot[];
view: ViewState;
Expand All @@ -42,21 +20,19 @@ export function CanvasScene({
agents,
view,
selected,
searchMatches,
searchMatches: _searchMatches,
onSelect,
onMouseDown,
onKeyDown,
onWheel,
}: CanvasSceneProps) {
const containerRef = useRef<HTMLDivElement>(null);
const layoutRef = useRef(createLayoutState());
const spawnTimesRef = useRef<Map<string, number>>(new Map());
const knownIdsRef = useRef<Set<string>>(new Set());
const [hovered, setHovered] = useState<AgentSnapshot | null>(null);
const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 });
const hitListRef = useRef<HitItem[]>([]);

const { canvasRef, startRender, stopRender } = useCanvasRenderer();
const { canvasRef, startRender, stopRender, getAgentAtPoint, getHitList } = useCanvasRenderer();

// Update layout and spawn times when agents change
useLayoutEffect(() => {
Expand All @@ -81,162 +57,36 @@ export function CanvasScene({
updateLayout(layoutRef.current, agents);
}, [agents]);

// Rebuild hit list whenever agents or view changes
useEffect(() => {
const roofW = TILE_W * ROOF_SCALE;
const roofH = roofW * 0.5;
const roofHitW = TILE_W * ROOF_HIT_SCALE;
const roofHitH = roofHitW * 0.5;

// Sort by depth (same as render order)
const sortedAgents = [...agents].sort((a, b) => {
const coordA = getCoordinate(layoutRef.current, a) ?? { x: 0, y: 0 };
const coordB = getCoordinate(layoutRef.current, b) ?? { x: 0, y: 0 };
return coordA.x + coordA.y - (coordB.x + coordB.y);
});

const hitList: HitItem[] = [];

for (const agent of sortedAgents) {
const coord = getCoordinate(layoutRef.current, agent);
if (!coord) continue;

const screen = isoToScreen(coord.x, coord.y, TILE_W, TILE_H);
const memMB = agent.mem / (1024 * 1024);
const heightBase = Math.min(120, Math.max(18, memMB * 0.4));
const idleScale = agent.state === 'idle' ? 0.6 : 1;
const height = heightBase * idleScale;
const roofY = screen.y - height - TILE_H * 0.15;

hitList.push({
x: screen.x,
y: screen.y,
roofY,
roofW,
roofH,
roofHitW,
roofHitH,
height,
agent,
key: keyForAgent(agent),
});
}

// Calculate obstruction and marker positions
const obstructedIds = new Set<string>();
for (const a of hitList) {
const roofPoint = { x: a.x, y: a.roofY };
for (const b of hitList) {
if (a === b) continue;
const topY = b.y - b.height;
const halfW = TILE_W / 2;
const halfH = TILE_H / 2;

const leftA = { x: b.x - halfW, y: topY };
const leftB = { x: b.x, y: topY + halfH };
const leftC = { x: b.x, y: b.y + halfH };
const leftD = { x: b.x - halfW, y: b.y };

const rightA = { x: b.x + halfW, y: topY };
const rightB = { x: b.x, y: topY + halfH };
const rightC = { x: b.x, y: b.y + halfH };
const rightD = { x: b.x + halfW, y: b.y };

if (
pointInQuad(roofPoint, leftA, leftB, leftC, leftD) ||
pointInQuad(roofPoint, rightA, rightB, rightC, rightD)
) {
obstructedIds.add(agentIdentity(a.agent));
break;
}
}
}

for (const item of hitList) {
if (obstructedIds.has(agentIdentity(item.agent))) {
item.markerY = item.roofY - MARKER_OFFSET;
}
}

hitListRef.current = hitList;
}, [agents, view]);

useEffect(() => {
const win = window as any;
if (!win.__consensusMock) {
win.__consensusMock = {};
}
win.__consensusMock.getHitList = () => hitListRef.current;
win.__consensusMock.getHitList = () => getHitList();
win.__consensusMock.getView = () => ({ x: view.x, y: view.y, scale: view.scale });
}, [view]);
}, [view, getHitList]);

// Start/stop render loop
useEffect(() => {
if (!canvasRef.current) return;

startRender(view, agents, {
layout: layoutRef.current,
hovered,
selected,
spawnTimes: spawnTimesRef.current,
deviceScale: window.devicePixelRatio || 1,
});
}, [agents, view, hovered, selected, startRender]);

useEffect(() => {
return () => {
stopRender();
};
}, [agents, view, hovered, selected, searchMatches, startRender, stopRender, canvasRef]);

const findAgentAt = useCallback((canvasX: number, canvasY: number): AgentSnapshot | null => {
// Transform canvas coordinates to world coordinates
const worldX = (canvasX - view.x) / view.scale;
const worldY = (canvasY - view.y) / view.scale;

const markerW = TILE_W * MARKER_SCALE;
const markerH = markerW * 0.5;
const hitList = hitListRef.current;

if (!hitList.length) return null;

// Check markers first (for obstructed agents)
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (!item.markerY) continue;
if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) {
return item.agent;
}
}

// Check roofs
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (pointInDiamond(worldX, worldY, item.x, item.roofY, item.roofHitW, item.roofHitH)) {
return item.agent;
}
}

// Check base tiles
for (let i = hitList.length - 1; i >= 0; i--) {
const item = hitList[i];
if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) {
return item.agent;
}
}

return null;
}, [view]);
}, [stopRender]);

const handleMouseMove = useCallback((e: React.MouseEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;

const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

const found = findAgentAt(x, y);
const found = getAgentAtPoint(e.clientX, e.clientY);
setHovered(found);
setTooltipPos({ x: e.clientX + 12, y: e.clientY + 12 });
}, [findAgentAt]);
}, [getAgentAtPoint]);

const handleMouseLeave = useCallback(() => {
setHovered(null);
Expand All @@ -249,18 +99,14 @@ export function CanvasScene({
return;
}

// Calculate from click position (for direct clicks without hover)
const rect = (e.target as HTMLCanvasElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const found = findAgentAt(x, y);
const found = getAgentAtPoint(e.clientX, e.clientY);
if (found) {
onSelect(found);
}
}, [hovered, onSelect, findAgentAt]);
}, [hovered, onSelect, getAgentAtPoint]);

return (
<div ref={containerRef} className="canvas-container">
<div className="canvas-container">
<canvas
id="scene"
ref={canvasRef}
Expand Down
Loading
Loading