Skip to content

fix: cancellation turn-granularity, extension report gaps, and cache-aware cost pricing - #236

Open
arunSunnyKVS wants to merge 3 commits into
masterfrom
fix/cancellation-turn-granularity-and-extension-report-gaps
Open

fix: cancellation turn-granularity, extension report gaps, and cache-aware cost pricing#236
arunSunnyKVS wants to merge 3 commits into
masterfrom
fix/cancellation-turn-granularity-and-extension-report-gaps

Conversation

@arunSunnyKVS

@arunSunnyKVS arunSunnyKVS commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

Four bugs, surfaced while testing opfor run with Ctrl+C, the extension's Stop button, and comparing reported cost against actual billing.

Cancellation (1)AbortSignal cancellation was only checked between attacks/evaluators, never between turns within one multi-turn attack. A long-running attack (e.g. turns: 100) was atomic — Ctrl+C wouldn't stop it until every turn finished, contradicting the "finishes in-flight attack" promise in the CLI's own interrupt message.

Extension reporting (2, 3) — a cancelled evaluator with zero completed turns rendered a fake "1 turn" with a blank transcript, because toResultViewModel hardcoded detail: { prompt: "", response: "" }. And cancelled runs showed no token usage or cost at all: DomTarget.send() throws an Error tagged code: "OPFOR_STOP" on user cancel, which runAllBrowser didn't recognise as a clean stop condition, so it fell through to an unhandled throw and returned no report — silently dropping the run's token totals. Separately, cost was never plumbed through the extension even on successful runs.

Cost accuracy (4) — cost estimation charged every input token at the full input rate. But inputTokens is the inclusive total: it already contains the cached tokens that providers bill far more cheaply. The token counter was also discarding the cache split the AI SDK already reports. On a real 24-request run this overstated cost by 22%.

Solution

  1. Threaded the existing AbortSignal one level deeper, into runAttack()'s per-turn loop, so it's checked before every turn and breaks out the same way the existing early-stop-on-target-error path does — still calling finalize() so a partial transcript is judged and reported.
  2. Fixed toResultViewModel to derive detail from the last completed turn, matching the contract core's own buildReport.ts already establishes for the CLI/SDK path.
  3. Added an OPFOR_STOP-tagged-error branch to runAllBrowser's catch block (recognised structurally via .code, since core can't import the extension's error class) so a user-cancelled run returns a proper partial report. Threaded tokenUsageByModel through orchestrator.js, aggregated it in popup.js via the already-bundled estimateRunCost, and wired the result into both the downloaded report and a new Cost stat card.
  4. Input is now divided across its cache tiers and each priced at its own published rate, reading the split from the AI SDK's provider-agnostic inputTokenDetails (so DeepSeek's prompt_tokens_details.cached_tokens and Anthropic's cache_read_input_tokens both work without provider-specific code).

Validation against real billing

The cost change was verified against LiteLLM's own spend logs for a 24-request run:

LiteLLM (actual billing) opfor report Delta
Requests 24 24 0
Input tokens 98,103 98,103 0
Cached tokens 29,696 29,696 0
Output tokens 32,269 32,269 0
Cost $0.057938723 $0.057938723 $0.000000000

Every one of the 24 requests agrees to 12 decimal places. The old formula reported $0.070749 for the same run — a 22.1% overstatement.

Changes

Cancellation

  • core/src/execute/attackRunner.tsrunAttack() accepts an optional signal, checked at the top of each turn
  • core/src/execute/runAgentLoop.ts, core/src/execute/mcpAttackDriver.ts — forward signal through to runAttack
  • core/src/execute/evaluatorLoop.ts — pass the already-available signal into both attack call sites

Extension reporting

  • core/src/execute/runAllBrowser.ts — recognise OPFOR_STOP-tagged errors as a clean stop condition, same as TargetStopError
  • runners/extension/popup.js — fix the detail fallback for zero-turn cancelled results; aggregate tokenUsageByModel and derive cost; populate the new Cost stat
  • runners/extension/popup.html — add a statCost card next to the existing statTokens card
  • runners/extension/orchestrator.js — thread tokenUsageByModel through all three result paths (success, error, stopped)

Cost pricing

  • core/src/execute/tokenTracker.ts — capture the cache split instead of discarding it; carry it to the per-model buckets
  • core/src/pricing/estimateCost.ts — price each input tier at its own rate
  • core/src/pricing/types.ts — the "Not applied yet" doc comments, now applied

Testscore/tests/attackRunner.test.ts, core/tests/pricing.test.ts, core/tests/tokenTracker.test.ts (18 new)

Backward compatibility

  • Reported fields are unchanged by the pricing work — only the cost figure moves.
  • A run whose provider reports no cache split prices exactly as before (cacheRead/cacheWrite fall to 0, noCache absorbs the total, and the formula collapses to the old one).
  • A tier with no published rate falls back to the full input rate rather than to free, preserving the module's existing never-quietly-free stance.
  • A published rate of 0 is honoured (?? not ||) — DeepSeek genuinely publishes cw: 0, and there's a test pinning this.

Issue

N/A

How to test

  1. npm run build
  2. CLI cancellation: run a target with turnMode: "multi" and a high turns count (e.g. 20+), Ctrl+C mid-attack, confirm it stops within one turn's latency instead of waiting for all turns.
  3. Extension: load the unpacked extension, start a run, click Stop mid-evaluator, download the report — confirm the cancelled evaluator shows no fake blank turn, and the sidepanel + downloaded report both show token/cost stats.
  4. Cost: run any multi-turn assessment against a caching provider and compare the reported "Testing cost" against your provider's billing for the same window.
  5. npm test — 294 tests pass (18 new).
  6. npm run typecheck / npm run lint — clean.

Screenshots

N/A

Summary by CodeRabbit

  • New Features

    • Added support for stopping in-progress evaluations while preserving partial results and usage data.
    • Added run cost estimates to reports and the completion screen.
    • Added model-level token usage details, including cached and uncached input breakdowns.
    • Improved cancelled-run reporting to avoid displaying fabricated empty results.
  • Bug Fixes

    • Stopped runs now display a clear stop status instead of an error.
    • Improved cost calculations for cached input tokens and provider-specific pricing.

The CLI's AbortSignal was only checked between attacks/evaluators, so a
long multi-turn attack (e.g. turns: 100) couldn't be interrupted until
every turn finished. The extension had a parallel gap: its own stop
signal wasn't recognized by runAllBrowser, so a cancelled run returned
no report at all — dropping token/cost data and leaving a fake blank
turn in the HTML report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 28336ef5-300e-4aa9-82c0-9eefc2bc2efb

📥 Commits

Reviewing files that changed from the base of the PR and between f0a92a1 and c481c02.

📒 Files selected for processing (2)
  • core/src/execute/tokenTracker.ts
  • core/tests/tokenTracker.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • core/tests/tokenTracker.test.ts
  • core/src/execute/tokenTracker.ts

Walkthrough

The change adds cancellation to attack execution, preserves partial stopped-run data, tracks cache-tier token usage, calculates tiered costs, and displays cost and final transcript details in the extension report.

Changes

Cancellation and cost reporting

Layer / File(s) Summary
Attack cancellation propagation
core/src/execute/attackRunner.ts, core/src/execute/mcpAttackDriver.ts, core/src/execute/runAgentLoop.ts, core/src/execute/evaluatorLoop.ts, core/tests/attackRunner.test.ts
Optional abort signals reach MCP and agent attacks. runAttack stops before the next turn and still finalizes. Tests cover pre-aborted, mid-run, and omitted signals.
Cache-aware token tracking and pricing
core/src/execute/tokenTracker.ts, core/src/pricing/estimateCost.ts, core/src/pricing/types.ts, core/tests/tokenTracker.test.ts, core/tests/pricing.test.ts
Token tracking separates uncached, cache-read, and cache-write input tokens. Cost estimation applies cache-tier prices and fallback rates. Tests cover parsing, propagation, balancing, zero rates, and fallback behavior.
Stopped-run handling and report presentation
core/src/execute/runAllBrowser.ts, runners/extension/orchestrator.js, runners/extension/popup.js, runners/extension/popup.html
OPFOR_STOP records the stop reason, emits progress, and preserves partial results and token usage. The popup aggregates usage, calculates cost, displays the Cost statistic, and uses the final transcript turn for result details.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Extension as Extension orchestrator
  participant Browser as runAllBrowser
  participant Evaluator as evaluatorLoop
  participant Runner as runAttack
  participant Tracker as TokenTracker
  participant Popup as Extension popup
  Extension->>Browser: request evaluator run
  Browser->>Evaluator: run evaluators with AbortSignal
  Evaluator->>Runner: start MCP or agent attack
  Runner-->>Evaluator: stop before next turn and finalize
  Evaluator->>Tracker: record token usage
  Browser-->>Extension: run_stopped with partial results
  Extension->>Popup: provide token usage and evaluator results
  Popup->>Popup: calculate and display estimated cost
Loading

Possibly related PRs

Suggested reviewers: jithin23-kv

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the PR's main changes: cancellation handling, extension reporting, and cache-aware cost pricing.
Description check ✅ Passed The description includes every required template section and provides detailed problem, solution, changes, issue, testing, and screenshot information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cancellation-turn-granularity-and-extension-report-gaps

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
runners/extension/popup.js (1)

1423-1443: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep last limited to turns with an assistant response.

turnsForReport() outputs a turn when either the user or assistant entry exists. If an interrupted evaluator leaves an in-flight user message in raw.transcript, last can come from a turn whose response is empty, and turns can include that incomplete entry. Filter turns with a non-empty assistant response before selecting last and before returning turns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runners/extension/popup.js` around lines 1423 - 1443, Update the turn
handling in turnsForReport to retain only turns with a non-empty assistant
response, then select last from that filtered collection and return it as turns.
Preserve the existing fallback to empty prompt and response when no completed
turns remain.
🧹 Nitpick comments (1)
runners/extension/popup.js (1)

14-15: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Import the cost helpers directly from their source module.

estimateRunCost and formatUsd are defined in core/src/pricing/estimateCost.ts, so import them from that file instead of re-exporting them through core/src/browser.ts and then bundling from ./dist/core.bundle.js.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@runners/extension/popup.js` around lines 14 - 15, Update the imports for
estimateRunCost and formatUsd in the popup module to reference their defining
source module directly, core/src/pricing/estimateCost.ts, instead of importing
through core/src/browser.ts or ./dist/core.bundle.js. Keep both helper usages
unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/execute/runAllBrowser.ts`:
- Around line 217-222: Update the extension orchestrator’s event handling to add
a run_stopped branch alongside the existing attack_start and attack_done
branches. Forward the stopped event by calling broadcastProgress with its
reason/state so the popup displays the partial run reason emitted by
runAllBrowser.

---

Outside diff comments:
In `@runners/extension/popup.js`:
- Around line 1423-1443: Update the turn handling in turnsForReport to retain
only turns with a non-empty assistant response, then select last from that
filtered collection and return it as turns. Preserve the existing fallback to
empty prompt and response when no completed turns remain.

---

Nitpick comments:
In `@runners/extension/popup.js`:
- Around line 14-15: Update the imports for estimateRunCost and formatUsd in the
popup module to reference their defining source module directly,
core/src/pricing/estimateCost.ts, instead of importing through
core/src/browser.ts or ./dist/core.bundle.js. Keep both helper usages unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e5ddf7a0-b139-4175-a656-5bff67a48eb7

📥 Commits

Reviewing files that changed from the base of the PR and between a67cca9 and b6362ed.

📒 Files selected for processing (9)
  • core/src/execute/attackRunner.ts
  • core/src/execute/evaluatorLoop.ts
  • core/src/execute/mcpAttackDriver.ts
  • core/src/execute/runAgentLoop.ts
  • core/src/execute/runAllBrowser.ts
  • core/tests/attackRunner.test.ts
  • runners/extension/orchestrator.js
  • runners/extension/popup.html
  • runners/extension/popup.js

Comment on lines +217 to +222
if ((err as { code?: string })?.code === "OPFOR_STOP") {
stopReason = err instanceof Error ? err.message : "Run stopped by user.";
notify({ type: "run_stopped", reason: stopReason });
pushPartialResult(stopReason);
break evaluatorLoop;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline runners/extension/orchestrator.js --items all
rg -n -C 6 'run_stopped|onProgress|broadcastProgress|setRunStatus|finalizeUserInterruption' \
  runners/extension/orchestrator.js

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 12727


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## runAllBrowser relevant section"
sed -n '180,235p' core/src/execute/runAllBrowser.ts

echo
echo "## extension run_stopped occurrences"
rg -n -C 8 'run_stopped|OPFOR_STOP|type === "attack_done"|emit\(|create.*Run|runAll\(' .

echo
echo "## executeAdaptiveRedTeamRun message send around stop"
sed -n '840,905p' runners/extension/orchestrator.js

Repository: KeyValueSoftwareSystems/agent-opfor

Length of output: 50394


Forward run_stopped from core to the extension popup.

runAllBrowser emits run_stopped for user-cancel/error paths, but runners/extension/orchestrator.js only calls broadcastProgress() for attack_start and attack_done. Add a matching branch and broadcast the stopped state so the extension UI can show the partial run reason.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@core/src/execute/runAllBrowser.ts` around lines 217 - 222, Update the
extension orchestrator’s event handling to add a run_stopped branch alongside
the existing attack_start and attack_done branches. Forward the stopped event by
calling broadcastProgress with its reason/state so the popup displays the
partial run reason emitted by runAllBrowser.

Cost estimation charged every input token at the full input rate, but
`inputTokens` is the inclusive total — it already contains the cached
tokens providers bill far more cheaply. The token counter was also
discarding the cache split the AI SDK already reports.

Input is now divided across its tiers and each priced at its own rate.
Validated against LiteLLM billing for a 24-request run: previously
reported $0.070749, actually billed $0.057939, now reports $0.057939 —
every request agreeing to 12 decimal places.

Reported fields are unchanged; only the cost figure moves. A run whose
provider reports no cache split prices exactly as before, and a tier with
no published rate falls back to the full input rate rather than to free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arunSunnyKVS arunSunnyKVS changed the title fix: honor cancellation mid-attack and fix extension report gaps on stop fix: cancellation turn-granularity, extension report gaps, and cache-aware cost pricing Aug 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@core/src/execute/tokenTracker.ts`:
- Around line 99-124: Update the token usage transform around the
inputTokenDetails schema and its noCache calculation to always derive noCache
from inputTokens minus cacheRead and cacheWrite, ignoring any provider-supplied
noCacheTokens override. Validate and reject inputs where cacheRead plus
cacheWrite exceeds inputTokens, and add regression tests covering both the
override case and the over-limit case.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b2540b77-5e97-4f72-bf8d-999e9b831573

📥 Commits

Reviewing files that changed from the base of the PR and between b6362ed and f0a92a1.

📒 Files selected for processing (5)
  • core/src/execute/tokenTracker.ts
  • core/src/pricing/estimateCost.ts
  • core/src/pricing/types.ts
  • core/tests/pricing.test.ts
  • core/tests/tokenTracker.test.ts

Comment thread core/src/execute/tokenTracker.ts
The transform read `noCacheTokens` verbatim, so a provider reporting
inputTokens 100 with noCache 100 and cacheRead 50 produced a 150-token
split against a 100-token call — and estimateRunCost billed all 150.
The invariant was asserted in the docs and tests but never enforced.

Derive the fresh count from inputTokens instead. For a well-formed
provider the two agree (the AI SDK builds inputTokens as the sum), so
this costs nothing and makes the invariant hold by construction. A split
claiming more cached tokens than there was input can't be divided at all,
so it is dropped and the call prices at the full input rate.

Addresses CodeRabbit review on #236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants