Skip to content
Closed
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
13 changes: 13 additions & 0 deletions scripts/ui-preview/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
16 changes: 11 additions & 5 deletions scripts/ui-preview/test-hierarchy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand Down Expand Up @@ -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()


Expand Down
16 changes: 16 additions & 0 deletions src/core/run-telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'>,
Expand All @@ -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,
Expand Down
15 changes: 12 additions & 3 deletions src/gui/RunActivity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ScanState } from '../types';
import {
activityActor,
activityRows,
activityText,
latestUpdate,
activityStatusLabel,
deepProgressLabel,
dispatchLabel,
Expand All @@ -21,8 +23,15 @@ import { clockTime } from './format';
export function RunActivity(props: { run: ScanState }) {
const run = () => props.run;
return (
<details class="gs-section gs-activity" aria-label="Scan activity">
<details
class="gs-section gs-activity"
aria-label="Scan activity"
open={run().status === 'running'}
>
<summary>Activity</summary>
<Show when={latestUpdate(run())}>
<p class="gs-update">{latestUpdate(run())}</p>
</Show>
<Show when={workerSummary(run()) || dispatchLabel(run())}>
<p class="gs-muted">
{[workerSummary(run()), dispatchLabel(run())].filter(Boolean).join(' · ')}
Expand All @@ -41,7 +50,7 @@ export function RunActivity(props: { run: ScanState }) {
{(activity) => (
<li data-activity-id={activity.id}>
<span class="gs-worker-name">{activityActor(activity.worker)}</span>
<span class="gs-worker-label">{activity.label}</span>
<span class="gs-worker-label">{activityText(activity)}</span>
<span class="gs-status" data-status={activity.status}>
{activityStatusLabel(activity.status, run())}
</span>
Expand All @@ -59,7 +68,7 @@ export function RunActivity(props: { run: ScanState }) {
<li>
<time dateTime={activity.updatedAt}>{clockTime(activity.updatedAt)}</time>
<span>
{activityActor(activity.worker)} · {activity.label} ·{' '}
{activityActor(activity.worker)} · {activityText(activity)} ·{' '}
{activityStatusLabel(activity.status, run())}
<Show when={activity.paths.length}>
<span class="gs-mono gs-muted gs-block">{activity.paths.join(', ')}</span>
Expand Down
15 changes: 14 additions & 1 deletion src/scan-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | 'coordinator'>();
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}`;
}
Expand Down
8 changes: 8 additions & 0 deletions src/security-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
: {}),
};
},
);
Expand Down
7 changes: 6 additions & 1 deletion src/tui/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type { Finding, ScanState } from '../types';
import {
activityActor,
activityRows,
activityText,
latestUpdate,
activityStatusLabel,
coverageLabel,
deepProgressLabel,
Expand Down Expand Up @@ -209,10 +211,13 @@ export function SecurityView(props: Props) {
</Show>
<Show when={activityRows(run()).length}>
<text>Activity</text>
<Show when={latestUpdate(run())}>
<text>{latestUpdate(run())}</text>
</Show>
<For each={workerActivityRows(run()).slice(0, 5)}>
{(activity) => (
<Label>
{activityActor(activity.worker)} · {activity.label} ·{' '}
{activityActor(activity.worker)} · {activityText(activity)} ·{' '}
{activityStatusLabel(activity.status, run())}
</Label>
)}
Expand Down
37 changes: 34 additions & 3 deletions tests/run-telemetry.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
);
Expand Down
18 changes: 18 additions & 0 deletions tests/scan-display.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
deepProgressLabel,
findingsPendingLabel,
activityRows,
activityText,
latestUpdate,
workerActivityRows,
activityActor,
activityStatusLabel,
Expand Down Expand Up @@ -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');
Expand Down