From 9c029819ae01e12118f6003f325f43bed2be285b Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Wed, 22 Jul 2026 18:29:12 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Improve=20agent=20context=20drill-d?= =?UTF-8?q?owns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let agents page bounded build evidence and normalize focused comparison context without changing raw API payloads. Surface the resolved API origin on network failures so configuration mistakes are immediately diagnosable. --- README.md | 9 +- docs/json-output.md | 54 ++++++++- skills/vizzly/references/cli-context.md | 6 +- src/api/client.js | 29 ++++- src/cli.js | 9 ++ src/commands/context.js | 116 +++++++++++++++--- tests/api/client.test.js | 27 +++++ tests/commands/context-cli.test.js | 89 +++++++++++++- tests/commands/context.test.js | 149 +++++++++++++++++++++++- 9 files changed, 456 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 367a1527..e1444f90 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ evidence in one place. ```bash # Cloud context for a build or comparison vizzly context build abc123 --source cloud -vizzly context comparison def456 --source cloud --json +vizzly context build abc123 --source cloud --agent --json --offset 10 +vizzly context comparison def456 --source cloud --agent --json # Local workspace context from .vizzly/ vizzly context build current --source local @@ -110,8 +111,10 @@ vizzly context screenshot build-detail-screenshots --source local --json vizzly context review-queue --source local --json ``` -`--json` is the durable automation path. `--agent` gives a compact handoff for -prompt assembly. Add `--full` when you need the whole payload, or +`--json` is the durable automation path. `--agent` gives a normalized handoff +for prompt assembly. Build handoffs contain up to 10 records; use the returned +next-page command or `--offset` to continue without loading the full build. Add +`--full` when you need the whole payload, or `--include screenshots,diffs,comments` when compact JSON needs selected detail. Local context is read-only and file-backed. It reads your existing `.vizzly` diff --git a/docs/json-output.md b/docs/json-output.md index 6743df43..97061936 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -264,6 +264,7 @@ cloud data or your local `.vizzly` workspace. ```bash vizzly context build abc123 --source cloud --json vizzly context build abc123 --source cloud --agent --json +vizzly context build abc123 --source cloud --agent --json --offset 10 vizzly context build abc123 --source cloud --agent --json --include diffs,comments vizzly context build abc123 --source cloud --agent --json --full vizzly context build current --source local --json @@ -273,7 +274,8 @@ vizzly context build current --source local --agent Use `--json` for durable automation. Use `--agent --json` when you want the compact handoff that agents should read first. It returns at most 10 actionable evidence records while preserving API order, with failed captures first and one variant from each screenshot group before additional -variants. Add `--include diffs` for raw Honeydiff diagnostics on those selected records. Explicit +variants. Follow the returned next-page command or use `--offset` to continue through that order. +Add `--include diffs` for raw Honeydiff diagnostics on those selected records. Explicit `screenshots` and `comments` includes return those API collections, and `--full` returns the complete build context payload unchanged. @@ -315,7 +317,10 @@ Compact agent JSON: } }, "evidence_limit": 10, + "evidence_offset": 0, + "evidence_total": 1, "evidence_returned": 1, + "evidence_has_more": false, "evidence_truncated": false, "evidence": [ { @@ -359,7 +364,7 @@ Compact agent JSON: "suggested_commands": [ { "label": "Inspect comparison context", - "command": "vizzly --json context comparison cmp-1 --source cloud" + "command": "vizzly --json context comparison cmp-1 --agent --source cloud" }, { "label": "Inspect screenshot history", @@ -375,9 +380,9 @@ Compact agent JSON: `status`, `summary`, review state, asset URLs, and Honeydiff values come from the API. The compact client does not estimate processing progress or rebuild server aggregates. Its local work is -limited to normalization, bounded evidence selection, truncation facts, and executable -`suggested_commands`. When `evidence_truncated` is `true`, the suggestions also include a `--full` -command. +limited to normalization, API-ordered evidence paging, truncation facts, and executable +`suggested_commands`. When more records follow the current page, the suggestions include the exact +next `--offset`. When the page omits any records, they also include a `--full` command. Full build context JSON: @@ -452,9 +457,48 @@ Full build context JSON: ```bash vizzly context comparison cmp-1 --source cloud --json +vizzly context comparison cmp-1 --source cloud --agent --json vizzly context comparison build-detail-screenshots --source local --json ``` +Raw JSON preserves the provider response. Add `--agent` to normalize current, baseline, diff, and +Honeydiff fields into the same evidence shape used by compact build context. + +Agent comparison JSON: + +```json +{ + "resource": "comparison_agent_context", + "source": "cloud", + "comparison": { + "id": "cmp-1", + "name": "Dashboard", + "result": "changed", + "review_state": "pending", + "screenshot": { + "url": "https://.../current.png" + }, + "baseline": { + "url": "https://.../baseline.png" + }, + "diff": { + "image_url": "https://.../diff.png", + "fingerprint_hash": "00000000001ec127", + "regions": [], + "cluster_metadata": { + "classification": "dynamic_content" + } + } + }, + "history": { + "similar_by_fingerprint": [], + "recent_by_name": [] + } +} +``` + +Raw comparison JSON remains available without `--agent`: + ```json { "resource": "comparison_context", diff --git a/skills/vizzly/references/cli-context.md b/skills/vizzly/references/cli-context.md index 50a35c92..9ac7259f 100644 --- a/skills/vizzly/references/cli-context.md +++ b/skills/vizzly/references/cli-context.md @@ -84,7 +84,7 @@ For each evidence record: Useful manual drill-downs are: ```bash -vizzly context comparison --source --json +vizzly context comparison --source --agent --json vizzly context screenshot "" --source --json vizzly context similar --source cloud --json vizzly context review-queue --source --json @@ -94,3 +94,7 @@ Use the source from the evidence you are inspecting in place of ``. `context similar` is cloud-only. Keep missing values unknown, and do not turn metadata into a visual conclusion when the underlying images are unavailable. + +When a build has more than 10 actionable records, run its returned next-page +command. The command uses `--offset` to preserve API order without pulling the +full build context into the agent handoff. diff --git a/src/api/client.js b/src/api/client.js index ee3dfd62..35f3ef33 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -27,6 +27,16 @@ function isProjectToken(token) { return typeof token === 'string' && token.startsWith('vzt_'); } +/** Prefer the transport cause because Node's top-level fetch message is generic. */ +function getNetworkFailureReason(error) { + return ( + error?.cause?.code || + error?.cause?.message || + error?.message || + 'request failed' + ); +} + /** * Create an API client with the given configuration * @@ -75,10 +85,21 @@ export function createApiClient(options = {}) { extra: fetchOptions.headers || {}, }); - let response = await fetch(url, { - ...fetchOptions, - headers, - }); + let response; + try { + response = await fetch(url, { + ...fetchOptions, + headers, + }); + } catch (error) { + let apiOrigin = new URL(url).origin; + let reason = getNetworkFailureReason(error); + throw new VizzlyError( + `Unable to reach Vizzly at ${apiOrigin}: ${reason}`, + 'NETWORK_ERROR', + { apiOrigin } + ); + } if (!response.ok) { let errorBody = await extractErrorBody(response); diff --git a/src/cli.js b/src/cli.js index 78d93354..a8a263a9 100644 --- a/src/cli.js +++ b/src/cli.js @@ -982,6 +982,12 @@ contextCmd .option('--source ', 'Context source: auto, cloud, or local', 'auto') .option('--agent', 'Output compact context for LLM agents') .option('--full', 'Return the full build context payload with --agent --json') + .option( + '--offset ', + 'Skip the first N evidence records with --agent --json', + Number, + 0 + ) .option( '--include ', 'Add detail to compact agent JSON: screenshots,diffs,comments' @@ -994,6 +1000,7 @@ Examples: $ vizzly context build current --source local $ vizzly context build current --source local --agent $ vizzly context build abc123 --source cloud --agent --json + $ vizzly context build abc123 --source cloud --agent --json --offset 10 $ vizzly context build abc123 --source cloud --agent --json --include diffs,comments $ vizzly context build abc123 --source cloud --agent --json --full ` @@ -1013,6 +1020,7 @@ contextCmd .description('Fetch a comparison context bundle') .argument('', 'Comparison ID to fetch context for') .option('--source ', 'Context source: auto, cloud, or local', 'auto') + .option('--agent', 'Normalize JSON evidence for LLM agents') .option( '--similar-limit ', 'Maximum similar fingerprint matches to return (1-50)', @@ -1036,6 +1044,7 @@ Examples: $ vizzly context comparison def456 --source local $ vizzly context comparison def456 --source cloud --similar-limit 5 --recent-limit 5 $ vizzly context comparison def456 --source cloud --json + $ vizzly context comparison def456 --source cloud --agent --json ` ) .action(async (comparisonId, options) => { diff --git a/src/commands/context.js b/src/commands/context.js index 0fdac45b..05198df8 100644 --- a/src/commands/context.js +++ b/src/commands/context.js @@ -15,7 +15,10 @@ import { resolveContextSource as defaultResolveContextSource } from '../context/ import { loadConfig as defaultLoadConfig } from '../utils/config-loader.js'; import * as defaultOutput from '../utils/output.js'; import { readSession as defaultReadSession } from '../utils/session.js'; -import { normalizeBuildContext } from '../utils/visual-context-normalizers.js'; +import { + normalizeBuildContext, + normalizeComparisonRecord, +} from '../utils/visual-context-normalizers.js'; function buildAuthErrorMessage() { return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.'; @@ -550,14 +553,18 @@ function appendContextSource(command, context = {}) { * * @param {Object} context - Normalized build context. * @param {Object[]} evidence - Evidence included in the compact handoff. - * @param {boolean} truncated - Whether additional evidence was omitted. + * @param {Object} options - Evidence page and include options. + * @param {number} [options.evidenceOffset] - API-ordered records already skipped. + * @param {number} [options.evidenceTotal] - Total actionable evidence records. + * @param {string[]} [options.include] - Detail collections to preserve when paging. * @returns {{label: string, command: string}[]} Suggested CLI commands. */ -function buildSuggestedCommands( - context = {}, - evidence = [], - truncated = false -) { +function buildSuggestedCommands(context = {}, evidence = [], options = {}) { + let { + evidenceOffset = 0, + evidenceTotal = evidence.length, + include = [], + } = options; let commands = []; let firstComparison = evidence.find( item => item.kind === 'comparison' && item.id @@ -571,7 +578,7 @@ function buildSuggestedCommands( commands.push({ label: 'Inspect comparison context', command: appendContextSource( - `vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)}`, + `vizzly --json context comparison ${quoteCommandArgument(firstComparison.id)} --agent`, context ), }); @@ -588,16 +595,30 @@ function buildSuggestedCommands( } if (buildTarget && evidence.length > 0) { + let offsetFlag = evidenceOffset > 0 ? ` --offset ${evidenceOffset}` : ''; commands.push({ label: 'Load raw diff diagnostics', command: appendContextSource( - `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs`, + `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs${offsetFlag}`, context ), }); } - if (buildTarget && truncated) { + let nextOffset = evidenceOffset + evidence.length; + if (buildTarget && nextOffset < evidenceTotal) { + let includeFlag = + include.length > 0 ? ` --include ${include.join(',')}` : ''; + commands.push({ + label: 'Load next evidence page', + command: appendContextSource( + `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --offset ${nextOffset}${includeFlag}`, + context + ), + }); + } + + if (buildTarget && evidenceTotal > evidence.length) { commands.push({ label: 'Load full build context', command: appendContextSource( @@ -622,11 +643,12 @@ function buildSuggestedCommands( * @param {string|null} [options.source] - Resolved source fallback. * @param {string[]} [options.include] - Explicit detail collections. * @param {number} [options.evidenceLimit] - Maximum evidence record count. + * @param {number} [options.evidenceOffset] - API-ordered records to skip. * @returns {Object} Bounded agent build context. */ function buildAgentBuildPayload( context, - { source = null, include = [], evidenceLimit = 10 } = {} + { source = null, include = [], evidenceLimit = 10, evidenceOffset = 0 } = {} ) { let includeSet = new Set(include); let includeDiffs = includeSet.has('diffs'); @@ -635,7 +657,11 @@ function buildAgentBuildPayload( ...normalized.failed_captures.map(buildFailedCaptureEvidence), ...selectBreadthFirstEvidence(normalized.groups), ]; - let evidence = candidates.slice(0, evidenceLimit); + let evidence = candidates.slice( + evidenceOffset, + evidenceOffset + evidenceLimit + ); + let evidenceHasMore = evidenceOffset + evidence.length < candidates.length; let evidenceTruncated = candidates.length > evidence.length; let payload = { resource: 'build_agent_context', @@ -657,16 +683,19 @@ function buildAgentBuildPayload( summary: normalized.summary || null, signature_properties: normalized.signature_properties ?? null, evidence_limit: evidenceLimit, + evidence_offset: evidenceOffset, + evidence_total: candidates.length, evidence_returned: evidence.length, + evidence_has_more: evidenceHasMore, evidence_truncated: evidenceTruncated, evidence, links: normalized.links || {}, preview: normalized.preview || null, - suggested_commands: buildSuggestedCommands( - normalized, - evidence, - evidenceTruncated - ), + suggested_commands: buildSuggestedCommands(normalized, evidence, { + evidenceOffset, + evidenceTotal: candidates.length, + include, + }), }; if (includeSet.has('screenshots')) { @@ -680,6 +709,51 @@ function buildAgentBuildPayload( return payload; } +/** Preserve an omitted history collection instead of inventing an empty one. */ +function normalizeComparisonHistory(comparisons) { + if (!Array.isArray(comparisons)) { + return comparisons ?? null; + } + + return comparisons.map(comparison => normalizeComparisonRecord(comparison)); +} + +/** + * Normalize a focused comparison and its history without changing API facts. + * + * The build handoff and comparison endpoint use different field names for the + * same Honeydiff evidence. Agents should not need to know that raw regions are + * `analysis.diff_regions` in one response and `diff.regions` in another. + * + * @param {Object} context - Raw comparison context returned by the provider. + * @returns {Object} Stable, API-backed evidence for an agent. + */ +function buildAgentComparisonPayload(context = {}) { + let history = context.history || {}; + + return { + resource: 'comparison_agent_context', + source: context.source || null, + review_flow: context.review_flow || null, + scope: context.scope || null, + build: context.build || null, + signature_properties: context.signature_properties ?? null, + comparison: normalizeComparisonRecord(context.comparison || {}, { + includeDiffs: true, + }), + dynamic_content: context.dynamic_content ?? null, + history: { + ...history, + similar_by_fingerprint: normalizeComparisonHistory( + history.similar_by_fingerprint + ), + recent_by_name: normalizeComparisonHistory(history.recent_by_name), + }, + review: context.review || null, + links: context.links || {}, + }; +} + function getBuildCommentsCount(context = {}) { if (Array.isArray(context.comments?.build)) { return context.comments.build.length; @@ -1256,6 +1330,7 @@ export async function contextBuildCommand( buildAgentBuildPayload(context, { source: runtime.source, include, + evidenceOffset: options.offset, }) ); output.cleanup(); @@ -1326,6 +1401,12 @@ export async function contextComparisonCommand( ); output.stopSpinner(); + if (globalOptions.json && options.agent) { + output.data(buildAgentComparisonPayload(context)); + output.cleanup(); + return; + } + if (globalOptions.json) { output.data(context); output.cleanup(); @@ -1511,6 +1592,7 @@ export async function contextReviewQueueCommand( export function validateContextBuildOptions(_options = {}) { let errors = validateSourceOption(_options.source); errors.push(...validateIncludeOption(_options.include)); + errors.push(...validateOffset(_options.offset)); return errors; } diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 0d2de51d..da86c793 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -181,6 +181,33 @@ describe('api/client', () => { assert.strictEqual(options.headers['Content-Type'], 'application/json'); }); + it('identifies the API origin when the network request fails', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'http://localhost:3000', + }); + let networkError = new TypeError('fetch failed'); + networkError.cause = { code: 'ECONNREFUSED' }; + mockFetch.mock.mockImplementation(async () => { + throw networkError; + }); + + await assert.rejects( + () => client.request('/api/builds'), + error => { + assert.strictEqual(error.name, 'VizzlyError'); + assert.match( + error.message, + /Unable to reach Vizzly at http:\/\/localhost:3000/ + ); + assert.match(error.message, /ECONNREFUSED/); + assert.strictEqual(error.code, 'NETWORK_ERROR'); + assert.strictEqual(error.context?.apiOrigin, 'http://localhost:3000'); + return true; + } + ); + }); + it('throws AuthError for 401 response', async () => { let client = createApiClient({ token: 'test-token', diff --git a/tests/commands/context-cli.test.js b/tests/commands/context-cli.test.js index 54b608e9..910fbcb0 100644 --- a/tests/commands/context-cli.test.js +++ b/tests/commands/context-cli.test.js @@ -4,7 +4,7 @@ import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, it } from 'node:test'; -import { runCLI } from '../helpers/cli-runner.js'; +import { parseJSONOutput, runCLI } from '../helpers/cli-runner.js'; function createWorkspaceFixture() { let cwd = join( @@ -275,6 +275,40 @@ async function withBuildContextApi(callback) { } describe('context CLI integration', () => { + it('reports the resolved API origin when cloud context is unreachable', async () => { + let server = createServer(request => request.socket.destroy()); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + try { + let address = server.address(); + let apiUrl = `http://127.0.0.1:${address.port}`; + let result = await runCLI( + ['--json', 'context', 'build', 'build-123', '--source', 'cloud'], + { + cwd: mkdtempSync(join(tmpdir(), 'vizzly-context-network-')), + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 1); + let messages = parseJSONOutput(result.stderr); + assert.ok( + messages.some( + message => + message.status === 'error' && + message.error?.message.includes( + `Unable to reach Vizzly at ${apiUrl}` + ) + ) + ); + } finally { + await new Promise(resolve => server.close(resolve)); + } + }); + it('returns bounded API-backed agent evidence through the real CLI', async () => { await withBuildContextApi(async ({ apiUrl, requests }) => { let cwd = mkdtempSync(join(tmpdir(), 'vizzly-context-cloud-')); @@ -312,6 +346,26 @@ describe('context CLI integration', () => { assert.ok(!compactPayload.groups); assert.ok(!compactPayload.next_actions); + let nextPage = await runCLI( + [ + '--json', + 'context', + 'build', + 'build-123', + '--agent', + '--offset', + '10', + ], + { cwd, env } + ); + + assert.strictEqual(nextPage.code, 0); + let nextPagePayload = JSON.parse(nextPage.stdout).data; + assert.strictEqual(nextPagePayload.evidence_offset, 10); + assert.strictEqual(nextPagePayload.evidence_returned, 1); + assert.strictEqual(nextPagePayload.evidence_has_more, false); + assert.strictEqual(nextPagePayload.evidence[0].id, 'comparison-11'); + let withDiffs = await runCLI( [ '--json', @@ -331,12 +385,45 @@ describe('context CLI integration', () => { { x: 10, y: 20, width: 30, height: 40 }, ]); assert.deepStrictEqual(requests, [ + '/api/sdk/context/builds/build-123?details=summary', '/api/sdk/context/builds/build-123?details=summary', '/api/sdk/context/builds/build-123?details=diffs', ]); }); }); + it('normalizes focused comparison evidence through the real CLI', async () => { + await withBuildContextApi(async ({ apiUrl }) => { + let cwd = mkdtempSync(join(tmpdir(), 'vizzly-context-comparison-')); + let result = await runCLI( + [ + '--json', + 'context', + 'comparison', + 'comparison-1', + '--agent', + '--source', + 'cloud', + ], + { + cwd, + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 0, result.stderr); + let payload = JSON.parse(result.stdout).data; + assert.strictEqual(payload.resource, 'comparison_agent_context'); + assert.strictEqual(payload.comparison.id, 'comparison-1'); + assert.deepStrictEqual(payload.comparison.diff.regions, [ + { x: 10, y: 20, width: 30, height: 40 }, + ]); + }); + }); + it('labels every cloud context resource with its selected source', async () => { await withBuildContextApi(async ({ apiUrl }) => { let cwd = mkdtempSync(join(tmpdir(), 'vizzly-context-provenance-')); diff --git a/tests/commands/context.test.js b/tests/commands/context.test.js index 0043a5e3..4d96612e 100644 --- a/tests/commands/context.test.js +++ b/tests/commands/context.test.js @@ -58,6 +58,19 @@ describe('commands/context', () => { ); }); + it('rejects invalid compact evidence offsets', () => { + assert.ok( + validateContextBuildOptions({ offset: -1 }).includes( + '--offset must be a non-negative integer' + ) + ); + assert.ok( + validateContextBuildOptions({ offset: 1.5 }).includes( + '--offset must be a non-negative integer' + ) + ); + }); + it('rejects out-of-range comparison context limits', () => { let errors = validateContextComparisonOptions({ similarLimit: 51 }); assert.ok( @@ -314,7 +327,7 @@ describe('commands/context', () => { assert.deepStrictEqual( payload.suggested_commands.map(item => item.command), [ - 'vizzly --json context comparison cmp-1 --source cloud', + 'vizzly --json context comparison cmp-1 --agent --source cloud', 'vizzly --json context screenshot Dashboard --source cloud', 'vizzly --json context build build-1 --agent --include diffs --source cloud', ] @@ -359,6 +372,9 @@ describe('commands/context', () => { let payload = output.calls.find(call => call.method === 'data').args[0]; assert.strictEqual(payload.evidence_returned, 10); assert.strictEqual(payload.evidence_truncated, true); + assert.strictEqual(payload.evidence_offset, 0); + assert.strictEqual(payload.evidence_total, 12); + assert.strictEqual(payload.evidence_has_more, true); assert.deepStrictEqual( payload.evidence.map(item => item.id), [ @@ -379,6 +395,61 @@ describe('commands/context', () => { item.command.endsWith('--agent --full --source cloud') ) ); + assert.ok( + payload.suggested_commands.some(item => + item.command.endsWith('--agent --offset 10 --source cloud') + ) + ); + }); + + it('returns the requested API-ordered evidence page', async () => { + let output = createMockOutput(); + let groups = Array.from({ length: 12 }, (_, index) => ({ + name: `Group ${index + 1}`, + variant_count: 1, + aggregate_status: { needs_review: true, needs_review_count: 1 }, + variants: [ + { + id: `cmp-${index + 1}`, + result: 'changed', + needs_review: true, + }, + ], + })); + + await contextBuildCommand( + 'build-1', + { agent: true, offset: 10 }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getBuildContext: async () => ({ build: { id: 'build-1' }, groups }), + output, + exit: () => {}, + } + ); + + let payload = output.calls.find(call => call.method === 'data').args[0]; + assert.strictEqual(payload.evidence_offset, 10); + assert.strictEqual(payload.evidence_total, 12); + assert.strictEqual(payload.evidence_returned, 2); + assert.strictEqual(payload.evidence_has_more, false); + assert.strictEqual(payload.evidence_truncated, true); + assert.deepStrictEqual( + payload.evidence.map(item => item.id), + ['cmp-11', 'cmp-12'] + ); + assert.ok( + payload.suggested_commands.some(item => + item.command.endsWith( + '--agent --include diffs --offset 10 --source cloud' + ) + ) + ); }); it('returns no compact evidence when the API marks every group reviewed', async () => { @@ -1143,6 +1214,82 @@ describe('commands/context', () => { assert.ok(knownRegionsCall); assert.strictEqual(knownRegionsCall.args[1], 'Known header copy band'); }); + + it('returns consistent API-backed comparison evidence for agents', async () => { + let output = createMockOutput(); + + await contextComparisonCommand( + 'comparison-1', + { agent: true }, + { json: true }, + { + loadConfig: async () => ({ + apiKey: 'token', + apiUrl: 'https://api.test', + }), + createApiClient: () => ({}), + getComparisonContext: async () => ({ + resource: 'comparison_context', + source: 'cloud', + review_flow: 'cricket_v1', + scope: { + organization: { slug: 'acme' }, + project: { slug: 'web' }, + }, + build: { id: 'build-1' }, + comparison: { + id: 'comparison-1', + result: 'changed', + visual_review: { state: 'pending' }, + screenshot: { + id: 'current-1', + name: 'Dashboard', + url: 'https://cdn.test/current.png', + }, + baseline: { + id: 'baseline-1', + url: 'https://cdn.test/baseline.png', + }, + analysis: { + diff_image_url: 'https://cdn.test/diff.png', + fingerprint_hash: 'fingerprint-1', + diff_regions: [{ x: 10, y: 20, width: 30, height: 40 }], + cluster_metadata: { classification: 'dynamic_content' }, + }, + }, + dynamic_content: null, + history: { + similar_by_fingerprint: [], + recent_by_name: [], + hotspot_analysis: null, + confirmed_regions: [], + }, + review: { review_summary: { pending: 1 } }, + links: { comparison_url: 'https://app.test/comparison-1' }, + }), + output, + exit: () => {}, + } + ); + + let payload = output.calls.find(call => call.method === 'data').args[0]; + assert.strictEqual(payload.resource, 'comparison_agent_context'); + assert.strictEqual(payload.source, 'cloud'); + assert.strictEqual(payload.comparison.name, 'Dashboard'); + assert.strictEqual(payload.comparison.review_state, 'pending'); + assert.strictEqual( + payload.comparison.diff.image_url, + 'https://cdn.test/diff.png' + ); + assert.deepStrictEqual(payload.comparison.diff.regions, [ + { x: 10, y: 20, width: 30, height: 40 }, + ]); + assert.deepStrictEqual(payload.comparison.diff.cluster_metadata, { + classification: 'dynamic_content', + }); + assert.strictEqual(payload.dynamic_content, null); + assert.deepStrictEqual(payload.history.confirmed_regions, []); + }); }); describe('contextScreenshotCommand', () => {