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
8 changes: 5 additions & 3 deletions web/frontend/e2e/jev-decision-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@ test('live judgments show choice distribution, native score and noul before actu
await expect(page.locator('[data-selected=true]')).toHaveCount(0)
await expect(page.getByTestId('jev-question').filter({ hasText: '正在判断' })).toHaveCount(3)
const call = { id: 'read', name: 'read-evidence', arguments: { data: new TextEncoder().encode('{"target":"public"}') } }
await render(page, [runtime(4, { case: 'decisionResult', value: { requestId: 'live', elapsedMs: 275, usage: {inputTokens:120n,outputTokens:15n}, evaluations: {
await render(page, [runtime(4, { case: 'decisionResult', value: { requestId: 'live', elapsedMs: 275,
usage: { inputTokens: 120n, outputTokens: 15n, detail: { cache_read: 60n, cache_write: 10n } }, evaluations: {
next: { value: { case: 'choice', value: 'picked' }, confidence: .7, probabilities: { winner: .7, picked: .3 } },
risk: { value: { case: 'score', value: 1.4 }, confidence: .8 }, enough: { value: { case: 'noul', value: .73 }, confidence: .6 },
} } }), runtime(5, { case: 'takeover', value: { definition } }), runtime(6, { case: 'dispatch', value: { call, candidateId: 'picked', read: true } })], true)
await expect(page.getByTestId('jev-check')).toHaveCount(0)
await expect(page.getByTestId('agent-workflow')).toHaveAttribute('open', '')
await page.locator('[data-record-id="event-3"]').click()
await expect(page.getByTestId('jev-token-usage')).toContainText('JEV · 输入 120 · 输出 15 token')
await expect(page.getByTestId('token-usage')).toHaveText('↓120↑1550%')
await expect(page.getByTestId('token-usage')).toHaveAccessibleName('输入 120 token · 输出 15 token · 合计 135 token · 缓存读取 60 token · 缓存写入 10 token · 缓存 50%')
const choice = page.locator('[data-question-id=next]')
await expect(choice.locator('[data-option-id]')).toHaveCount(3)
expect(await choice.locator('[data-option-id]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-option-id')))).toEqual(['winner', 'picked', 'unknown'])
Expand Down Expand Up @@ -87,5 +89,5 @@ test('compilation keeps generation, review, publication and a failed retry in ch
await expect(page.locator('[data-control-current=true]')).toContainText('生成失败')
await expect(page.locator('[data-control-current=true]')).toContainText('Invalid binding')
await expect(page.locator('[data-generation-request-id="reflex-draft-2"]')).toContainText('第 2 次生成 · 错误阶段:reader')
await expect(page.getByTestId('jev-token-usage').filter({hasText:'Reflex LLM'})).toContainText('token 用量未知')
await expect(page.locator('[data-generation-request-id="reflex-draft-2"]').locator('..').getByTestId('token-usage')).toHaveCount(0)
})
3 changes: 3 additions & 0 deletions web/frontend/e2e/jev-mechanism.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ test('compiler rounds and mechanism diagnostics retain separate usage', async ({
event(8, { case: 'usage', value: { inputTokens: 50n, outputTokens: 5n } }),
runtime(9, { case: 'decisionResult', value: { requestId: 'missing-usage' } })]
await render(page, [...events, ...events])
await expect(page.locator('[data-record-id="mechanism-2"]').getByTestId('token-usage')).toHaveText('↓100↑10')
await expect(page.locator('[data-record-id="mechanism-4"]').getByTestId('token-usage')).toHaveCount(0)
await expect(page.locator('[data-record-id="mechanism-9"]').getByTestId('token-usage')).toHaveCount(0)
await page.locator('[data-record-id="mechanism-3"]').click()
const detail = page.locator('[data-control-current=true]')
await expect(detail).toContainText('native read flag contradicts trusted contract')
Expand Down
2 changes: 1 addition & 1 deletion web/frontend/e2e/jev-robustness.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ test('foreground arguments and tool-free generated results stay in the execution
const segment = page.getByTestId('agent-workflow')
await page.locator('[data-record-id="deep-2"]').click()
await expect(segment).toContainText('当前任务参数')
await expect(segment.getByTestId('jev-token-usage')).toContainText('前台 LLM')
await expect(segment.getByTestId('token-usage')).toHaveText('↓20↑8')
await page.locator('[data-record-id="deep-4"]').click()
await expect(segment).toContainText('answer')
await expect(segment).toContainText('12')
Expand Down
71 changes: 71 additions & 0 deletions web/frontend/e2e/workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,77 @@ test('delegated execution branches once and background events stay in their orig
expect(workflow.nodes.find(node => node.background)?.state).toBe('pending')
})

test('card usage accumulates calls and excludes the turn aggregate after a response boundary', () => {
const usage = (seq: number, input: bigint, output: bigint) => event(seq, { case: 'usage', value: {
inputTokens: input, outputTokens: output, totalTokens: input + output, detail: { cache_read: input / 2n, cache_write: 0n },
} })
const events = [event(1, { case: 'message', value: { id: 'first', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'First step' } } }] } }),
usage(2, 100n, 20n), usage(3, 50n, 10n), event(4, { case: 'status', value: { state: 'eval_start' } }),
event(5, { case: 'message', value: { id: 'last', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'Final answer' } } }] } }),
usage(6, 40n, 5n), event(7, { case: 'turnEnded', value: { usage: { inputTokens: 190n, outputTokens: 35n, totalTokens: 225n } } })]
const items = reduceAOPToTimeline([...events, ...events], { responseBoundary: e => e.payload.case === 'status' })
expect(items.filter(item => item.kind === 'assistant_response').map(item => item.response?.metadata?.usage)).toEqual([
{ input_tokens: 150, output_tokens: 30, total_tokens: 180, cache_read: 75, cache_write: 0, model: '' },
{ input_tokens: 40, output_tokens: 5, total_tokens: 45, cache_read: 20, cache_write: 0, model: '' },
])
})

for (const width of [390, 1280]) test(`card token usage stays compact and appears once through live updates and replay ${width}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 })
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.route('**/cyber.rpc.artifact.ArtifactService/SyncArtifacts', route => route.fulfill({
contentType: 'application/proto', body: Buffer.from(toBinary(SyncArtifactsResponseSchema, create(SyncArtifactsResponseSchema))),
}))
await mount(page)
const first = [event(1, { case: 'turnStarted', value: {} }),
event(2, { case: 'message', value: { id: 'thought', role: 'assistant', content: [{ value: { case: 'reasoning', value: { text: 'Inspect the recorded evidence.' } } }] } }),
event(3, { case: 'toolCall', value: call }), event(4, { case: 'toolResult', value: { callId: call.id, name: call.name,
output: [{ value: { case: 'text', value: { text: 'Current evidence retrieved.' } } }] } }),
event(5, { case: 'usage', value: { inputTokens: 1200n, outputTokens: 240n, totalTokens: 1440n, detail: { cache_read: 960n, cache_write: 0n } } })]
await render(page, first)
const badges = page.getByTestId('token-usage')
await expect(badges).toHaveCount(1)
await expect(badges.first()).toHaveText('↓1.2k↑24080%')
const rest = [event(6, { case: 'status', value: { state: 'eval_start' } }),
event(7, { case: 'message', value: { id: 'reply', role: 'assistant', content: [{ value: { case: 'text', value: { text: 'Recorded evidence verified.' } } }] } }),
event(8, { case: 'usage', value: { inputTokens: 300n, outputTokens: 30n, totalTokens: 330n, detail: { cache_read: 0n } } }),
event(9, { case: 'turnEnded', value: { usage: { inputTokens: 1500n, outputTokens: 270n, totalTokens: 1770n } } })]
await render(page, rest, true)
await expect(badges).toHaveCount(2)
await expect(page.locator('[data-kind=tool]').getByTestId('token-usage')).toHaveCount(0)
await expect(page.locator('[data-kind=response]').getByTestId('token-usage')).toHaveCount(0)
await expect(page.getByTestId('assistant-response-footer').getByTestId('token-usage')).toHaveText('↓300↑30')
await badges.first().focus()
await expect(page.getByRole('tooltip')).toContainText('缓存读取 960 token')
await expect(page.getByRole('tooltip')).toContainText('缓存写入 0 token')
await page.keyboard.press('Escape')
const content = await badges.allTextContents()
await render(page, [...first, ...rest, ...first, ...rest])
await expect(badges).toHaveCount(2)
expect(await badges.allTextContents()).toEqual(content)
for (const badge of await badges.all()) {
const bounds = await badge.boundingBox()
expect(bounds!.height).toBeLessThan(24)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width)
}
await badges.first().evaluate(element => (element as HTMLElement).blur())
await expect(page.getByRole('tooltip')).toHaveCount(0)
await page.screenshot({ path: info.outputPath(`card-token-usage-${width}.png`), fullPage: true })
})

test('total-only usage stays visible and unreported usage adds no placeholder', async ({ page }) => {
await mount(page)
const answer = event(1, { case: 'message', value: { id: 'answer', role: 'assistant',
content: [{ value: { case: 'text', value: { text: 'Verified current evidence.' } } }] } })
await render(page, [answer])
await expect(page.getByTestId('token-usage')).toHaveCount(0)
await render(page, [event(2, { case: 'turnEnded', value: { usage: { totalTokens: 11n } } })], true)
await expect(page.getByTestId('token-usage')).toHaveText('11 tok')
await expect(page.getByTestId('token-usage')).toHaveAccessibleName('合计 11 token')
await render(page, [answer, event(2, { case: 'usage', value: { inputTokens: 100n, detail: { usage_missing: 1n } } })])
await expect(page.getByTestId('token-usage')).toHaveCount(0)
})

test('live decisions and tool receipts update inline without duplicating their content', async ({ page }, info) => {
await mount(page)
const initial = [event(1, { case: 'turnStarted', value: {} }), trace(2, { case: 'takeover', value: { definition } }),
Expand Down
9 changes: 7 additions & 2 deletions web/frontend/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { withRecaps } from '../lib/recap-view'
import { isJEVBoundary, jevTimelineEvents, projectJEV, runtimeEvents, withJEV } from '../lib/jev-view'
import { withWorkflows, type WorkflowTurn } from '../lib/workflow-view'
import { Workflow } from './chat/Workflow'
import { TokenUsageBadge } from './chat/TokenUsageBadge'
import { emptyExtensionResolver, type ExtensionResolver } from '../runtime/conversation'
import { ContributionBoundary } from '../runtime/contribution-boundary'
import { ReviewState, type Review } from '../cyber-proto'
Expand Down Expand Up @@ -911,6 +912,7 @@ function timelineContent(
</div>}
{node.response?.content.trim() && <div data-testid="assistant-response-content"><MarkdownContent content={trimDisplayContent(node.response.content)} compact /></div>}
{typeof node.response?.metadata?.recap === 'string' && <div data-testid="assistant-response-footer" className="mt-3 border-t border-border pt-3 text-xs text-muted-foreground"><span data-testid="task-recap">{node.response.metadata.recap}</span></div>}
<TokenUsageBadge usage={node.response?.metadata?.usage} />
</div>
: node.kind === 'extension' && node.extensionType === 'guardrail'
? <GuardrailReviewCard review={node.data.review as Review} unavailable={node.data.unavailable === true} intercepted={node.data.intercepted === true}
Expand Down Expand Up @@ -1164,8 +1166,11 @@ function AssistantResponseEntry({
</div>
) : undefined}
response={hasResponse ? <MarkdownContent content={trimDisplayContent(message?.content || '')} compact /> : undefined}
footer={!embedded && typeof message?.metadata?.recap === 'string'
? <span data-testid="task-recap">{message.metadata.recap}</span> : undefined}
footer={message?.metadata?.usage || !embedded && typeof message?.metadata?.recap === 'string'
? <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
{!embedded && typeof message?.metadata?.recap === 'string' && <span data-testid="task-recap">{message.metadata.recap}</span>}
<TokenUsageBadge usage={message?.metadata?.usage} />
</div> : undefined}
labels={{ tools: toolsLabel, thinking: t('thinkingLabel'), response: t('responseLabel') }}
headerClassName="xl:hidden"
timeLabel={formatRailTime(response)}
Expand Down
14 changes: 4 additions & 10 deletions web/frontend/src/components/chat/JEVDecision.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,12 @@ import { ClaimType, type Claim, type Evaluation } from '../../gen/decision/claim
import { Check, CircuitBoard, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import type { DecisionRequest, DecisionResult } from '../../gen/types/jev_pb'
import type { TokenUsage } from '../../../cyber-ui/packages/aop/src/gen/aop/event_pb'
import { TokenUsageBadge } from './TokenUsageBadge'
import { decisionOptions, decisionQuestions, evaluationNumber, evaluationChoice } from '../../lib/jev-decisions'
import './JEVTimeline.css'

const percent = (value: number) => `${(value * 100).toFixed(1)}%`

export function TokenUsageLine({ source, usage }: { source: string; usage?: TokenUsage }) {
const { t } = useTranslation('jev')
return <p className="mt-2 text-[10px] tabular-nums text-muted-foreground" data-testid="jev-token-usage" title={t('tokenExplanation')}>{source} · {usage && !usage.detail.usage_missing
? t('tokenUsage', { input: usage.inputTokens.toString(), output: usage.outputTokens.toString() }) : t('usageUnknown')}</p>
}

export function ChoiceBranches({ question, answer, definition = false }: { question: Claim; answer?: Evaluation; definition?: boolean }) {
const { t } = useTranslation('jev')
const options = decisionOptions(question, answer)
Expand Down Expand Up @@ -65,13 +59,13 @@ export function DecisionBatch({ request, result, bodyOnly = false }: { request:
const header = <div className="flex flex-wrap items-center gap-2 text-xs">
<CircuitBoard className="h-4 w-4 text-emerald-500" /><span className="font-semibold">JEV</span><span>{t('judgment')}</span>
<span className="text-[10px] text-muted-foreground">{questions.length > 1 ? t('parallelQuestions', { count: questions.length }) : t('singleQuestion')}</span>
{result ? <span className="ml-auto text-[10px] tabular-nums text-muted-foreground">{Number(result.elapsedMs)} ms</span> : <Loader2 className="ml-auto h-3 w-3 animate-spin" />}
{result ? <span className="ml-auto flex flex-wrap items-center justify-end gap-x-3 gap-y-1"><TokenUsageBadge usage={result.usage} /><span className="text-[10px] tabular-nums text-muted-foreground">{Number(result.elapsedMs)} ms</span></span> : <Loader2 className="ml-auto h-3 w-3 animate-spin" />}
</div>
const body = <>{result && <TokenUsageLine source="JEV" usage={result.usage} />}{result?.error && <p className="mt-2 break-words text-xs text-destructive">{result.error}</p>}
const body = <>{result?.error && <p className="mt-2 break-words text-xs text-destructive">{result.error}</p>}
<div className="jev-question-grid">{questions.map(([id, question]) => <QuestionView key={id} id={id} question={question} answer={result?.evaluations[id]} purpose={request.purpose} finished={!!result} />)}</div></>
const attributes = { 'data-testid': 'jev-decision', 'data-request-id': request.requestId, 'data-runtime-request': request.requestId, 'data-state': result ? result.error ? 'failed' : 'answered' : 'judging' }
return <div className={`jev-decision ${bodyOnly ? 'jev-decision-body' : ''}`} {...attributes}>{bodyOnly ? <div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{questions.length > 1 ? t('parallelQuestions', { count: questions.length }) : t('singleQuestion')}</span>
{result ? <span>{Number(result.elapsedMs)} ms</span> : <Loader2 className="h-3 w-3 animate-spin" />}
{result ? <span className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1"><TokenUsageBadge usage={result.usage} /><span>{Number(result.elapsedMs)} ms</span></span> : <Loader2 className="h-3 w-3 animate-spin" />}
</div> : header}{body}</div>
}
12 changes: 9 additions & 3 deletions web/frontend/src/components/chat/JEVTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { DecisionRequestSchema } from '../../gen/types/jev_pb'
import { ClaimSchema } from '../../gen/decision/claim_pb'
import { claimDefinitions, parseJEVJSON, evaluationType } from '../../lib/jev-decisions'
import type { JEVSegment, JEVCompilation, JEVCheck } from '../../lib/jev-view'
import { DecisionBatch, TokenUsageLine } from './JEVDecision'
import { DecisionBatch } from './JEVDecision'
import { TokenUsageBadge } from './TokenUsageBadge'
import { JEVDefinition } from './JEVDefinition'
import { JEVReference } from './JEVReference'
import { recordWorkflows, type WorkflowNode } from '../../lib/workflow-view'
Expand Down Expand Up @@ -103,10 +104,15 @@ export function WorkflowNodeContent({ node, nodes, stage }: { node: WorkflowNode
}))
const published = generation.state === 'finished' && !generation.error && drafts.length > 0 && targets.every(Boolean)
const publications = [...new Map(targets.filter((target): target is WorkflowNode => !!target).map(target => [target.id, target])).values()]
// Compiler rounds own their individual counters; the parent includes
// those same calls and already contributes to the panel's aggregate.
const hasRounds = generation.kind === 'reflex_llm' && generation.requestId && nodes.some(other => other.sessionId === node.sessionId
&& other.turnId === node.turnId && other.related?.some(record => record.value.payload.case === 'generation'
&& record.value.payload.value.parentRequestId === generation.requestId && record.value.payload.value.usage))
return <div className="space-y-3">
<p className="text-xs text-muted-foreground">{t(generation.state === 'started' ? 'generationStarted' : generation.error ? 'generationFailed' : 'generationFinished')}</p>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground"><span>{t(generation.state === 'started' ? 'generationStarted' : generation.error ? 'generationFailed' : 'generationFinished')}</span>
{generation.state === 'finished' && generation.kind !== 'reflex_validation' && !hasRounds && <TokenUsageBadge usage={generation.usage} />}</div>
{(generation.attempt > 0 || generation.errorStage) && <p className="text-[11px] text-muted-foreground" data-generation-request-id={generation.requestId}>{t('generationAttempt', { count: generation.attempt })}{generation.errorStage && ` · ${t('errorStage', { stage: generation.errorStage })}`}</p>}
{generation.state === 'finished' && generation.kind !== 'reflex_validation' && <TokenUsageLine source={generation.kind === 'parameters_llm' ? t('foregroundLLM') : generation.kind === 'claim_llm' ? 'Claim LLM' : 'Reflex LLM'} usage={generation.usage} />}
{generation.error && generation.error !== diagnosticMessage && <CompilationFeedback reason={generation.error} />}
{generation.kind === 'reflex_validation' && generation.output && <CompilerDiagnosticView output={generation.output} />}
{published ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('workflow.artifactAtPublication')}</p>
Expand Down
Loading
Loading