diff --git a/scripts/ui-preview/main.tsx b/scripts/ui-preview/main.tsx index 91cf263..5a8a7a9 100644 --- a/scripts/ui-preview/main.tsx +++ b/scripts/ui-preview/main.tsx @@ -65,11 +65,23 @@ const run = (id, status, focus) => ({ estimatedCostUsd: 0.24, telemetry: { activity: [ + { + id: id + '-note', + kind: 'message', + status: 'completed', + label: 'Scan update', + detail: + 'The strongest candidate so far is a missing tenant check on project updates. Checking whether a member of another workspace can reach it.', + paths: [], + startedAt: new Date(t - 6000).toISOString(), + updatedAt: new Date(t - 5000).toISOString(), + }, { id: id + '-reading', kind: 'command', status: status === 'running' ? 'running' : 'completed', label: 'Reading source', + detail: 'sed -n "1,260p" src/api/projects.ts', paths: ['src/api/projects.ts'], worker: 1, startedAt: new Date(t - 12000).toISOString(), @@ -80,6 +92,7 @@ const run = (id, status, focus) => ({ kind: 'command', status: 'completed', label: 'Searching source', + detail: 'rg -n "tenantId" src/api', paths: ['src/api'], worker: 2, startedAt: new Date(t - 14000).toISOString(), diff --git a/scripts/ui-preview/test-hierarchy.py b/scripts/ui-preview/test-hierarchy.py index 5db420c..4168de6 100644 --- a/scripts/ui-preview/test-hierarchy.py +++ b/scripts/ui-preview/test-hierarchy.py @@ -40,17 +40,21 @@ async def server_event(): await expect(page.get_by_role('heading', name='Reviewing source', exact=True)).to_be_visible() await expect(page.locator('progress')).to_have_attribute('value', '3') await expect(page.get_by_text('Partial estimate USD 0.24', exact=True).first).to_be_visible() - await expect(page.locator('.gs-workers')).not_to_be_visible() + # Activity is open while a scan runs, and leads with the scan's latest note. + await expect(page.locator('.gs-workers')).to_be_visible() + await expect(page.locator('.gs-update')).to_contain_text('strongest candidate') await expect(page.get_by_text('3 of 10 reviews', exact=True)).to_be_visible() await shot('hierarchy-running-desktop') - # Keyboard disclosure, then a server event: progress updates and the disclosure stays open. + # Closing it from the keyboard sticks across a server event; reopening works too. await page.locator('.gs-activity > summary').focus() await page.keyboard.press('Enter') - await expect(page.locator('.gs-workers')).to_be_visible() + await expect(page.locator('.gs-workers')).not_to_be_visible() await server_event() await expect(page.locator('progress')).to_have_attribute('value', '4') await expect(page.get_by_text('4 of 10 reviews', exact=True)).to_be_visible() + await expect(page.locator('.gs-workers')).not_to_be_visible() + await page.keyboard.press('Enter') await expect(page.locator('.gs-workers')).to_be_visible() await page.locator('.gs-info > summary').click() await expect(page.get_by_text('Measured subtotal only', exact=False)).to_be_visible() @@ -69,8 +73,10 @@ async def server_event(): await page.locator('.gs-coverage > summary').click() await expect(page.get_by_text('Production identity-provider configuration was not available', exact=False)).to_be_visible() + # A running scan opens with only its activity showing. await select('Review upload handling') - assert await page.locator('.gs-detail details[open]').count() == 0 + assert await page.locator('.gs-detail details[open]').count() == 1 + await expect(page.locator('.gs-activity')).to_have_attribute('open', '') assert await page.locator('progress').count() == 0 await expect(page.get_by_role('heading', name='Building threat model')).to_be_visible() await page.get_by_role('button', name='New scan', exact=True).click() @@ -105,7 +111,7 @@ async def server_event(): await page.locator('.gs-coverage > summary').click() await expect(page.get_by_text('The scan did not report what it reviewed.', exact=True)).to_be_visible() assert not errors, errors - print(json.dumps({'passed': ['running stage and phase progress', 'collapsed diagnostics', 'keyboard disclosure', 'server event updates progress and keeps disclosure', 'selection resets disclosure', 'findings above fold', 'finding evidence', 'coverage details', 'unknown progress', 'new scan dismissal', 'cancel affects selected scan only', 'narrow no overflow', 'finalization', 'deep consolidation', 'failed scan', 'informational-only result', 'missing coverage', 'no browser errors'], 'screenshots': str(EVIDENCE)})) + print(json.dumps({'passed': ['running stage and phase progress', 'activity open while running', 'keyboard disclosure', 'server event updates progress and keeps disclosure', 'selection resets disclosure', 'findings above fold', 'finding evidence', 'coverage details', 'unknown progress', 'new scan dismissal', 'cancel affects selected scan only', 'narrow no overflow', 'finalization', 'deep consolidation', 'failed scan', 'informational-only result', 'missing coverage', 'no browser errors'], 'screenshots': str(EVIDENCE)})) await browser.close() diff --git a/src/core/run-telemetry.ts b/src/core/run-telemetry.ts index 3f188de..9d9c6a0 100644 --- a/src/core/run-telemetry.ts +++ b/src/core/run-telemetry.ts @@ -17,6 +17,8 @@ export interface ScanActivityEntry { kind: 'command' | 'tool' | 'reasoning' | 'message'; status: 'running' | 'completed' | 'failed'; label: string; + /** What the step said, for the user's own screen. Never returned to the session model. */ + detail?: string; paths: string[]; worker?: number; startedAt: string; @@ -133,6 +135,17 @@ function activityLabel(kind: ScanActivityEntry['kind'], description: string): st if (executable === 'git') return 'Repository command'; return kind === 'tool' ? 'Tool call' : 'Command'; } +/** One redacted line of what a step said. Opaque tool payloads are reduced to the tool's name. */ +function activityDetail(kind: ScanActivityEntry['kind'], description: string): string | undefined { + const text = safeReportText(description, 4000).replace(/\s+/g, ' ').trim(); + if (!text) return; + if (kind === 'tool') { + const [name = '', payload = ''] = text.split(' · ', 2); + if (name === 'spawn_agent') return 'Starting a worker'; + if (/\S{60,}/.test(payload)) return name; + } + return text.length > 400 ? `${text.slice(0, 399)}…` : text; +} export function recordActivity( telemetry: ScanTelemetry, value: CallbackValue<'onActivity'>, @@ -159,6 +172,9 @@ export function recordActivity( kind: value.kind, status: value.status, label: activityLabel(value.kind, value.description ?? ''), + ...(activityDetail(value.kind, value.description ?? '') + ? { detail: activityDetail(value.kind, value.description ?? '') } + : {}), paths: safeActivityPaths(value.paths, repository), ...(worker === undefined ? {} : { worker }), startedAt: previous?.startedAt ?? now, diff --git a/src/gui/RunActivity.tsx b/src/gui/RunActivity.tsx index 2e66d97..844836e 100644 --- a/src/gui/RunActivity.tsx +++ b/src/gui/RunActivity.tsx @@ -3,6 +3,8 @@ import type { ScanState } from '../types'; import { activityActor, activityRows, + activityText, + latestUpdate, activityStatusLabel, deepProgressLabel, dispatchLabel, @@ -21,8 +23,15 @@ import { clockTime } from './format'; export function RunActivity(props: { run: ScanState }) { const run = () => props.run; return ( -
+
Activity + +

{latestUpdate(run())}

+

{[workerSummary(run()), dispatchLabel(run())].filter(Boolean).join(' · ')} @@ -41,7 +50,7 @@ export function RunActivity(props: { run: ScanState }) { {(activity) => (

  • {activityActor(activity.worker)} - {activity.label} + {activityText(activity)} {activityStatusLabel(activity.status, run())} @@ -59,7 +68,7 @@ export function RunActivity(props: { run: ScanState }) {
  • - {activityActor(activity.worker)} · {activity.label} ·{' '} + {activityActor(activity.worker)} · {activityText(activity)} ·{' '} {activityStatusLabel(activity.status, run())} {activity.paths.join(', ')} diff --git a/src/scan-display.ts b/src/scan-display.ts index f585b9d..d9a8470 100644 --- a/src/scan-display.ts +++ b/src/scan-display.ts @@ -188,16 +188,29 @@ export function activityRows(run: ScanState) { b.updatedAt.localeCompare(a.updatedAt), ); } -/** Show each observed actor once; the disclosure retains the individual keyed activities. */ +/** + * Show each observed actor once; the disclosure retains the individual keyed activities. The + * scan's notes are shown above as the latest update, so they are not repeated here. + */ export function workerActivityRows(run: ScanState) { const seen = new Set(); return activityRows(run).filter((activity) => { + if (activity.kind === 'message' && activity.detail) return false; const actor = activity.worker ?? 'coordinator'; if (seen.has(actor)) return false; seen.add(actor); return true; }); } +/** What a step said when the scan reported it, otherwise its category. */ +export function activityText(activity: { label: string; detail?: string }) { + return activity.detail || activity.label; +} +/** The scan's most recent note about what it has found or is doing. */ +export function latestUpdate(run: ScanState) { + return activityRows(run).find((activity) => activity.kind === 'message' && activity.detail) + ?.detail; +} export function activityActor(worker?: number) { return worker === undefined ? 'Coordinator' : `Worker ${worker}`; } diff --git a/src/security-styles.ts b/src/security-styles.ts index 56368d9..88c7658 100644 --- a/src/security-styles.ts +++ b/src/security-styles.ts @@ -971,6 +971,14 @@ export const securityStyles = ` margin-bottom: 8px; font-size: 12px; } +.gs-activity > .gs-update { + margin-bottom: 12px; + padding-left: 10px; + border-left: 2px solid var(--gs-accent); + font-size: 13px; + line-height: 1.5; + overflow-wrap: anywhere; +} .gs-workers { padding: 0; list-style: none; diff --git a/src/tools.ts b/src/tools.ts index 3ab4b61..0bf2ea7 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -245,7 +245,13 @@ export function registerTools( const state = await run(input); return { ...scanSummary(state), - ...(input.activity ? { events: state.events, activity: state.telemetry?.activity } : {}), + ...(input.activity + ? { + events: state.events, + // What each step said stays on the user's screen. + activity: state.telemetry?.activity.map(({ detail: _detail, ...entry }) => entry), + } + : {}), }; }, ); diff --git a/src/tui/view.tsx b/src/tui/view.tsx index 07a8e75..f0287b5 100644 --- a/src/tui/view.tsx +++ b/src/tui/view.tsx @@ -7,6 +7,8 @@ import type { Finding, ScanState } from '../types'; import { activityActor, activityRows, + activityText, + latestUpdate, activityStatusLabel, coverageLabel, deepProgressLabel, @@ -209,10 +211,13 @@ export function SecurityView(props: Props) { Activity + + {latestUpdate(run())} + {(activity) => ( )} diff --git a/tests/run-telemetry.test.mjs b/tests/run-telemetry.test.mjs index 86ed408..bcf6d1b 100644 --- a/tests/run-telemetry.test.mjs +++ b/tests/run-telemetry.test.mjs @@ -10,7 +10,7 @@ import { import { CodexScanService } from '../src/core/codex-adapter.ts'; import { plan } from './helpers/plan.mjs'; const now = '2026-11-10T10:00:00.000Z'; -test('activity preserves safe identities, mentions and terminal transitions, never raw descriptions or IDs', () => { +test('activity keeps what each step said, redacted, and never raw IDs or outside paths', () => { const telemetry = emptyTelemetry(); const activity = { id: 'SECRET reasoning-id', @@ -34,11 +34,42 @@ test('activity preserves safe identities, mentions and terminal transitions, nev assert.deepEqual(telemetry.activity[0].paths, ['src/api.ts']); assert.equal(telemetry.activity[0].worker, 2); assert.equal(telemetry.activity[0].startedAt, now); - assert.doesNotMatch(JSON.stringify(telemetry), /SECRET|password/); + assert.equal(telemetry.activity[0].detail, 'cat src/api.ts; password=[REDACTED]'); + assert.doesNotMatch(JSON.stringify(telemetry), /SECRET/); + // Tool payloads that are not readable text are reduced to the tool's name. + const tools = emptyTelemetry(); + recordActivity( + tools, + { id: 't', kind: 'tool', status: 'running', description: `spawn_agent · ${'g'.repeat(300)}` }, + '/tmp/project', + now, + ); + recordActivity( + tools, + { id: 'u', kind: 'tool', status: 'running', description: `exec · ${'x'.repeat(80)}` }, + '/tmp/project', + now, + ); + recordActivity( + tools, + { + id: 'v', + kind: 'message', + status: 'completed', + description: `Checked\n the ${'a '.repeat(400)}`, + }, + '/tmp/project', + now, + ); + assert.deepEqual( + tools.activity.map((item) => item.detail?.slice(0, 20)), + ['Starting a worker', 'exec', 'Checked the a a a a '], + ); + assert.equal(tools.activity[2].detail.length, 400); for (let i = 0; i < 120; i++) recordActivity( telemetry, - { ...activity, id: String(i), kind: 'reasoning', description: 'SECRET' }, + { ...activity, id: `SECRET-${i}`, kind: 'reasoning', description: 'Planning the review' }, '/tmp/project', now, ); diff --git a/tests/scan-display.test.mjs b/tests/scan-display.test.mjs index bc8667b..212775a 100644 --- a/tests/scan-display.test.mjs +++ b/tests/scan-display.test.mjs @@ -15,6 +15,8 @@ import { deepProgressLabel, findingsPendingLabel, activityRows, + activityText, + latestUpdate, workerActivityRows, activityActor, activityStatusLabel, @@ -119,6 +121,22 @@ test('activity keeps safe labels and worker identity without inferring worker co activityRows(state).map((row) => row.id), ['b', 'a'], ); + assert.equal(activityText(state.telemetry.activity[0]), 'Reading source'); + assert.equal(latestUpdate(state), undefined); + state.telemetry.activity.push({ + id: 'c', + kind: 'message', + label: 'Scan update', + detail: 'Two candidates confirmed in the billing API.', + status: 'completed', + updatedAt: '2026-11-10T00:00:02Z', + }); + assert.equal( + activityText(state.telemetry.activity[2]), + 'Two candidates confirmed in the billing API.', + ); + assert.equal(latestUpdate(state), 'Two candidates confirmed in the billing API.'); + state.telemetry.activity.pop(); assert.equal(activityActor(), 'Coordinator'); assert.equal(activityActor(0), 'Worker 0'); assert.equal(workerSummary(state), '2 worker sessions observed');