<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://pershukov.com">
  <title>Evgeny Pershukov — Blog</title>
  <subtitle>QA automation engineer with 8+ years of experience, now testing LLM agents, RAG pipelines, and MCP integrations.</subtitle>
  <link href="https://pershukov.com/feed.xml" rel="self"/>
  <link href="https://pershukov.com/blog/"/>
  <updated>2026-08-06T00:00:00Z</updated>
  <id>https://pershukov.com/blog/</id>
  <author>
    <name>Evgeny Pershukov</name>
  </author>
  <entry>
    <title>17 Bugs in My Backtest Engine (And What They Taught Me)</title>
    <link href="https://pershukov.com/blog/backtest-engine-audit-17-bugs/"/>
    <updated>2026-08-06T00:00:00Z</updated>
    <id>https://pershukov.com/blog/backtest-engine-audit-17-bugs/</id>
    <content type="html" xml:base="https://pershukov.com/blog/backtest-engine-audit-17-bugs/">
      &lt;p&gt;Every trading strategy I run starts with a backtest: a simulator that replays years of market candles through the strategy and reports what would have happened. The bot behind it is a real system — ~26,000 lines of strict TypeScript, 272 tests, an LLM verifier that vets trade signals, a risk manager with hard limits.&lt;/p&gt;
&lt;p&gt;Here is the uncomfortable part: the strategy code had tests, but the &lt;em&gt;simulator&lt;/em&gt; was mostly trusted. And the simulator is the oracle. If the backtest lies, every strategy tuned against it is optimized for a market that does not exist. So I treated the backtest engine the way I would treat any production system under test — two full adversarial audit rounds, followed by three parallel external reviews. Result: &lt;strong&gt;17 bugs found and fixed&lt;/strong&gt;, two of them critical.&lt;/p&gt;
&lt;p&gt;This post is a walkthrough of the most instructive ones, grouped by the failure pattern they represent — because the patterns generalize far beyond trading.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;the-setup&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#the-setup&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; The setup&lt;/h2&gt;
&lt;p&gt;The engine simulates an exchange: balances (&lt;code&gt;free&lt;/code&gt;/&lt;code&gt;locked&lt;/code&gt; per asset), limit and market orders, leverage with borrowed margin, funding payments, and liquidation. Three files carry the simulation: &lt;code&gt;BacktestExchange.ts&lt;/code&gt; (order execution and balances), &lt;code&gt;BacktestRunner.ts&lt;/code&gt; (the candle loop), &lt;code&gt;BacktestReport.ts&lt;/code&gt; (metrics).&lt;/p&gt;
&lt;p&gt;Audit one covered the main flows and fixed 8 bugs. Audit two was a deliberate second pass — same files, fresh eyes, looking for what the first pass missed. It found 9 more. That alone is lesson zero: &lt;strong&gt;the first audit of a complex stateful system never gets everything.&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;pattern-1%3A-state-that-outlives-itself&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-1%3A-state-that-outlives-itself&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 1: State that outlives itself&lt;/h2&gt;
&lt;p&gt;The liquidation handler had two bugs that compounded. First, it sold only &lt;code&gt;free&lt;/code&gt; base balance — and only &lt;em&gt;afterwards&lt;/em&gt; cancelled open orders, unlocking the rest. The unlocked base was never sold, leaving dead balance behind after a &amp;quot;full&amp;quot; liquidation. Second, and worse:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// after liquidation, none of these were reset:&lt;/span&gt;
positionQty      &lt;span class=&quot;token comment&quot;&gt;// funding kept being deducted&lt;/span&gt;
positionCost     &lt;span class=&quot;token comment&quot;&gt;// avgPrice = positionCost / positionQty → garbage&lt;/span&gt;
borrowedQuote    &lt;span class=&quot;token comment&quot;&gt;// net equity calculation → garbage&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A liquidation mid-backtest poisoned every subsequent candle. The engine did not crash — it just quietly produced wrong numbers. That is the defining trait of oracle bugs: &lt;strong&gt;nothing throws, the report just lies.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A variant of the same pattern lived on the strategy side. &lt;code&gt;executeDirectionalMode()&lt;/code&gt; recorded a new position in memory &lt;em&gt;before&lt;/em&gt; the order was confirmed. If the exchange rejected the order, the position still existed — a ghost. The position manager then generated close signals for it, producing spurious orders that burned capital on nothing. The fix was structural: positions are created only in &lt;code&gt;onOrderExecuted&lt;/code&gt;, on confirmed fill. Never record state for an action the world has not confirmed.&lt;/p&gt;
&lt;h2 id=&quot;pattern-2%3A-the-silent-clamp&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-2%3A-the-silent-clamp&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 2: The silent clamp&lt;/h2&gt;
&lt;p&gt;The most expensive habit in the codebase was &lt;code&gt;Math.max(0, ...)&lt;/code&gt; used as error handling:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// market sell with insufficient free balance:&lt;/span&gt;
bBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; bBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; qty&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// silently sells less than asked&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// funding deduction with empty free balance:&lt;/span&gt;
qBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; qBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; funding&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;token comment&quot;&gt;// funding silently vanishes&lt;/span&gt;

&lt;span class=&quot;token comment&quot;&gt;// fee deduction on a second fill in the same candle:&lt;/span&gt;
qBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; Math&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;max&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; qBal&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;free &lt;span class=&quot;token operator&quot;&gt;-&lt;/span&gt; fee&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;token comment&quot;&gt;// money from thin air&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each clamp turned an invariant violation into a slightly wrong number, with no signal to the caller. The strategy believed a position was closed; the balance said otherwise. A silent clamp is a swallowed exception with extra steps. The fix everywhere was the same: make the shortfall explicit — partial fill reported back to the caller, deduction taken from &lt;code&gt;locked&lt;/code&gt;, or an outright reject.&lt;/p&gt;
&lt;p&gt;This pattern is everywhere in application code, not just trading. Any place a value is forced into range &amp;quot;to be safe&amp;quot; is a place where a real problem becomes invisible.&lt;/p&gt;
&lt;h2 id=&quot;pattern-3%3A-accounting-that-double-counts-or-under-counts&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-3%3A-accounting-that-double-counts-or-under-counts&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 3: Accounting that double-counts or under-counts&lt;/h2&gt;
&lt;p&gt;Leverage broke equity math twice, in opposite directions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Under-counting:&lt;/strong&gt; equity was computed as &lt;code&gt;quote + base × price&lt;/code&gt;, ignoring the borrowed margin entirely. A $10,000 position with $5,000 borrowed reported $15,000 in equity — max drawdown looked smaller than it was.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Double-counting:&lt;/strong&gt; after the first fix, a second bug surfaced in the yearly runs. For market buys, margin was deducted from &lt;code&gt;free&lt;/code&gt; but never returned after fill (limit orders unlocked it; market orders did not), and then &lt;code&gt;getNetEquity()&lt;/code&gt; subtracted the same position cost &lt;em&gt;again&lt;/em&gt;. Every directional entry &amp;quot;burned&amp;quot; its notional value from reported equity.&lt;/p&gt;
&lt;p&gt;Together with the ghost-position bug, this produced my favorite number of the whole exercise: a backtest that started with &lt;strong&gt;$10,000 reported $795 in capital&lt;/strong&gt; for 2025. Two bugs, each survivable alone, interacting into something absurd. The absurdity was the gift — $795 is obviously wrong, which is the only reason anyone dug in. The dangerous bugs are the ones that move your result by 8%.&lt;/p&gt;
&lt;h2 id=&quot;pattern-4%3A-reused-evidence&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-4%3A-reused-evidence&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 4: Reused evidence&lt;/h2&gt;
&lt;p&gt;The P&amp;amp;L tracker matched sells to buys FIFO — but never marked buys as consumed. Sell 1 BTC against a 1 BTC buy, then sell another 0.5 BTC, and the engine happily matched it against the &lt;em&gt;same&lt;/em&gt; buy again. Every buy could be sold multiple times; realized P&amp;amp;L was fiction. The fix was one field: &lt;code&gt;consumedQty&lt;/code&gt; on each trade record.&lt;/p&gt;
&lt;p&gt;A related one: grid buys and DCA sells shared a single FIFO pool, so a DCA sell could be matched against a grid buy, producing meaningless per-strategy P&amp;amp;L. Matching needs to respect the boundaries of the thing being measured.&lt;/p&gt;
&lt;h2 id=&quot;pattern-5%3A-hardcoded-context&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-5%3A-hardcoded-context&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 5: Hardcoded context&lt;/h2&gt;
&lt;p&gt;Five bugs were the same bug: a constant baked in from whatever data the author had open that day.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Assumption&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Funding interval = 15m candles&lt;/td&gt;
&lt;td&gt;any interval&lt;/td&gt;
&lt;td&gt;funding scaled wrong on other timeframes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sharpe annualization = 1h candles&lt;/td&gt;
&lt;td&gt;15m data&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Sharpe inflated 2×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average hold time = 1h/candle&lt;/td&gt;
&lt;td&gt;15m data&lt;/td&gt;
&lt;td&gt;hold time inflated 4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base asset = &lt;code&gt;&#39;BTC&#39;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ETH, SOL, ETHBTC pairs exist&lt;/td&gt;
&lt;td&gt;final positions never closed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fee = flat 0.1%&lt;/td&gt;
&lt;td&gt;exchange charges 0.06% for BTC&lt;/td&gt;
&lt;td&gt;fees overstated 1.67×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The Sharpe one deserves emphasis: a headline quality metric, off by a factor of two, because of &lt;code&gt;Math.sqrt(365 * 24)&lt;/code&gt;. The fix in every case was to &lt;em&gt;derive&lt;/em&gt; the constant from the data — candle gap from timestamps, base asset from symbol info — not to pick a better constant.&lt;/p&gt;
&lt;h2 id=&quot;pattern-6%3A-what-the-external-reviewers-caught&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#pattern-6%3A-what-the-external-reviewers-caught&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pattern 6: What the external reviewers caught&lt;/h2&gt;
&lt;p&gt;After the two internal audits, I ran three parallel external reviews with different angles: order execution, financial realism, market microstructure. They found a different &lt;em&gt;class&lt;/em&gt; of problem — not broken code, but a broken model of reality:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Fee structure.&lt;/strong&gt; The reviewers assumed Binance-style maker/taker fees. Checking the actual exchange docs showed no maker/taker split at all in leverage mode — a flat 0.06%. Platform-specific research beats reasonable assumptions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Free leverage.&lt;/strong&gt; The default funding rate was &lt;code&gt;0&lt;/code&gt;, meaning borrowed margin cost nothing. At 3× leverage held for 30 days, real funding is roughly −8% of margin; the backtest showed 0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Look-ahead bias.&lt;/strong&gt; The strategy saw candle N&#39;s close and executed at candle N&#39;s close. Real strategies act on the &lt;em&gt;previous&lt;/em&gt; close and fill at an unknown next price. This systematically inflates win rate, and no unit test will ever catch it — it is a modeling error, not a code error.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-this-taught-me&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/backtest-engine-audit-17-bugs/#what-this-taught-me&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What this taught me&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Test the oracle before the system.&lt;/strong&gt; My 272 tests validated the bot against the simulator; almost nothing validated the simulator against reality. Any system whose output drives decisions — a backtest, an eval harness, a metrics pipeline — deserves its own adversarial pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absurd numbers are leads, not noise.&lt;/strong&gt; $795 from $10,000 was a bug report written by the data itself. Build the habit of chasing outputs that look wrong instead of rationalizing them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Adversarial review finds what authorship cannot.&lt;/strong&gt; I wrote the engine; my first audit found the bugs I was capable of seeing. The second pass found 9 more, and the external reviewers found the assumptions I did not know I was making. This is the same reason LLM-as-judge evals use a different model than the one being judged.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The same patterns apply to AI systems.&lt;/strong&gt; Silent clamps = guardrails that swallow failures. Ghost state = tool calls assumed successful before confirmation. Hardcoded context = eval thresholds copied from someone else&#39;s benchmark. Reused evidence = test cases leaked into training prompts. The domain changes; the failure patterns do not.&lt;/p&gt;
&lt;div class=&quot;key-takeaways&quot;&gt;
&lt;p class=&quot;key-takeaways-title&quot;&gt;Key takeaways&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The component that produces your metrics is itself a system under test — audit it with the same rigor as the product.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Math.max(0, ...)&lt;/code&gt;-style clamps turn invariant violations into silent lies. Make shortfalls explicit.&lt;/li&gt;
&lt;li&gt;Derive constants from data; never hardcode timeframe, symbol, or fee assumptions.&lt;/li&gt;
&lt;li&gt;Two minor bugs can interact into an absurd result — and the absurdity is what makes them findable. Chase weird numbers.&lt;/li&gt;
&lt;li&gt;One audit pass is never enough for stateful systems; fresh-eyes reviews catch a different class of bugs than code tests do.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;

    </content>
  </entry>
  <entry>
    <title>Context Packs for AI Coding Agents: Building taskctx</title>
    <link href="https://pershukov.com/blog/context-packs-for-ai-coding-agents/"/>
    <updated>2026-08-04T00:00:00Z</updated>
    <id>https://pershukov.com/blog/context-packs-for-ai-coding-agents/</id>
    <content type="html" xml:base="https://pershukov.com/blog/context-packs-for-ai-coding-agents/">
      &lt;p&gt;Every AI coding session starts with amnesia. The agent does not know what issue #123 is about, what was decided in its comments, which PRs are open against it, or what changed in the feature branch yesterday. For a real task in a multi-repo product, that context lives in at least four places: the issue tracker, pull requests, git branches, and the working copies themselves.&lt;/p&gt;
&lt;p&gt;The usual workaround is copy-pasting fragments into the prompt until the agent stops asking questions. It works, but it is slow, lossy, and different every time. I got tired of doing it and built &lt;strong&gt;taskctx&lt;/strong&gt; — a small CLI that assembles the whole context of a task into one deterministic Markdown file. This post covers what goes into a pack and the design decisions that make it work in daily agent-driven development.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;the-idea%3A-a-context-pack%2C-not-a-prompt&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#the-idea%3A-a-context-pack%2C-not-a-prompt&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; The idea: a context pack, not a prompt&lt;/h2&gt;
&lt;p&gt;A context pack is a snapshot of everything an agent needs to start working on an issue, rendered as a single &lt;code&gt;context-pack.md&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;taskctx collect &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;# → .taskctx/123/context-pack.md&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The agent does not get a giant prompt. It gets a file with stable paths to everything else — full per-commit diffs, raw GitHub JSON, the repository map — so it can read deeper only where needed. Context assembly stops being part of the prompt and becomes part of the workspace.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-goes-into-a-pack&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#what-goes-into-a-pack&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What goes into a pack&lt;/h2&gt;
&lt;h3 id=&quot;1.-delta-first&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#1.-delta-first&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 1. Delta first&lt;/h3&gt;
&lt;p&gt;The pack opens with what changed since the last collect, because that is what a returning session actually needs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- api: 3 new commits (a1b2c3d..e4f5g6h)
- web: no new commits
- web#8636 state: open → merged
- issue: +2 comments
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;New commits are computed against the previous manifest, PR changes are rendered as ready-made transition lines, and the comment delta is a simple count. An agent that worked on the task yesterday reads five lines and is caught up.&lt;/p&gt;
&lt;h3 id=&quot;2.-issue-snapshot&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#2.-issue-snapshot&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 2. Issue snapshot&lt;/h3&gt;
&lt;p&gt;Title, state, labels, body, and comments — the raw material. On top of that, one extraction proved surprisingly valuable: &lt;strong&gt;open checkboxes&lt;/strong&gt;. The renderer scans the issue body and every comment for &lt;code&gt;- [ ]&lt;/code&gt; items and lists them with their source:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- [ ] Verify timer state after re-login _(issue body)_
- [ ] Add retry backoff test _(@reviewer 2026-07-30)_
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Checkbox items are usually the actual remaining work, scattered across a long thread. Collected in one place, they become the agent&#39;s implicit todo list.&lt;/p&gt;
&lt;h3 id=&quot;3.-repository-map-and-branch-state&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#3.-repository-map-and-branch-state&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 3. Repository map and branch state&lt;/h3&gt;
&lt;p&gt;The config describes the workspace: each repo&#39;s local path (or &lt;code&gt;null&lt;/code&gt; for remote-only), its GitHub slug, base branch, and branch naming pattern like &lt;code&gt;issue-{N}&lt;/code&gt;. For every repo the pack shows the matched branch, the merge-base with the base branch, and the commit list with file statistics — additions and deletions per file, aggregated over the branch.&lt;/p&gt;
&lt;p&gt;Full per-commit diffs are cached to disk during collect; the pack only carries their paths. The agent opens a diff when it needs one instead of paying for every diff in tokens up front.&lt;/p&gt;
&lt;h3 id=&quot;4.-pr-states&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#4.-pr-states&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 4. PR states&lt;/h3&gt;
&lt;p&gt;For each linked PR: number, title, state, merged flag, review decision, and URL. Enough for the agent to know whether its previous work is under review, merged, or sent back.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;design-decisions&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#design-decisions&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Design decisions&lt;/h2&gt;
&lt;h3 id=&quot;cache-everything%2C-render-deterministically&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#cache-everything%2C-render-deterministically&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Cache everything, render deterministically&lt;/h3&gt;
&lt;p&gt;Collect writes raw GitHub JSON, a manifest, and per-commit diffs into a &lt;code&gt;.taskctx/&amp;lt;N&amp;gt;/&lt;/code&gt; store. Rendering reads only the store:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;taskctx pack 123&lt;/code&gt; re-renders with zero network access — on a plane, in CI, or when the API rate limit says no.&lt;/li&gt;
&lt;li&gt;The same store renders the same pack, byte for byte. Packs are diffable and safe to commit, and a changed pack is a signal, not noise.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--offline&lt;/code&gt; goes further and recomputes from local git refs only, without even &lt;code&gt;gh&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;delta-over-snapshot&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#delta-over-snapshot&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Delta over snapshot&lt;/h3&gt;
&lt;p&gt;A pack that only describes current state forces the agent (and the human) to diff mentally against yesterday. Keeping the previous manifest makes the delta computable, and computing it makes the top section of the pack the most valuable one. This mirrors how I think about test reports: &amp;quot;what changed&amp;quot; beats &amp;quot;what is&amp;quot;.&lt;/p&gt;
&lt;h3 id=&quot;instruction-file-over-convention&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#instruction-file-over-convention&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Instruction file over convention&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;taskctx init&lt;/code&gt; writes an &lt;code&gt;instruction.md&lt;/code&gt; telling any agent how to consume packs: read the pack first, use the diff directory for details, search the working copies via the repo map, never re-run git diffs by hand. Any agent that picks up the repo inherits the workflow — no tribal knowledge in my head or in a chat history.&lt;/p&gt;
&lt;h3 id=&quot;graceful-degradation-per-repo&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#graceful-degradation-per-repo&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Graceful degradation per repo&lt;/h3&gt;
&lt;p&gt;Branches get deleted after merges; repos are not always checked out. Each repo entry in the pack is independent: a missing branch says &amp;quot;branch not found&amp;quot;, a deleted one says &amp;quot;branch gone&amp;quot;, a remote-only repo works through the API alone. One broken piece never fails the whole pack — same principle as &lt;a href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/&quot;&gt;classifying failures in the monitoring watchdog&lt;/a&gt;, applied to context collection.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;implementation-notes&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#implementation-notes&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Implementation notes&lt;/h2&gt;
&lt;p&gt;The tool is about 1,300 lines of TypeScript running on Bun, with Vitest covering the four risky seams: collection, git plumbing, GitHub API parsing, and pack rendering. The renderer is a pure function over the store — no I/O — which makes it trivial to test with fixtures and keeps the determinism promise enforceable.&lt;/p&gt;
&lt;p&gt;The commands:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;taskctx init                 &lt;span class=&quot;token comment&quot;&gt;# seed config + agent instruction.md&lt;/span&gt;
taskctx collect &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt;          &lt;span class=&quot;token comment&quot;&gt;# fetch + render the pack&lt;/span&gt;
taskctx pack &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt;             &lt;span class=&quot;token comment&quot;&gt;# re-render from cache&lt;/span&gt;
taskctx status &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt;           &lt;span class=&quot;token comment&quot;&gt;# delta only, to stdout&lt;/span&gt;
taskctx collect &lt;span class=&quot;token number&quot;&gt;123&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;--repo&lt;/span&gt; web &lt;span class=&quot;token parameter variable&quot;&gt;--offline&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-changed-in-practice&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/context-packs-for-ai-coding-agents/#what-changed-in-practice&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What changed in practice&lt;/h2&gt;
&lt;p&gt;Session startup went from &amp;quot;ten minutes of copy-paste and one thing always forgotten&amp;quot; to one command and a file link. The unexpected win was continuity: because packs are deterministic and delta-first, an agent resuming a task after three days behaves like a colleague who read the thread, not like a new hire. And as a QA habit, I now treat stale context as a test failure — if the pack is old, you refresh it before trusting anything the agent says about the task.&lt;/p&gt;
&lt;div class=&quot;key-takeaways&quot;&gt;
&lt;p class=&quot;key-takeaways-title&quot;&gt;Key takeaways&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every agent session starts with amnesia — context assembly should be a tool, not a prompt ritual.&lt;/li&gt;
&lt;li&gt;Render the delta first: a returning session needs &quot;what changed,&quot; not the whole history.&lt;/li&gt;
&lt;li&gt;Cache everything and keep rendering deterministic, so packs are diffable and safe to commit.&lt;/li&gt;
&lt;li&gt;Integrate via an instruction file any agent can read — no platform-specific plugin API required.&lt;/li&gt;
&lt;li&gt;Degrade gracefully per repo: one missing branch should never fail the whole pack.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;Project page: &lt;a href=&quot;https://pershukov.com/projects/taskctx/&quot;&gt;taskctx — Task Context Packs for LLM Sessions&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

    </content>
  </entry>
  <entry>
    <title>Monitoring a Chrome Extension in Production: Building a 24/7 Playwright Watchdog</title>
    <link href="https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/"/>
    <updated>2026-07-29T00:00:00Z</updated>
    <id>https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/</id>
    <content type="html" xml:base="https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/">
      &lt;p&gt;Browser extensions live inside someone else&#39;s DOM. Your extension can be perfectly engineered, fully covered by tests, and still break in production because the host app shipped a redesign overnight and renamed the CSS class your content script was anchoring to. No unit test catches that. No CI run against a saved fixture catches that. The only way to know is to open the real product, with the real extension, as a real user — and look.&lt;/p&gt;
&lt;p&gt;This post walks through a monitoring system I built for exactly that problem: a 24/7 watchdog that loads the Everhour Chrome extension into Chromium, opens Asana pages every 10 minutes, verifies that 20+ injected UI controls are rendered, and sends a Slack alert with a pixel-diff screenshot when something disappears. The whole thing runs in Docker with no monitor attached.&lt;/p&gt;
&lt;p&gt;The interesting parts are not the selectors. They are the reliability engineering around the checks: headed Chromium in a container, retries driven by error classification, a watchdog that watches the watcher, and an alerting pipeline designed to not cry wolf.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;why-headed-chromium%2C-and-why-xvfb&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#why-headed-chromium%2C-and-why-xvfb&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Why headed Chromium, and why Xvfb&lt;/h2&gt;
&lt;p&gt;Chrome extensions only load in a &lt;strong&gt;persistent context&lt;/strong&gt; — &lt;code&gt;browser.newContext()&lt;/code&gt; will not cut it. And while Chromium&#39;s newer headless mode has made progress with extensions, the battle-tested route for a 24/7 system is still a headed browser: &lt;code&gt;--load-extension&lt;/code&gt; behaves predictably, extension service workers start the same way they do for real users, and the page&#39;s anti-automation heuristics treat the session as normal.&lt;/p&gt;
&lt;p&gt;A headed browser needs a display. In Docker there is none, so you give it a virtual one:&lt;/p&gt;
&lt;pre class=&quot;language-dockerfile&quot;&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;# entrypoint: start virtual display, then the app&lt;/span&gt;
Xvfb :99 -screen 0 1920x1080x24 &amp;amp;
export DISPLAY=:99
node dist/main.js&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The browser itself is launched once and kept alive between check cycles:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; context &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; chromium&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;launchPersistentContext&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;userDataDir&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  headless&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token boolean&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  args&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;
    &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;--load-extension=&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;extensionPath&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;--disable-extensions-except=&lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;extensionPath&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two practical consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The profile persists.&lt;/strong&gt; Login sessions survive restarts because &lt;code&gt;userDataDir&lt;/code&gt; is a mounted volume. First run on an empty profile triggers an auto-login flow (including TOTP-based 2FA via &lt;code&gt;otplib&lt;/code&gt;); after that, cookies do the work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Profiles are platform-specific.&lt;/strong&gt; A profile created on a Windows dev machine will not work inside a Linux container. Don&#39;t fight this — let auto-login mint a fresh profile per environment.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;checks-as-configuration%2C-not-code&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#checks-as-configuration%2C-not-code&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Checks as configuration, not code&lt;/h2&gt;
&lt;p&gt;Each monitored page is declared in YAML: a URL, a list of selectors with descriptions, and an optional &lt;code&gt;chainAfter&lt;/code&gt; edge. Per-integration files (&lt;code&gt;config/asana.yaml&lt;/code&gt;, &lt;code&gt;config/everhour.yaml&lt;/code&gt;) are merged at startup and validated for id uniqueness:&lt;/p&gt;
&lt;pre class=&quot;language-yaml&quot;&gt;&lt;code class=&quot;language-yaml&quot;&gt;&lt;span class=&quot;token key atrule&quot;&gt;checks&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;id&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; asana&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;board&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;view&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;everhour
    &lt;span class=&quot;token key atrule&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Board view&quot;&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;group&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; Asana
    &lt;span class=&quot;token key atrule&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; https&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;//app.asana.com/1/&lt;span class=&quot;token punctuation&quot;&gt;...&lt;/span&gt;/board/&lt;span class=&quot;token punctuation&quot;&gt;...&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;chainAfter&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; asana&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;list&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;view&lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt;everhour
    &lt;span class=&quot;token key atrule&quot;&gt;waitAfterNavigationMs&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;3000&lt;/span&gt;
    &lt;span class=&quot;token key atrule&quot;&gt;selectors&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;selector&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;.EverhourTimerButton&quot;&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Timer button on card&quot;&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;minCount&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;1&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;token key atrule&quot;&gt;selector&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;text/Estimated time&quot;&lt;/span&gt;
        &lt;span class=&quot;token key atrule&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Estimate badge&quot;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;chainAfter&lt;/code&gt; exists because every check runs on &lt;strong&gt;one shared tab&lt;/strong&gt;. Parallel navigation on a single &lt;code&gt;page&lt;/code&gt; is a URL race; a sequential chain turns the whole cycle into a deterministic walk: extension popup → list view → board view → task details → home → my tasks. A failing check never blocks the chain — its dependents still run.&lt;/p&gt;
&lt;p&gt;The check itself is dumb on purpose: navigate, wait, assert each selector exists (respecting &lt;code&gt;minCount&lt;/code&gt;), record PASS/FAIL per control. All the intelligence lives in what happens &lt;em&gt;around&lt;/em&gt; it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;error-taxonomy-drives-retry-policy&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#error-taxonomy-drives-retry-policy&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Error taxonomy drives retry policy&lt;/h2&gt;
&lt;p&gt;The single most important design decision: &lt;strong&gt;not every failure is retried the same way&lt;/strong&gt;. Every exception is classified into one of seven types, and the type determines the recovery strategy:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error type&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ElementNotFound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Selector missing&lt;/td&gt;
&lt;td&gt;Retry (up to 3 attempts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NetworkError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Navigation/request failure&lt;/td&gt;
&lt;td&gt;Retry with exponential backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Timeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page or selector timeout&lt;/td&gt;
&lt;td&gt;Retry with exponential backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ExtensionFailure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Extension controls not injected&lt;/td&gt;
&lt;td&gt;Retry after extension health check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionLost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auth session expired&lt;/td&gt;
&lt;td&gt;Re-login, retry — &lt;strong&gt;attempt not consumed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BrowserCrash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Chromium process died&lt;/td&gt;
&lt;td&gt;Restart browser, &lt;strong&gt;no retry&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Unknown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anything else&lt;/td&gt;
&lt;td&gt;Retry conservatively&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two rules deserve emphasis.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SessionLost does not consume a retry attempt.&lt;/strong&gt; An expired session is not a check failure — it is an environmental condition. The retry loop calls an &lt;code&gt;onSessionLost&lt;/code&gt; callback (which re-authenticates), then tries again on the same attempt number. A separate counter caps this at 3 re-logins per check so a broken login flow cannot spin forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;BrowserCrash gets no retry.&lt;/strong&gt; Retrying against a dead browser process is meaningless; the only correct response is a full browser restart, after which the next cycle proceeds normally.&lt;/p&gt;
&lt;p&gt;The retry budget itself is small — 3 attempts, 30-second base delay, doubling per attempt for network-class errors. A check that recovers on attempt 2 or 3 is logged as &lt;em&gt;flaky&lt;/em&gt;, which feeds into alerting (more on that below):&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;let&lt;/span&gt; attempt &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; attempt &lt;span class=&quot;token operator&quot;&gt;&amp;lt;=&lt;/span&gt; cfg&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;maxAttempts&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; attempt&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;try&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; result &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;runCheck&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;page&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; check&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;result &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;PASS&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;attempt &lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; logger&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;warn&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token template-string&quot;&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;Check &lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;check&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt; recovered after &lt;/span&gt;&lt;span class=&quot;token interpolation&quot;&gt;&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;${&lt;/span&gt;attempt&lt;span class=&quot;token interpolation-punctuation punctuation&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt; retry(s) (flaky)&lt;/span&gt;&lt;span class=&quot;token template-punctuation string&quot;&gt;`&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; retryCount&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; attempt&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;/* ... */&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token comment&quot;&gt;// FAIL: fall through to retry logic&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;catch&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err &lt;span class=&quot;token keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;MonitorError&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; err&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;type &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;SessionLost&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;++&lt;/span&gt;sessionLostCount &lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt; &lt;span class=&quot;token constant&quot;&gt;SESSION_LOST_MAX_RETRIES&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;throw&lt;/span&gt; err&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;onSessionLost&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;          &lt;span class=&quot;token comment&quot;&gt;// re-login&lt;/span&gt;
      &lt;span class=&quot;token keyword&quot;&gt;continue&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;                        &lt;span class=&quot;token comment&quot;&gt;// same attempt number&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;err &lt;span class=&quot;token keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;MonitorError&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; err&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;type &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;BrowserCrash&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;throw&lt;/span&gt; err&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;token comment&quot;&gt;// no retry&lt;/span&gt;
    &lt;span class=&quot;token comment&quot;&gt;// ...&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;sleep&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;calculateDelay&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;baseDelaySec&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; exponential&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; attempt&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is also a &lt;strong&gt;proactive&lt;/strong&gt; layer: every 5 minutes, registered auth providers check &lt;code&gt;isAuthenticated()&lt;/code&gt; and re-login &lt;em&gt;before&lt;/em&gt; the next check cycle if the session expired. Most session losses are healed before any check notices.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;who-watches-the-watcher&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#who-watches-the-watcher&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Who watches the watcher&lt;/h2&gt;
&lt;p&gt;A monitoring system that hangs silently is worse than no monitoring — it gives you false confidence. Three mechanisms keep the monitor itself honest:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Heartbeat + watchdog process.&lt;/strong&gt; The main process rewrites &lt;code&gt;heartbeat.json&lt;/code&gt; after every cycle. A separate, deliberately tiny watchdog process reads it every 60 seconds; if the heartbeat is older than 90 seconds, it kills and respawns the main process. The watchdog has no browser, no network, no dependencies — it is too simple to hang the same way the main process can.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory guard.&lt;/strong&gt; Long-lived Chromium leaks. The monitor polls the browser&#39;s RSS (&lt;code&gt;tasklist&lt;/code&gt; on Windows, &lt;code&gt;ps&lt;/code&gt; on Linux) and restarts Chrome when it crosses a configurable limit (2 GB by default).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scheduled daily restart.&lt;/strong&gt; Even below the memory limit, the browser gets a clean restart every night at 03:00. Extensions are re-injected via &lt;code&gt;--load-extension&lt;/code&gt; on every launch, so a restart also picks up any extension files updated on the mounted volume.&lt;/p&gt;
&lt;p&gt;The combination means the system recovers from hangs, leaks, and crashes without human involvement — and every recovery path is exercised regularly enough that you trust it when it matters.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;alerting-without-alert-fatigue&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#alerting-without-alert-fatigue&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Alerting without alert fatigue&lt;/h2&gt;
&lt;p&gt;A monitor that pages you for every transient blip gets muted within a week. The alerting pipeline has three filters:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flaky ≠ broken.&lt;/strong&gt; A check that failed but recovered on retry is recorded as FLAKY in the dashboard but does not alert. Only &lt;em&gt;stable failures&lt;/em&gt; — still failing after the full retry budget — trigger Slack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deduplication with a signature.&lt;/strong&gt; Repeat alerts for the same failure are suppressed for 30 minutes. The dedup key is not just the check id: it is a signature encoding &lt;em&gt;which&lt;/em&gt; checks failed and &lt;em&gt;how badly&lt;/em&gt; (&lt;code&gt;:crit&lt;/code&gt; for browser crashes, &lt;code&gt;:skip&lt;/code&gt; for auth-skipped). If the failure picture changes — a second page type starts failing, or a FAIL escalates to a crash — the signature changes and the alert goes through immediately, because this is genuinely new information.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Quiet hours with a digest, not a black hole.&lt;/strong&gt; Between 21:00 and 09:00 (configured timezone), Slack alerts are suppressed — but written to an &lt;code&gt;alert_queue&lt;/code&gt; table in SQLite. In the morning you get a digest of everything that happened overnight. Suppression never means loss.&lt;/p&gt;
&lt;p&gt;Every alert carries the failure context: which controls are missing, the error details, and — the best part — screenshots.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;pixel-diff-screenshots%3A-show%2C-don&#39;t-tell&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#pixel-diff-screenshots%3A-show%2C-don&#39;t-tell&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Pixel-diff screenshots: show, don&#39;t tell&lt;/h2&gt;
&lt;p&gt;A text alert saying &amp;quot;Timer button not found on Board view&amp;quot; forces you to open the app and hunt. A screenshot shows you instantly. The system goes one step further and borrows from visual regression testing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;On PASS&lt;/strong&gt;, the highlighted screenshot is written to &lt;code&gt;{checkId}-baseline.png&lt;/code&gt;. Every green run refreshes the &lt;em&gt;last-known-good&lt;/em&gt; reference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On FAIL&lt;/strong&gt;, the screenshot is written to a timestamped file, then pixel-diffed against the baseline using &lt;code&gt;pixelmatch&lt;/code&gt;. Changed pixels are highlighted into &lt;code&gt;{checkId}-diff.png&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token comment&quot;&gt;// PASS → shot becomes the new last-known-good baseline&lt;/span&gt;
&lt;span class=&quot;token comment&quot;&gt;// FAIL → timestamped live shot + pixelmatch diff vs baseline&lt;/span&gt;
&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; sc &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;takeHighlightedScreenshot&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;page&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; check&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;id&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; controls&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; mode&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Slack alert then carries three links: 📸 live screenshot, 📋 baseline, 🔍 diff. When Asana ships a UI update and half the controls vanish, the diff shows you the redesigned toolbar in one glance — no dashboard visit required. When the diff is empty but selectors fail, you know the extension stopped injecting rather than the page changing. That distinction alone cuts triage time from minutes to seconds.&lt;/p&gt;
&lt;p&gt;Everything is persisted in SQLite (&lt;code&gt;check_history&lt;/code&gt; with per-control JSON detail, screenshot paths, retry counts, durations), with 30-day retention for detail rows and 90-day aggregates for trend reporting.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;lessons-from-running-it-24%2F7&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#lessons-from-running-it-24%2F7&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Lessons from running it 24/7&lt;/h2&gt;
&lt;p&gt;A few things only production time teaches you:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Monitoring code is production code.&lt;/strong&gt; It needs its own reliability engineering — watchdogs, rotation, retention, restarts. Budget for it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Classify before you retry.&lt;/strong&gt; A single &amp;quot;retry 3 times on error&amp;quot; policy would have masked session expirations and hammered a dead browser. The taxonomy is the feature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ERR_ABORTED&lt;/code&gt; is not an error.&lt;/strong&gt; Single-page apps abort navigations during redirects all the time. A &lt;code&gt;safeGoto()&lt;/code&gt; wrapper that swallows this specific case eliminates an entire class of false alarms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extension service workers are flaky to detect.&lt;/strong&gt; &lt;code&gt;waitForEvent(&#39;serviceworker&#39;)&lt;/code&gt; occasionally times out while content scripts work perfectly. Don&#39;t gate checks on the SW; gate them on what the user sees.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flaky tracking pays off.&lt;/strong&gt; The retry-recovery log turned out to be an early-warning system: a control that goes flaky before going stable-fail is usually a host app A/B test rolling out.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give alerts a digest mode.&lt;/strong&gt; Quiet hours that &lt;em&gt;queue&lt;/em&gt; instead of drop keep nights silent and mornings informative.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-to-do-next&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/monitoring-chrome-extension-playwright-watchdog/#what-to-do-next&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What to do next&lt;/h2&gt;
&lt;p&gt;If you maintain a browser extension, an integration, or any UI that renders inside a third-party product:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pick the 3–5 user-facing controls that define &amp;quot;the integration works.&amp;quot;&lt;/li&gt;
&lt;li&gt;Write one YAML-style check per page type with those selectors.&lt;/li&gt;
&lt;li&gt;Classify your failures before writing a single retry.&lt;/li&gt;
&lt;li&gt;Add a screenshot pipeline — first for failures, then a baseline/diff pair.&lt;/li&gt;
&lt;li&gt;Put a heartbeat on the monitor itself from day one.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The full system — checker, retry engine, Slack alerts, dashboard, watchdog, Docker setup — is TypeScript with Playwright, Express, and SQLite, and the architecture is integration-agnostic: adding Jira or Trello monitoring means writing one auth provider and one YAML file. Source: &lt;a href=&quot;https://github.com/evgeny-pershukov/ext-monitoring&quot;&gt;github.com/evgeny-pershukov/ext-monitoring&lt;/a&gt;.&lt;/p&gt;
&lt;div class=&quot;key-takeaways&quot;&gt;
&lt;p class=&quot;key-takeaways-title&quot;&gt;Key takeaways&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Monitoring code is production code: it needs its own watchdog, memory guard, rotation, and retention budget.&lt;/li&gt;
&lt;li&gt;Classify failures before writing retries — one &quot;retry 3 times&quot; policy masks session expirations and pages you at 3 AM for nothing.&lt;/li&gt;
&lt;li&gt;Headed Chromium under Xvfb in Docker renders extensions exactly like a user machine; headless does not.&lt;/li&gt;
&lt;li&gt;Pixel-diff screenshots in alerts cut triage from minutes to seconds: the diff shows what broke without a dashboard visit.&lt;/li&gt;
&lt;li&gt;Flaky-before-fail is an early-warning signal — track recoveries, not just failures.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;Written by Evgeny Pershukov. Follow me on &lt;a href=&quot;https://www.linkedin.com/in/evgeny-pershukov/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://github.com/evgeny-pershukov&quot;&gt;GitHub&lt;/a&gt; for more notes on AI Test Engineering.&lt;/em&gt;&lt;/p&gt;

    </content>
  </entry>
  <entry>
    <title>Testing AI Agents: A Practical Guide</title>
    <link href="https://pershukov.com/blog/testing-ai-agents-practical-guide/"/>
    <updated>2026-07-16T00:00:00Z</updated>
    <id>https://pershukov.com/blog/testing-ai-agents-practical-guide/</id>
    <content type="html" xml:base="https://pershukov.com/blog/testing-ai-agents-practical-guide/">
      &lt;p&gt;Traditional QA treats a system as a black box with deterministic inputs and expected outputs. Click button A, expect result B. That model still applies to the &lt;em&gt;tools&lt;/em&gt; an agent calls, but it breaks at the &lt;em&gt;agent&lt;/em&gt; layer because LLMs are stochastic, context-dependent, and capable of multi-step reasoning.&lt;/p&gt;
&lt;p&gt;This post is a practical guide for QA engineers who want to move from testing deterministic automation to testing LLM agents. It covers the layers you need to test, a minimal eval harness you can build today, and the metrics that actually matter when an agent goes live.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-makes-an-agent-different-from-a-traditional-app%3F&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#what-makes-an-agent-different-from-a-traditional-app%3F&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What makes an agent different from a traditional app?&lt;/h2&gt;
&lt;p&gt;In a classic web app, the state is mostly under your control: DOM, API responses, database rows. An LLM agent adds an unpredictable reasoning layer on top. It may:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reinterpret instructions based on context&lt;/li&gt;
&lt;li&gt;Choose different tools in different orders&lt;/li&gt;
&lt;li&gt;Fail gracefully, or appear to succeed while doing the wrong thing&lt;/li&gt;
&lt;li&gt;Hallucinate data that never existed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means the test pyramid needs to be extended. You cannot just assert on the final output; you also need to assert on the &lt;em&gt;process&lt;/em&gt; that produced it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;the-three-layers-of-agent-testing&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#the-three-layers-of-agent-testing&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; The three layers of agent testing&lt;/h2&gt;
&lt;p&gt;Think of an agent as a stack. You can test each layer independently and combine them into integration checks.&lt;/p&gt;
&lt;h3 id=&quot;1.-tool-call-layer&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#1.-tool-call-layer&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 1. Tool-call layer&lt;/h3&gt;
&lt;p&gt;At the bottom, the agent calls tools: APIs, databases, file systems, browsers. Each tool should be tested like a normal unit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Input schema validation&lt;/li&gt;
&lt;li&gt;Output schema validation&lt;/li&gt;
&lt;li&gt;Error handling for malformed or empty inputs&lt;/li&gt;
&lt;li&gt;Side effects (was the database actually updated?)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the closest to traditional QA. Use the same frameworks you already know: Playwright for browser tools, Jest/Mocha for API wrappers, and &lt;code&gt;zod&lt;/code&gt; for schema validation.&lt;/p&gt;
&lt;h3 id=&quot;2.-orchestration-layer&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#2.-orchestration-layer&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 2. Orchestration layer&lt;/h3&gt;
&lt;p&gt;The orchestration layer decides &lt;em&gt;which&lt;/em&gt; tool to call and &lt;em&gt;when&lt;/em&gt;. This is where the LLM starts to matter. The questions you want to answer are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Given a user request, does the agent pick the right tool?&lt;/li&gt;
&lt;li&gt;Does it pass the right arguments?&lt;/li&gt;
&lt;li&gt;Does it recover from a failed tool call?&lt;/li&gt;
&lt;li&gt;Does it stop when it has enough information, or does it loop?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You cannot test this with hard assertions because the same request can yield different valid paths. Instead, you use &lt;em&gt;evaluations&lt;/em&gt; over a set of sample tasks.&lt;/p&gt;
&lt;h3 id=&quot;3.-task-success-layer&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#3.-task-success-layer&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; 3. Task-success layer&lt;/h3&gt;
&lt;p&gt;At the top, the only thing the user cares about: did the agent complete the task correctly? This is evaluated against a ground-truth reference set, but &amp;quot;correct&amp;quot; may include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Functional correctness (the right answer)&lt;/li&gt;
&lt;li&gt;Safety constraints (no PII leaked, no harmful actions)&lt;/li&gt;
&lt;li&gt;Latency and cost (did it take 20 calls when 2 would do?)&lt;/li&gt;
&lt;li&gt;Tone and format constraints&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;build-a-minimal-eval-harness-in-typescript&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#build-a-minimal-eval-harness-in-typescript&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Build a minimal eval harness in TypeScript&lt;/h2&gt;
&lt;p&gt;Here is a tiny harness you can adapt for your own agent. It runs a set of test cases, each with a user prompt and expected criteria, then scores the result.&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;EvalCase&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  name&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  prompt&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  expectedTool&lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  expectedArgs&lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Record&lt;span class=&quot;token operator&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;unknown&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token function-variable function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;result&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; AgentResult&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; pass&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;boolean&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; score&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; reason&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;token class-name&quot;&gt;AgentResult&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  finalAnswer&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  toolCalls&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; tool&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; args&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;unknown&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt; output&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;unknown&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  latencyMs&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;number&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;runEval&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;agent&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; Agent&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; cases&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; EvalCase&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; results &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; c &lt;span class=&quot;token keyword&quot;&gt;of&lt;/span&gt; cases&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; result &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;await&lt;/span&gt; agent&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;prompt&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; verdict &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;result&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    results&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;...&lt;/span&gt;verdict&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; name&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;name&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; latencyMs&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;latencyMs &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;summarize&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;results&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;token keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;summarize&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;results&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token builtin&quot;&gt;any&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; total &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; results&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;reduce&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;sum&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; sum &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; r&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;score&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;/&lt;/span&gt; results&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;length&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt; overall&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; total&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;toFixed&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; cases&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; results &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A single case might look like this:&lt;/p&gt;
&lt;pre class=&quot;language-ts&quot;&gt;&lt;code class=&quot;language-ts&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cancelMeetingCase&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; EvalCase &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
  name&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;cancel meeting and notify attendees&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  prompt&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;Cancel my 3pm standup and tell everyone it is postponed.&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  expectedTool&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;calendar.cancelEvent&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;token function-variable function&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;result&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; cancelled &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;toolCalls&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;some&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;c&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;tool &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;calendar.cancelEvent&quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;args&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;title &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;3pm standup&quot;&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;const&lt;/span&gt; notified &lt;span class=&quot;token operator&quot;&gt;=&lt;/span&gt; result&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;toolCalls&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;some&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;
      &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;c&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;tool &lt;span class=&quot;token operator&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;slack.sendMessage&quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; c&lt;span class=&quot;token punctuation&quot;&gt;.&lt;/span&gt;args&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;text&lt;span class=&quot;token operator&quot;&gt;?.&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;includes&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;postponed&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;token keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;
      pass&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; cancelled &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; notified&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      score&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;cancelled &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.5&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;notified &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0.5&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
      reason&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; cancelled &lt;span class=&quot;token operator&quot;&gt;&amp;amp;&amp;amp;&lt;/span&gt; notified &lt;span class=&quot;token operator&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;ok&quot;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;missing steps&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key idea: &lt;strong&gt;the check is a scoring function, not a boolean assertion&lt;/strong&gt;. This gives you a spectrum of quality instead of a hard pass/fail.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;metrics-that-matter&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#metrics-that-matter&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; Metrics that matter&lt;/h2&gt;
&lt;p&gt;Pick a small set of metrics and track them over time. I recommend starting with:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;th&gt;How to measure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tool-call accuracy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Did the agent use the right tool with the right arguments?&lt;/td&gt;
&lt;td&gt;Exact match on tool name + fuzzy match on args&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Task success rate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Did it finish the user&#39;s task?&lt;/td&gt;
&lt;td&gt;Human judgment or LLM-as-judge on final output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hallucination rate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Did it invent facts or tools?&lt;/td&gt;
&lt;td&gt;Check tool calls against allowed list and output against source data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Turn count / latency&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Is it efficient?&lt;/td&gt;
&lt;td&gt;Count tool calls and wall-clock time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Regression delta&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Did a new model/prompt break old behavior?&lt;/td&gt;
&lt;td&gt;Re-run eval set after every change and compare&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;llm-as-judge-(and-its-limits)&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#llm-as-judge-(and-its-limits)&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; LLM-as-judge (and its limits)&lt;/h2&gt;
&lt;p&gt;You can use a separate LLM to score the agent&#39;s output. It is cheap, fast, and scales well. A typical prompt looks like:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Score the following answer from 1 to 5 on correctness, completeness, and safety. Explain your reasoning briefly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;But do not rely on it alone. LLM judges have the same failure modes as the agents you are testing: they can be biased by confident phrasing, miss subtle errors, and score low on adversarial examples. Always keep a set of cases where a human is the final judge.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;from-eval-harness-to-ci-pipeline&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#from-eval-harness-to-ci-pipeline&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; From eval harness to CI pipeline&lt;/h2&gt;
&lt;p&gt;The biggest mistake is keeping evals on a laptop. Put them in CI:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Run the eval suite on every PR.&lt;/li&gt;
&lt;li&gt;Store results as JSON artifacts.&lt;/li&gt;
&lt;li&gt;Compare the PR result against &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Block the merge if the overall score drops by more than a threshold (e.g., 2%).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You do not need a fancy platform. A TeamCity or GitHub Actions step that runs &lt;code&gt;npm run eval&lt;/code&gt; and parses the JSON is enough.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2 id=&quot;what-to-do-next&quot; tabindex=&quot;-1&quot;&gt;&lt;a class=&quot;heading-anchor&quot; href=&quot;https://pershukov.com/blog/testing-ai-agents-practical-guide/#what-to-do-next&quot; aria-hidden=&quot;true&quot;&gt;#&lt;/a&gt; What to do next&lt;/h2&gt;
&lt;p&gt;If you are a QA engineer moving into AI testing, start small:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pick one agentic flow in your product.&lt;/li&gt;
&lt;li&gt;Write 10 example user requests with expected outcomes.&lt;/li&gt;
&lt;li&gt;Build a scoring function for each outcome.&lt;/li&gt;
&lt;li&gt;Run the suite weekly, then on every change.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Over time, expand the suite to cover edge cases, adversarial prompts, and multi-turn conversations. The goal is not perfect coverage; the goal is a signal that tells you when the agent gets worse.&lt;/p&gt;
&lt;p&gt;If you want to go deeper, the next posts in this series will cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Building a regression dashboard for LLM prompts&lt;/li&gt;
&lt;li&gt;Testing MCP (Model Context Protocol) integrations end-to-end&lt;/li&gt;
&lt;li&gt;Evaluating multi-agent orchestration and handoff reliability&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;key-takeaways&quot;&gt;
&lt;p class=&quot;key-takeaways-title&quot;&gt;Key takeaways&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Test agents on three layers: tool-call correctness, orchestration, and end-to-end task success — each catches failures the others miss.&lt;/li&gt;
&lt;li&gt;Replace exact-match asserts with scored metrics and explicit thresholds; non-determinism is a test condition, not a failure.&lt;/li&gt;
&lt;li&gt;Track tool-call accuracy, task success rate, hallucination rate, and regression delta — these four numbers tell you when the agent gets worse.&lt;/li&gt;
&lt;li&gt;Use LLM-as-judge for open-ended outputs, but keep human-judged cases for calibration.&lt;/li&gt;
&lt;li&gt;Evals belong in CI: run on every PR, compare against main, block merges on score drops.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Written by Evgeny Pershukov. Follow me on &lt;a href=&quot;https://www.linkedin.com/in/evgeny-pershukov/&quot;&gt;LinkedIn&lt;/a&gt; or &lt;a href=&quot;https://github.com/evgeny-pershukov&quot;&gt;GitHub&lt;/a&gt; for more notes on AI Test Engineering.&lt;/em&gt;&lt;/p&gt;

    </content>
  </entry>
</feed>
