From fa58a436b2327adb69ebfb3c73739d4f69b57753 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 28 Aug 2026 12:15:14 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Finalize=20agent-friendly=20vis?= =?UTF-8?q?ual=20context?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the API-owned compact context contract for both human and agent flows. Add opaque cursor paging, bounded schema checks, local-provider parity, and lossless raw/full drill-downs. --- README.md | 12 +- docs/json-output.md | 150 ++-- src/cli.js | 34 +- src/commands/context.js | 762 ++++++++---------- src/context/local-workspace-provider.js | 293 ++++++- tests/api/endpoints.test.js | 16 +- tests/commands/context-cli.test.js | 309 ++++++- tests/commands/context.test.js | 19 +- .../context/local-workspace-provider.test.js | 97 +++ 9 files changed, 1121 insertions(+), 571 deletions(-) diff --git a/README.md b/README.md index e1444f90..ee067ac1 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ evidence in one place. ```bash # Cloud context for a build or comparison vizzly context build abc123 --source cloud -vizzly context build abc123 --source cloud --agent --json --offset 10 +vizzly context build abc123 --source cloud --agent --json --cursor eyJ2IjoxfQ vizzly context comparison def456 --source cloud --agent --json # Local workspace context from .vizzly/ @@ -111,11 +111,11 @@ 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 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. +`--json` is the durable automation path. `--agent` gives you the compact API +handoff for prompt assembly. Build handoffs contain up to 10 records; use the +returned next-page command or opaque `--cursor` to continue safely. Add +`--include diffs` for raw diff diagnostics, or `--full` when you need the whole +payload. Local context is read-only and file-backed. It reads your existing `.vizzly` workspace state from TDD runs, including screenshots, diffs, and saved hotspot diff --git a/docs/json-output.md b/docs/json-output.md index 97061936..76180d3b 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -264,20 +264,18 @@ 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 --cursor eyJ2IjoxfQ +vizzly context build abc123 --source cloud --agent --json --include diffs vizzly context build abc123 --source cloud --agent --json --full vizzly context build current --source local --json 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. 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. +agents should read first. The API chooses and orders up to 10 evidence records, then returns an +opaque cursor when more evidence is available. Follow the suggested next-page command or pass that +cursor to `--cursor`. Add `--include diffs` for raw Honeydiff diagnostics on the same page. +`--full` returns the complete build context payload unchanged. Compact agent JSON: @@ -285,10 +283,9 @@ Compact agent JSON: { "resource": "build_agent_context", "source": "cloud", - "project": { - "organization": "acme", - "slug": "storybook", - "name": "Storybook" + "scope": { + "organization": { "slug": "acme" }, + "project": { "slug": "storybook", "name": "Storybook" } }, "build": { "id": "abc123", @@ -316,51 +313,44 @@ Compact agent JSON: "new": 1 } }, - "evidence_limit": 10, - "evidence_offset": 0, - "evidence_total": 1, - "evidence_returned": 1, - "evidence_has_more": false, - "evidence_truncated": false, - "evidence": [ - { - "kind": "comparison", - "id": "cmp-1", - "name": "Dashboard", - "result": "changed", - "review_state": "pending", - "needs_review": true, - "group": { - "name": "Dashboard", - "variant_count": 2, - "needs_review_count": 1, - "failed_count": 0, - "max_diff_percentage": 0.42 - }, - "screenshot": { - "id": "current-1", - "browser": "chrome", - "viewport": { "width": 1440, "height": 900 }, - "bitmap": { "width": 2880, "height": 1800 }, - "signature": "Dashboard|1440|chrome", - "url": "https://.../current.png" - }, - "baseline": { - "id": "baseline-1", - "build_id": "baseline-build", - "url": "https://.../baseline.png" - }, - "diff": { - "percentage": 0.42, - "fingerprint_hash": "00000000001ec127", - "region_count": 12, - "projection": { - "clusters": { "count": 12 } + "evidence": { + "items": [ + { + "type": "comparison", + "id": "cmp-1", + "screenshot_name": "Dashboard", + "result": "changed", + "review_state": "pending", + "needs_review": true, + "screenshot": { + "id": "current-1", + "browser": "chrome", + "viewport": { "width": 1440, "height": 900 }, + "bitmap": { "width": 2880, "height": 1800 }, + "signature": "Dashboard|1440|chrome", + "url": "https://.../current.png", + "baseline": { + "id": "baseline-1", + "build_id": "baseline-build", + "url": "https://.../baseline.png" + } }, - "image_url": "https://..." + "diff": { + "percentage": 0.42, + "fingerprint_hash": "00000000001ec127", + "region_count": 12, + "image_url": "https://..." + } } + ], + "page": { + "limit": 10, + "returned": 1, + "total": 12, + "has_more": true, + "next_cursor": "eyJ2IjoxfQ" } - ], + }, "suggested_commands": [ { "label": "Inspect comparison context", @@ -373,16 +363,20 @@ Compact agent JSON: { "label": "Load raw diff diagnostics", "command": "vizzly --json context build abc123 --agent --include diffs --source cloud" + }, + { + "label": "Load next evidence page", + "command": "vizzly --json context build abc123 --agent --cursor eyJ2IjoxfQ --source cloud" } ] } ``` -`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, 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. +`status`, `summary`, evidence order, pagination, asset URLs, and Honeydiff values come from the API. +The CLI does not estimate processing progress, rebuild server aggregates, or rank evidence. It adds +source-pinned `suggested_commands` so follow-up requests stay on the same provider. When another page +exists, the next command carries the API's opaque cursor. A `--full` command is always available when +you need the raw payload. Full build context JSON: @@ -458,11 +452,14 @@ 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 cmp-1 --source cloud --agent --json --cursor eyJ2IjoxfQ +vizzly context comparison cmp-1 --source cloud --agent --json --include diffs 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. +Raw JSON preserves the provider response. Add `--agent` to request the compact API shape. The focal +comparison stays in its API-native shape, including `analysis`; the CLI does not rename those facts. +Similar fingerprint history and recent same-name history stay in separate paged collections. Agent comparison JSON: @@ -472,7 +469,7 @@ Agent comparison JSON: "source": "cloud", "comparison": { "id": "cmp-1", - "name": "Dashboard", + "screenshot_name": "Dashboard", "result": "changed", "review_state": "pending", "screenshot": { @@ -481,18 +478,37 @@ Agent comparison JSON: "baseline": { "url": "https://.../baseline.png" }, - "diff": { - "image_url": "https://.../diff.png", + "analysis": { + "diff_image_url": "https://.../diff.png", "fingerprint_hash": "00000000001ec127", - "regions": [], + "diff_regions": [], "cluster_metadata": { "classification": "dynamic_content" } } }, "history": { - "similar_by_fingerprint": [], - "recent_by_name": [] + "active_stream": null, + "similar_by_fingerprint": { + "items": [], + "page": { + "limit": 10, + "returned": 0, + "total": 0, + "has_more": false, + "next_cursor": null + } + }, + "recent_by_name": { + "items": [], + "page": { + "limit": 10, + "returned": 0, + "total": 0, + "has_more": false, + "next_cursor": null + } + } } } ``` diff --git a/src/cli.js b/src/cli.js index 269197d9..109de1df 100644 --- a/src/cli.js +++ b/src/cli.js @@ -978,16 +978,14 @@ contextCmd .argument('', 'Build ID to fetch context for') .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('--full', 'Return the full build context instead of compact context') .option( - '--offset ', - 'Skip the first N evidence records with --agent --json', - Number, - 0 + '--cursor ', + 'Continue compact evidence from an opaque API cursor' ) .option( '--include ', - 'Add detail to compact agent JSON: screenshots,diffs,comments' + 'Add raw diff diagnostics to compact context: diffs' ) .addHelpText( 'after', @@ -997,8 +995,8 @@ 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 --cursor eyJ2IjoxfQ + $ vizzly context build abc123 --source cloud --agent --json --include diffs $ vizzly context build abc123 --source cloud --agent --json --full ` ) @@ -1017,29 +1015,23 @@ 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('--agent', 'Output compact context for LLM agents') .option( - '--similar-limit ', - 'Maximum similar fingerprint matches to return (1-50)', - Number + '--full', + 'Return the full comparison context instead of compact context' ) .option( - '--recent-limit ', - 'Maximum recent same-name comparisons to return (1-50)', - Number - ) - .option( - '--window-size ', - 'Historical hotspot analysis window size (1-50)', - Number + '--cursor ', + 'Continue compact history from an opaque API cursor' ) + .option('--include ', 'Add detail to compact context: diffs') .addHelpText( 'after', ` Examples: $ vizzly context comparison def456 --source cloud $ vizzly context comparison def456 --source local - $ vizzly context comparison def456 --source cloud --similar-limit 5 --recent-limit 5 + $ vizzly context comparison def456 --source cloud --cursor eyJ2IjoxfQ $ vizzly context comparison def456 --source cloud --json $ vizzly context comparison def456 --source cloud --agent --json ` diff --git a/src/commands/context.js b/src/commands/context.js index 951656ee..2414023d 100644 --- a/src/commands/context.js +++ b/src/commands/context.js @@ -12,13 +12,11 @@ import { } from '../api/index.js'; import { createLocalWorkspaceContextProvider as defaultCreateLocalWorkspaceContextProvider } from '../context/local-workspace-provider.js'; import { resolveContextSource as defaultResolveContextSource } from '../context/provider-resolver.js'; +import { VizzlyError } from '../errors/vizzly-error.js'; 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, - normalizeComparisonRecord, -} from '../utils/visual-context-normalizers.js'; +import { normalizeBuildContext } from '../utils/visual-context-normalizers.js'; function buildAuthErrorMessage() { return 'Authentication required. Use --token, set VIZZLY_TOKEN, run "vizzly login", or link a project.'; @@ -29,7 +27,7 @@ function buildSourceErrorMessage() { } function buildIncludeErrorMessage() { - return '--include must contain only: screenshots, diffs, comments'; + return '--include must contain only: diffs'; } function validateLimitRange(value, flagName, { min = 1, max }) { @@ -44,18 +42,131 @@ function validateLimitRange(value, flagName, { min = 1, max }) { return []; } -function validateOffset(value) { +function validateCursor(value) { if (value == null) { return []; } + if (typeof value !== 'string' || value.trim().length === 0) { + return ['--cursor must be a non-empty opaque cursor']; + } + + return []; +} + +function validateOffset(value) { + if (value == null) return []; if (!Number.isInteger(value) || value < 0) { return ['--offset must be a non-negative integer']; } - return []; } +let COMPACT_CONTEXT_LIMIT = 10; +let COMPACT_SUMMARY_MAX_BYTES = 64 * 1024; +let COMPACT_DIFFS_MAX_BYTES = 512 * 1024; + +function getCompactPage(collection, label) { + if (!collection || !Array.isArray(collection.items) || !collection.page) { + throw new VizzlyError( + `Vizzly returned an invalid compact ${label} collection`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + if (collection.items.length > COMPACT_CONTEXT_LIMIT) { + throw new VizzlyError( + `Vizzly returned more than ${COMPACT_CONTEXT_LIMIT} compact ${label} items`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + if (collection.items.some(item => !item || typeof item !== 'object')) { + throw new VizzlyError( + `Vizzly returned an invalid item in compact ${label}`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + let { page } = collection; + let validLimit = + Number.isInteger(page.limit) && + page.limit >= 1 && + page.limit <= COMPACT_CONTEXT_LIMIT; + let validReturned = + Number.isInteger(page.returned) && + page.returned === collection.items.length; + let validTotal = + page.total == null || + (Number.isInteger(page.total) && + page.total >= 0 && + page.total >= page.returned); + let validHasMore = typeof page.has_more === 'boolean'; + let validCursor = + page.next_cursor == null || + (typeof page.next_cursor === 'string' && page.next_cursor.length > 0); + + if ( + !validLimit || + !validReturned || + !validTotal || + !validHasMore || + !validCursor + ) { + throw new VizzlyError( + `Vizzly returned invalid pagination facts for compact ${label}`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + if (page.included === false && collection.items.length > 0) { + throw new VizzlyError( + `Vizzly returned items for an omitted compact ${label} collection`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + if (page.has_more !== Boolean(page.next_cursor)) { + throw new VizzlyError( + `Vizzly returned contradictory pagination facts for compact ${label}`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + return collection; +} + +function validateCompactContext(context, resource, details) { + if (!context || typeof context !== 'object') { + throw new VizzlyError( + `Vizzly returned an invalid compact ${resource} context`, + 'COMPACT_CONTEXT_INVALID' + ); + } + + if (resource === 'build') { + getCompactPage(context.evidence, 'build evidence'); + } else { + let history = context.history || {}; + getCompactPage(history.similar_by_fingerprint, 'similar history'); + getCompactPage(history.recent_by_name, 'recent history'); + } + + let { source: _source, ...providerContext } = context; + let bytes = Buffer.byteLength(JSON.stringify(providerContext)); + let maxBytes = + details === 'diffs' ? COMPACT_DIFFS_MAX_BYTES : COMPACT_SUMMARY_MAX_BYTES; + if (bytes > maxBytes) { + throw new VizzlyError( + `Vizzly returned an oversized compact ${resource} context`, + 'COMPACT_CONTEXT_OVERSIZED', + { bytes, max_bytes: maxBytes } + ); + } + + return context; +} + function validateSourceOption(value) { if (value == null) { return []; @@ -77,8 +188,27 @@ function parseIncludeOption(value) { return rawItems.map(item => item.trim()).filter(Boolean); } +function buildCompactContextRequest(options = {}, globalOptions = {}) { + let include = parseIncludeOption(options.include); + let compact = !options.full && (!globalOptions.json || options.agent); + let details = include.includes('diffs') ? 'diffs' : 'summary'; + + return { + compact, + details, + include, + query: compact + ? { + details, + limit: COMPACT_CONTEXT_LIMIT, + cursor: options.cursor, + } + : undefined, + }; +} + function validateIncludeOption(value) { - let allowed = new Set(['screenshots', 'diffs', 'comments']); + let allowed = new Set(['diffs']); let invalid = parseIncludeOption(value).filter(item => !allowed.has(item)); return invalid.length > 0 ? [buildIncludeErrorMessage()] : []; @@ -332,16 +462,6 @@ function getComparisonDisplayState(comparison = {}) { return comparison.result || comparison.status || 'unknown'; } -function isChangedComparison(comparison = {}) { - return ['changed', 'failed', 'pending'].includes( - getComparisonDisplayState(comparison) - ); -} - -function isNewComparison(comparison = {}) { - return getComparisonDisplayState(comparison) === 'new'; -} - function getComparisonName(comparison = {}) { return ( comparison.screenshot_name || @@ -364,142 +484,6 @@ function getComparisonFingerprint(comparison = {}) { ); } -/** - * Decide whether a comparison belongs in the agent handoff. - * - * Explicit API review state wins because an already-reviewed visual change is - * not actionable. Legacy result fallback keeps older flat responses useful - * only when the server did not supply that review fact. - * - * @param {Object} comparison - Normalized comparison record. - * @returns {boolean} Whether the record is actionable evidence. - */ -function isEvidenceCandidate(comparison = {}) { - if (comparison.needs_review != null) { - return comparison.needs_review === true; - } - - return ['changed', 'new', 'failed', 'error'].includes(comparison.result); -} - -/** - * Keep the group facts needed to understand one comparison in isolation. - * - * Repeating this small server-owned summary on each record avoids returning - * the full, potentially unbounded group tree in compact agent output. - * - * @param {Object} group - Normalized screenshot group. - * @returns {Object} Compact API-backed aggregate facts for the group. - */ -function buildEvidenceGroup(group = {}) { - let aggregate = group.aggregate_status || {}; - - return { - name: group.name || null, - variant_count: group.variant_count ?? null, - needs_review_count: aggregate.needs_review_count ?? null, - failed_count: aggregate.failed_count ?? null, - max_diff_percentage: aggregate.max_diff_percentage ?? null, - }; -} - -/** - * Shape one normalized comparison for the bounded evidence queue. - * - * The projection keeps visual result, review state, render assets, and - * Honeydiff facts together so an agent can reason about a diff without - * joining separate collections client-side. - * - * @param {Object} comparison - Normalized comparison record. - * @param {Object} group - Normalized group containing the comparison. - * @returns {Object} One self-contained comparison evidence record. - */ -function buildComparisonEvidence(comparison = {}, group = {}) { - return { - kind: 'comparison', - id: comparison.id, - name: comparison.name, - result: comparison.result, - status: comparison.status, - review_state: comparison.review_state, - visual_review: comparison.visual_review, - approval_status: comparison.approval_status, - needs_review: comparison.needs_review, - is_flaky: comparison.is_flaky, - group: buildEvidenceGroup(group), - screenshot: comparison.screenshot, - baseline: comparison.baseline, - diff: comparison.diff, - }; -} - -/** - * Represent a capture failure without pretending it is a comparison. - * - * Failed screenshots have useful render evidence but no comparison ID. The - * explicit kind and null ID prevent suggested commands from sending a - * screenshot identifier to the comparison endpoint. - * - * @param {Object} capture - Normalized failed screenshot capture. - * @returns {Object} One failed-capture evidence record. - */ -function buildFailedCaptureEvidence(capture = {}) { - return { - ...buildComparisonEvidence(capture, { name: capture.name }), - kind: 'failed_capture', - id: null, - error_message: capture.error_message, - }; -} - -/** - * Read actionable variants without overriding a server-reviewed group. - * - * A false aggregate is authoritative even when a partial variant payload - * appears pending, which prevents the client from reopening completed work. - * - * @param {Object} group - Normalized screenshot group. - * @returns {Object[]} Actionable variants in API order. - */ -function getGroupEvidence(group = {}) { - if (group.aggregate_status?.needs_review === false) { - return []; - } - - return (group.variants || []).filter(isEvidenceCandidate); -} - -/** - * Interleave actionable variants across groups in their original API order. - * - * Taking one variant per group before taking second variants preserves useful - * breadth when the final handoff is capped and one screenshot has many device - * or browser variants. - * - * @param {Object[]} groups - Normalized screenshot groups. - * @returns {Object[]} Self-contained evidence records in breadth-first order. - */ -function selectBreadthFirstEvidence(groups = []) { - let candidatesByGroup = groups.map(getGroupEvidence); - let evidence = []; - let variantIndex = 0; - let remaining = candidatesByGroup.some(candidates => candidates.length > 0); - - while (remaining) { - remaining = false; - for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) { - let comparison = candidatesByGroup[groupIndex][variantIndex]; - if (comparison) { - evidence.push(buildComparisonEvidence(comparison, groups[groupIndex])); - remaining = true; - } - } - variantIndex += 1; - } - - return evidence; -} - /** * Quote a value only when a suggested command needs shell protection. * @@ -544,37 +528,19 @@ function appendContextSource(command, context = {}) { return `${command} --source ${source}`; } -/** - * Build concrete drill-down paths from the evidence actually returned. - * - * Executable commands replace generic client-authored advice. They let an - * agent ask the API for deeper comparison, history, or raw diff context, and - * only suggest the full build when the bounded queue omitted records. - * - * @param {Object} context - Normalized build context. - * @param {Object[]} evidence - Evidence included in the compact handoff. - * @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 = [], options = {}) { - let { - evidenceOffset = 0, - evidenceTotal = evidence.length, - include = [], - } = options; +function buildCompactBuildCommands(context = {}, include = [], cursor = null) { + let evidence = context.evidence?.items || []; + let page = context.evidence?.page || {}; let commands = []; - let firstComparison = evidence.find( - item => item.kind === 'comparison' && item.id - ); - let firstNamedEvidence = evidence.find(item => item.name); let buildTarget = isLocalContext(context) ? 'current' : context.build?.id || null; + let firstComparison = evidence.find( + item => item.type === 'comparison' && item.id + ); + let firstNamedEvidence = evidence.find(item => item.screenshot_name); - if (firstComparison?.id) { + if (firstComparison) { commands.push({ label: 'Inspect comparison context', command: appendContextSource( @@ -584,41 +550,41 @@ function buildSuggestedCommands(context = {}, evidence = [], options = {}) { }); } - if (firstNamedEvidence?.name) { + if (firstNamedEvidence) { + let screenshotName = firstNamedEvidence.screenshot_name; commands.push({ label: 'Inspect screenshot history', command: appendContextSource( - `vizzly --json context screenshot ${quoteCommandArgument(firstNamedEvidence.name)}`, + `vizzly --json context screenshot ${quoteCommandArgument(screenshotName)}`, context ), }); } if (buildTarget && evidence.length > 0) { - let offsetFlag = evidenceOffset > 0 ? ` --offset ${evidenceOffset}` : ''; + let cursorFlag = cursor ? ` --cursor ${quoteCommandArgument(cursor)}` : ''; commands.push({ label: 'Load raw diff diagnostics', command: appendContextSource( - `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs${offsetFlag}`, + `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --include diffs${cursorFlag}`, context ), }); } - let nextOffset = evidenceOffset + evidence.length; - if (buildTarget && nextOffset < evidenceTotal) { + if (buildTarget && page.has_more && page.next_cursor) { 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}`, + `vizzly --json context build ${quoteCommandArgument(buildTarget)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, context ), }); } - if (buildTarget && evidenceTotal > evidence.length) { + if (buildTarget) { commands.push({ label: 'Load full build context', command: appendContextSource( @@ -631,129 +597,167 @@ function buildSuggestedCommands(context = {}, evidence = [], options = {}) { return commands; } -/** - * Create the compact agent presentation without rewriting API truth. - * - * Status, summaries, review facts, assets, and Honeydiff values pass through - * normalization from the server. The client owns only the bounded selection, - * truthful truncation facts, explicit includes, and follow-up commands. - * - * @param {Object} context - Raw build context returned by the provider. - * @param {Object} options - Compact presentation options. - * @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, evidenceOffset = 0 } = {} -) { - let includeSet = new Set(include); - let includeDiffs = includeSet.has('diffs'); - let normalized = normalizeBuildContext(context, { includeDiffs }); - let candidates = [ - ...normalized.failed_captures.map(buildFailedCaptureEvidence), - ...selectBreadthFirstEvidence(normalized.groups), - ]; - let evidence = candidates.slice( - evidenceOffset, - evidenceOffset + evidenceLimit - ); - let evidenceHasMore = evidenceOffset + evidence.length < candidates.length; - let evidenceTruncated = candidates.length > evidence.length; - let payload = { +function buildCompactBuildPayload(context, include = [], cursor = null) { + return { + ...context, resource: 'build_agent_context', - source: normalized.source || source, - review_flow: normalized.review_flow, - scope: normalized.scope || null, - project: { - organization: normalized.scope?.organization?.slug || null, - slug: normalized.scope?.project?.slug || null, - name: normalized.scope?.project?.name || null, - visibility: normalized.scope?.project?.visibility || null, - }, - build: normalized.build || null, - baseline: { - selected: normalized.baseline?.selected || null, - selection_reason: normalized.baseline?.selection_reason || null, - }, - status: normalized.status || null, - summary: normalized.summary || null, - dynamic_regions: normalized.dynamic_regions ?? 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, { - evidenceOffset, - evidenceTotal: candidates.length, - include, - }), + suggested_commands: buildCompactBuildCommands(context, include, cursor), }; +} + +function buildCompactComparisonCommands(context = {}, include = []) { + let commands = []; + let comparisonId = context.comparison?.id; + let streams = [ + ['similar_by_fingerprint', 'similar history'], + ['recent_by_name', 'recent history'], + ]; + let includeFlag = include.length > 0 ? ` --include ${include.join(',')}` : ''; + + for (let [stream, label] of streams) { + let page = context.history?.[stream]?.page; + if (!comparisonId || !page?.has_more || !page.next_cursor) { + continue; + } - if (includeSet.has('screenshots')) { - payload.screenshots = normalized.screenshots || []; + commands.push({ + label: `Load next ${label} page`, + command: appendContextSource( + `vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --cursor ${quoteCommandArgument(page.next_cursor)}${includeFlag}`, + context + ), + }); } - if (includeSet.has('comments')) { - payload.comments = normalized.comments || {}; + if (comparisonId) { + commands.push({ + label: 'Load full comparison context', + command: appendContextSource( + `vizzly --json context comparison ${quoteCommandArgument(comparisonId)} --agent --full`, + context + ), + }); } - return payload; + return commands; +} + +function buildCompactComparisonPayload(context, include = []) { + return { + ...context, + resource: 'comparison_agent_context', + suggested_commands: buildCompactComparisonCommands(context, include), + }; +} + +function formatKnownBoolean(value) { + if (value === true) return 'yes'; + if (value === false) return 'no'; + return 'unknown'; } -/** Preserve an omitted history collection instead of inventing an empty one. */ -function normalizeComparisonHistory(comparisons) { - if (!Array.isArray(comparisons)) { - return comparisons ?? null; +function formatCompactPage(collection) { + let page = collection?.page || {}; + let returned = page.returned ?? collection?.items?.length; + let total = page.total; + + if (returned == null) return 'unknown'; + if (total == null) return String(returned); + return `${returned} of ${total}${page.has_more ? ' · more available' : ''}`; +} + +function displayCompactHeading(output, context, title, status) { + let colors = output.getColors(); + let tone = getStatusTone(colors, status); + let organization = context.scope?.organization?.slug || 'unknown'; + let project = context.scope?.project?.slug || 'unknown'; + + output.print(` ${colors.bold(title)} ${tone(status.toUpperCase())}`); + output.print(` ${colors.dim(`@${organization}/${project}`)}`); + output.blank(); +} + +function displayCompactBuildContext(output, context) { + output.header('context', 'build'); + let colors = output.getColors(); + let build = context.build || {}; + let displayStatus = build.status || context.status?.state || 'unknown'; + displayCompactHeading( + output, + context, + build.name || build.id || 'unknown build', + displayStatus + ); + output.labelValue( + 'Attention', + formatKnownBoolean(context.status?.needs_review) + ); + output.labelValue('Evidence', formatCompactPage(context.evidence)); + + let items = context.evidence?.items || []; + if (items.length > 0) { + output.blank(); + output.print(' Evidence'); + for (let item of items) { + let result = item.result || item.status || 'unknown'; + let percentage = getComparisonDiffPercentage(item); + let detail = percentage == null ? '' : ` · ${percentage}% diff`; + output.print( + ` ${colors.dim('•')} ${getComparisonName(item)}: ${result}${detail}` + ); + } + } + + if (context.evidence?.page?.has_more) { + let next = context.suggested_commands?.find( + command => command.label === 'Load next evidence page' + ); + if (next) { + output.blank(); + output.labelValue('Next', next.command); + } } - return comparisons.map(comparison => normalizeComparisonRecord(comparison)); + if (context.links?.build_url) { + output.labelValue('Build URL', context.links.build_url); + } } -/** - * 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 || {}; +function displayCompactComparisonContext(output, context) { + output.header('context', 'comparison'); + let comparison = context.comparison || {}; + let displayState = getComparisonDisplayState(comparison); + displayCompactHeading( + output, + context, + getComparisonName(comparison), + displayState + ); + output.labelValue( + 'Images', + comparison.diff?.image_url || comparison.analysis?.diff_image_url + ? 'baseline/current/diff available' + : 'unavailable' + ); + output.labelValue( + 'Similar history', + formatCompactPage(context.history?.similar_by_fingerprint) + ); + output.labelValue( + 'Recent history', + formatCompactPage(context.history?.recent_by_name) + ); - 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_regions: context.dynamic_regions ?? null, - 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 || {}, - }; + let similar = context.history?.similar_by_fingerprint?.items || []; + if (similar.length > 0) { + output.blank(); + output.print(' Similar Diffs'); + printComparisonList(output, similar); + } + + if (context.links?.comparison_url) { + output.labelValue('Comparison URL', context.links.comparison_url); + } } function getBuildCommentsCount(context = {}) { @@ -1054,93 +1058,6 @@ function displayBuildContext(output, context) { } } -function formatAgentBuildContext(context) { - let comparisons = context.comparisons || []; - let changed = comparisons.filter(isChangedComparison); - let fresh = comparisons.filter(isNewComparison); - let needsReview = comparisons.filter(comparison => comparison.needs_review); - let baseline = context.baseline?.selected; - let lines = [ - `# Vizzly Visual Context: ${context.build?.name || context.build?.id || 'Build'}`, - '', - `Project: ${context.scope?.organization?.slug || 'unknown'}/${context.scope?.project?.slug || 'unknown'}`, - `Build: ${context.build?.id || 'unknown'} (${context.build?.status || 'unknown'})`, - ]; - - if (baseline) { - lines.push( - `Approved baseline: ${baseline.name || baseline.id || 'selected'} (${baseline.approval_status || 'unknown'})` - ); - } - - if (context.status) { - lines.push( - `Needs review: ${context.status.needs_review ? 'yes' : 'no'} (${context.status.pending_comparisons || 0} pending comparisons)` - ); - } - - if (context.preview?.url || context.preview?.preview_url) { - lines.push( - `Preview: ${context.preview.url || context.preview.preview_url}` - ); - } - - if (context.links?.build_url) { - lines.push(`Build URL: ${context.links.build_url}`); - } - - if (context.links?.report_url) { - lines.push(`Report: ${context.links.report_url}`); - } - - lines.push(''); - lines.push('## Diff Summary'); - lines.push(`- Total comparisons: ${comparisons.length}`); - lines.push(`- Changed: ${changed.length}`); - lines.push(`- New: ${fresh.length}`); - lines.push(`- Needs review: ${needsReview.length}`); - - if (changed.length > 0 || fresh.length > 0) { - lines.push(''); - lines.push('## Evidence To Inspect'); - - for (let comparison of [...changed, ...fresh].slice(0, 10)) { - let diffPercentage = getComparisonDiffPercentage(comparison); - let detail = diffPercentage == null ? '' : ` · ${diffPercentage}% diff`; - let diffUrl = - comparison.diff?.image_url || comparison.analysis?.diff_image_url; - lines.push( - `- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}${detail}` - ); - if (diffUrl) { - lines.push(` Diff: ${diffUrl}`); - } - } - } - - if (comparisons.length > 0 && changed.length === 0 && fresh.length === 0) { - lines.push(''); - lines.push('## Reviewed Screenshots'); - - for (let comparison of comparisons.slice(0, 10)) { - lines.push( - `- ${getComparisonName(comparison)}: ${getComparisonDisplayState(comparison)}` - ); - } - - if (comparisons.length > 10) { - lines.push(`- ...${comparisons.length - 10} more`); - } - } - - lines.push(''); - lines.push( - 'Use this as reviewed UI context. Treat approved baselines as visual truth, inspect meaningful diffs, and leave approval decisions to humans.' - ); - - return lines.join('\n'); -} - function countScreenshotCommentEntries(groups = []) { return groups.reduce( (total, group) => total + (group.comments?.length || 0), @@ -1314,11 +1231,10 @@ export async function contextBuildCommand( } let resolvedBuildId = resolveBuildContextId(buildId, runtime, deps); - let include = parseIncludeOption(options.include); - let query = - globalOptions.json && options.agent && !options.full - ? { details: include.includes('diffs') ? 'diffs' : 'summary' } - : undefined; + let { compact, details, include, query } = buildCompactContextRequest( + options, + globalOptions + ); output.startSpinner('Fetching build context...'); let context = await runtime.provider.getBuildContext( @@ -1327,14 +1243,19 @@ export async function contextBuildCommand( ); output.stopSpinner(); - if (globalOptions.json && options.agent && !options.full) { - output.data( - buildAgentBuildPayload(context, { - source: runtime.source, - include, - evidenceOffset: options.offset, - }) + if (compact) { + let compactContext = validateCompactContext(context, 'build', details); + let payload = buildCompactBuildPayload( + compactContext, + include, + options.cursor ); + + if (globalOptions.json) { + output.data(payload); + } else { + displayCompactBuildContext(output, payload); + } output.cleanup(); return; } @@ -1345,12 +1266,6 @@ export async function contextBuildCommand( return; } - if (options.agent) { - output.print(formatAgentBuildContext(context)); - output.cleanup(); - return; - } - displayBuildContext(output, context); output.cleanup(); } catch (error) { @@ -1390,11 +1305,10 @@ export async function contextComparisonCommand( if (!runtime) { return; } - let query = { - similarLimit: options.similarLimit, - recentLimit: options.recentLimit, - windowSize: options.windowSize, - }; + let { compact, details, include, query } = buildCompactContextRequest( + options, + globalOptions + ); output.startSpinner('Fetching comparison context...'); let context = await runtime.provider.getComparisonContext( @@ -1403,8 +1317,19 @@ export async function contextComparisonCommand( ); output.stopSpinner(); - if (globalOptions.json && options.agent) { - output.data(buildAgentComparisonPayload(context)); + if (compact) { + let compactContext = validateCompactContext( + context, + 'comparison', + details + ); + let payload = buildCompactComparisonPayload(compactContext, include); + + if (globalOptions.json) { + output.data(payload); + } else { + displayCompactComparisonContext(output, payload); + } output.cleanup(); return; } @@ -1594,28 +1519,15 @@ export async function contextReviewQueueCommand( export function validateContextBuildOptions(_options = {}) { let errors = validateSourceOption(_options.source); errors.push(...validateIncludeOption(_options.include)); - errors.push(...validateOffset(_options.offset)); + errors.push(...validateCursor(_options.cursor)); return errors; } export function validateContextComparisonOptions(options = {}) { let errors = []; errors.push(...validateSourceOption(options.source)); - errors.push( - ...validateLimitRange(options.similarLimit, '--similar-limit', { - max: 50, - }) - ); - errors.push( - ...validateLimitRange(options.recentLimit, '--recent-limit', { - max: 50, - }) - ); - errors.push( - ...validateLimitRange(options.windowSize, '--window-size', { - max: 50, - }) - ); + errors.push(...validateIncludeOption(options.include)); + errors.push(...validateCursor(options.cursor)); return errors; } diff --git a/src/context/local-workspace-provider.js b/src/context/local-workspace-provider.js index fc79d81b..fa9686a0 100644 --- a/src/context/local-workspace-provider.js +++ b/src/context/local-workspace-provider.js @@ -372,6 +372,121 @@ function mapLocalComparison(snapshot, comparison) { }; } +function projectLocalScreenshot(screenshot, baseline) { + if (!screenshot) return null; + + return { + id: screenshot.id, + name: screenshot.name, + browser: screenshot.browser, + viewport: { + width: screenshot.viewport_width, + height: screenshot.viewport_height, + }, + url: screenshot.original_url, + baseline: baseline + ? { + id: baseline.id, + build_id: baseline.build_id, + name: baseline.name, + browser: baseline.browser, + viewport: { + width: baseline.viewport_width, + height: baseline.viewport_height, + }, + url: baseline.original_url, + } + : null, + }; +} + +function projectLocalDiff(diff, includeDiffs) { + let projected = { + percentage: diff?.percentage ?? null, + changed_pixels: diff?.changed_pixels ?? null, + total_pixels: diff?.total_pixels ?? null, + threshold: diff?.threshold ?? null, + image_url: diff?.image_url ?? null, + fingerprint_hash: diff?.fingerprint_hash ?? null, + region_count: Array.isArray(diff?.regions) ? diff.regions.length : null, + }; + + if (includeDiffs) { + projected.regions = diff?.regions || []; + projected.cluster_metadata = diff?.cluster_metadata ?? null; + projected.fingerprint_data = diff?.fingerprint_data ?? null; + projected.diff_lines = diff?.diff_lines ?? []; + } + + return projected; +} + +function projectLocalEvidence(comparison, includeDiffs) { + return { + type: 'comparison', + id: comparison.id, + screenshot_name: comparison.screenshot_name, + status: comparison.status, + result: comparison.result, + approval_status: comparison.approval_status, + needs_review: comparison.needs_review, + build_id: comparison.build_id, + build_name: comparison.build_name, + build_branch: comparison.build_branch, + build_commit_sha: comparison.build_commit_sha, + build_created_at: comparison.build_created_at, + screenshot: projectLocalScreenshot( + comparison.screenshot, + comparison.baseline + ), + diff: projectLocalDiff(comparison.diff, includeDiffs), + }; +} + +function projectLocalFocusedComparison(comparison, includeDiffs) { + if (includeDiffs) return comparison; + + let analysis = comparison.analysis || {}; + let { + diff_regions: _diffRegions, + cluster_metadata: _clusterMetadata, + fingerprint_data: _fingerprintData, + diff_lines: _diffLines, + ...summaryAnalysis + } = analysis; + + return { + ...comparison, + diff: projectLocalDiff(comparison.diff, false), + analysis: summaryAnalysis, + }; +} + +function projectLocalHistoryItem(comparison) { + return { + id: comparison.id, + screenshot_name: comparison.screenshot_name, + result: comparison.result, + status: comparison.status, + needs_review: comparison.needs_review, + build_id: comparison.build_id, + build_name: comparison.build_name, + build_branch: comparison.build_branch, + build_created_at: comparison.build_created_at, + screenshot: projectLocalScreenshot( + comparison.screenshot, + comparison.baseline + ), + diff: { + percentage: comparison.diff?.percentage ?? null, + fingerprint_hash: comparison.diff?.fingerprint_hash ?? null, + region_count: Array.isArray(comparison.diff?.regions) + ? comparison.diff.regions.length + : null, + }, + }; +} + function buildReviewSummary(comparisons = []) { let approved = comparisons.filter( comparison => mapApprovalStatus(comparison.status) === 'approved' @@ -522,7 +637,88 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { }; } - function getBuildContext(buildId) { + function createLocalCursor( + resource, + target, + query, + stream, + offset, + revision + ) { + return Buffer.from( + JSON.stringify({ + version: 1, + resource, + target, + query, + stream, + offset, + revision, + }) + ).toString('base64url'); + } + + function readLocalCursor(cursor, resource, target, query, revision, streams) { + if (!cursor) return null; + + try { + let parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString()); + if ( + parsed.version !== 1 || + parsed.resource !== resource || + parsed.target !== target || + parsed.query !== query || + parsed.revision !== revision || + !streams.includes(parsed.stream) || + !Number.isInteger(parsed.offset) || + parsed.offset < 0 + ) { + throw new Error('mismatch'); + } + return parsed; + } catch { + throw createLocalWorkspaceError( + 'The local context cursor is invalid or the local results changed. Start again without --cursor.' + ); + } + } + + function createLocalPage( + items, + { limit, offset, resource, target, query, stream, revision } + ) { + if (offset > items.length) { + throw createLocalWorkspaceError( + 'The local context cursor is invalid or the local results changed. Start again without --cursor.' + ); + } + + let pageItems = items.slice(offset, offset + limit); + let nextOffset = offset + pageItems.length; + let hasMore = nextOffset < items.length; + + return { + items: pageItems, + page: { + limit, + returned: pageItems.length, + total: items.length, + has_more: hasMore, + next_cursor: hasMore + ? createLocalCursor( + resource, + target, + query, + stream, + nextOffset, + revision + ) + : null, + }, + }; + } + + function getBuildContext(buildId, query = {}) { let snapshot = loadSnapshot(); let resolvedBuild = buildBuildSnapshot(snapshot); @@ -547,7 +743,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons); let reviewState = buildReviewState(resolvedBuild, reviewSummary); - return { + let context = { resource: 'build_context', source: LOCAL_CONTEXT_SOURCE, scope: createScope(), @@ -582,9 +778,47 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { }, links: createBuildLinks(snapshot), }; + + if (!query.details) return context; + + let revision = String(snapshot.reportData.timestamp || resolvedBuild.id); + let limit = query.limit || 10; + let cursorQuery = `${query.details}:${limit}`; + let cursor = readLocalCursor( + query.cursor, + 'build_context', + resolvedBuild.id, + cursorQuery, + revision, + ['evidence'] + ); + let includeDiffs = query.details === 'diffs'; + let evidence = mappedComparisons.map(comparison => + projectLocalEvidence(comparison, includeDiffs) + ); + + return { + resource: 'build_context', + source: LOCAL_CONTEXT_SOURCE, + scope: context.scope, + build: context.build, + baseline: context.baseline, + status: context.status, + summary: context.summary, + evidence: createLocalPage(evidence, { + limit, + offset: cursor?.offset || 0, + resource: 'build_context', + target: resolvedBuild.id, + query: cursorQuery, + stream: 'evidence', + revision, + }), + links: context.links, + }; } - function getComparisonContext(comparisonId) { + function getComparisonContext(comparisonId, query = {}) { let snapshot = loadSnapshot(); let comparison = findComparison(snapshot, comparisonId); @@ -604,7 +838,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { ) .map(candidate => mapLocalComparison(snapshot, candidate)); - return { + let context = { resource: 'comparison_context', source: LOCAL_CONTEXT_SOURCE, scope: createScope(), @@ -641,6 +875,57 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { }, links: buildComparisonLinks(snapshot, comparison.id), }; + + if (!query.details) return context; + + let revision = String(snapshot.reportData.timestamp || context.build.id); + let limit = query.limit || 10; + let cursorQuery = `${query.details}:${limit}`; + let cursor = readLocalCursor( + query.cursor, + 'comparison_context', + comparison.id, + cursorQuery, + revision, + ['similar_by_fingerprint', 'recent_by_name'] + ); + let activeStream = cursor?.stream; + let similarOffset = + activeStream === 'similar_by_fingerprint' ? cursor.offset : 0; + let recentOffset = activeStream === 'recent_by_name' ? cursor.offset : 0; + + return { + resource: 'comparison_context', + source: LOCAL_CONTEXT_SOURCE, + scope: context.scope, + build: context.build, + comparison: projectLocalFocusedComparison( + context.comparison, + query.details === 'diffs' + ), + history: { + similar_by_fingerprint: createLocalPage([], { + limit, + offset: similarOffset, + resource: 'comparison_context', + target: comparison.id, + query: cursorQuery, + stream: 'similar_by_fingerprint', + revision, + }), + recent_by_name: createLocalPage(history.map(projectLocalHistoryItem), { + limit, + offset: recentOffset, + resource: 'comparison_context', + target: comparison.id, + query: cursorQuery, + stream: 'recent_by_name', + revision, + }), + }, + review: context.review, + links: context.links, + }; } function getScreenshotContext(screenshotName) { diff --git a/tests/api/endpoints.test.js b/tests/api/endpoints.test.js index 48c98fb1..d5c16e96 100644 --- a/tests/api/endpoints.test.js +++ b/tests/api/endpoints.test.js @@ -132,11 +132,15 @@ describe('api/endpoints', () => { it('includes build context detail params when provided', async () => { let client = createMockClient({ resource: 'build_context' }); - await getBuildContext(client, 'build-123', { details: 'summary' }); + await getBuildContext(client, 'build-123', { + details: 'summary', + limit: 10, + cursor: 'opaque-page-2', + }); assert.strictEqual( client.getLastCall().endpoint, - '/api/sdk/context/builds/build-123?details=summary' + '/api/sdk/context/builds/build-123?details=summary&limit=10&cursor=opaque-page-2' ); }); @@ -144,14 +148,14 @@ describe('api/endpoints', () => { let client = createMockClient({ resource: 'comparison_context' }); await getComparisonContext(client, 'comparison-123', { - similarLimit: 5, - recentLimit: 4, - windowSize: 12, + details: 'diffs', + limit: 10, + cursor: 'opaque-similar-page-2', }); assert.strictEqual( client.getLastCall().endpoint, - '/api/sdk/context/comparisons/comparison-123?similarLimit=5&recentLimit=4&windowSize=12' + '/api/sdk/context/comparisons/comparison-123?details=diffs&limit=10&cursor=opaque-similar-page-2' ); }); diff --git a/tests/commands/context-cli.test.js b/tests/commands/context-cli.test.js index 59ba442b..6a627ee3 100644 --- a/tests/commands/context-cli.test.js +++ b/tests/commands/context-cli.test.js @@ -303,19 +303,108 @@ async function withBuildContextApi(callback) { requests.push(req.url); res.setHeader('content-type', 'application/json'); + if (req.url.startsWith('/api/sdk/context/builds/oversized')) { + res.end( + JSON.stringify({ + resource: 'build_context', + build: { id: 'oversized' }, + evidence: { + items: [], + page: { + limit: 10, + returned: 0, + total: 0, + has_more: false, + next_cursor: null, + }, + }, + padding: 'x'.repeat(70 * 1024), + }) + ); + return; + } + + if (req.url.startsWith('/api/sdk/context/builds/invalid')) { + res.end(JSON.stringify({ resource: 'build_context' })); + return; + } + + if (req.url.startsWith('/api/sdk/context/comparisons/partial')) { + res.end( + JSON.stringify({ + resource: 'comparison_context', + comparison: { id: 'partial' }, + history: { + similar_by_fingerprint: { + items: [], + page: { + limit: 10, + returned: 0, + total: 0, + has_more: false, + next_cursor: null, + }, + }, + recent_by_name: { + items: [], + page: { + limit: 10, + returned: 0, + total: null, + has_more: false, + next_cursor: null, + }, + }, + }, + }) + ); + return; + } + if (req.url.startsWith('/api/sdk/context/comparisons/')) { let comparison = { ...comparisons[0], diff: undefined, analysis: comparisons[0].diff, }; + let url = new URL(req.url, 'http://127.0.0.1'); + let compact = url.searchParams.has('details'); res.end( JSON.stringify({ resource: 'comparison_context', review_flow: 'cricket_v1', + scope: { + organization: { slug: 'acme' }, + project: { slug: 'web', name: 'Web' }, + }, + build: { id: 'build-123', status: 'completed' }, comparison, dynamic_regions: createDynamicRegionResolutionContext(), + history: compact + ? { + similar_by_fingerprint: { + items: [comparisons[1]], + page: { + limit: 10, + returned: 1, + total: 2, + has_more: true, + next_cursor: 'similar-page-2', + }, + }, + recent_by_name: { + items: [comparisons[2]], + page: { + limit: 10, + returned: 1, + total: 1, + has_more: false, + next_cursor: null, + }, + }, + } + : undefined, }) ); return; @@ -355,6 +444,21 @@ async function withBuildContextApi(callback) { return; } + let url = new URL(req.url, 'http://127.0.0.1'); + let details = url.searchParams.get('details'); + let cursor = url.searchParams.get('cursor'); + let pageComparisons = cursor + ? comparisons.slice(10) + : comparisons.slice(0, 10); + let evidence = pageComparisons.map(comparison => ({ + ...comparison, + type: 'comparison', + diff: { + ...comparison.diff, + regions: details === 'diffs' ? comparison.diff.regions : undefined, + }, + })); + res.end( JSON.stringify({ resource: 'build_context', @@ -366,11 +470,23 @@ async function withBuildContextApi(callback) { build: { id: 'build-123', status: 'completed' }, status: { needs_review: true, pending_comparisons: 11 }, summary: { comparisons: { total: 11, changed: 11 } }, - dynamic_regions: req.url.includes('details=') + dynamic_regions: details ? diagnosticDynamicRegions : completeDynamicRegions, - groups, - comparisons, + evidence: details + ? { + items: evidence, + page: { + limit: 10, + returned: evidence.length, + total: comparisons.length, + has_more: !cursor, + next_cursor: cursor ? null : 'build-page-2', + }, + } + : undefined, + groups: details ? undefined : groups, + comparisons: details ? undefined : comparisons, }) ); }); @@ -445,30 +561,36 @@ describe('context CLI integration', () => { assert.strictEqual(compact.code, 0); let compactPayload = JSON.parse(compact.stdout).data; - assert.strictEqual(compactPayload.evidence_returned, 10); - assert.strictEqual(compactPayload.evidence_truncated, true); + assert.strictEqual(compactPayload.evidence.page.returned, 10); + assert.strictEqual(compactPayload.evidence.page.has_more, true); assert.strictEqual(compactPayload.source, 'cloud'); assert.strictEqual(compactPayload.review_flow, 'legacy'); - assert.strictEqual(compactPayload.evidence[0].review_state, 'pending'); - assert.strictEqual(compactPayload.evidence[0].id, 'comparison-1'); - assert.strictEqual(compactPayload.evidence[0].name, 'Screenshot 1'); - assert.strictEqual(compactPayload.evidence[0].is_flaky, false); + assert.strictEqual(compactPayload.evidence.items[0].id, 'comparison-1'); + assert.strictEqual( + compactPayload.evidence.items[0].screenshot_name, + 'Screenshot 1' + ); assert.deepStrictEqual( compactPayload.dynamic_regions, diagnosticDynamicRegions ); - assert.strictEqual(compactPayload.evidence[0].screenshot.id, 'current-1'); - assert.strictEqual(compactPayload.evidence[0].baseline.id, 'baseline-1'); - assert.strictEqual(compactPayload.evidence[0].diff.total_pixels, 5184000); assert.strictEqual( - compactPayload.evidence[0].screenshot.url, + compactPayload.evidence.items[0].screenshot.id, + 'current-1' + ); + assert.strictEqual( + compactPayload.evidence.items[0].diff.total_pixels, + 5184000 + ); + assert.strictEqual( + compactPayload.evidence.items[0].screenshot.url, 'https://cdn.test/current-1.png' ); assert.strictEqual( - compactPayload.evidence[0].diff.image_url, + compactPayload.evidence.items[0].diff.image_url, 'https://cdn.test/diff-1.png' ); - assert.deepStrictEqual(compactPayload.evidence[0].diff.artifacts, { + assert.deepStrictEqual(compactPayload.evidence.items[0].diff.artifacts, { analysis: { available: true, schema_version: 2, @@ -494,7 +616,7 @@ describe('context CLI integration', () => { coordinate_space_version: 'bitmap-top-left-v1', }, }); - assert.ok(!compactPayload.evidence[0].diff.regions); + assert.ok(!compactPayload.evidence.items[0].diff.regions); assert.ok(!compactPayload.groups); assert.ok(!compactPayload.next_actions); @@ -505,18 +627,17 @@ describe('context CLI integration', () => { 'build', 'build-123', '--agent', - '--offset', - '10', + '--cursor', + 'build-page-2', ], { 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'); + assert.strictEqual(nextPagePayload.evidence.page.returned, 1); + assert.strictEqual(nextPagePayload.evidence.page.has_more, false); + assert.strictEqual(nextPagePayload.evidence.items[0].id, 'comparison-11'); let withDiffs = await runCLI( [ @@ -533,7 +654,7 @@ describe('context CLI integration', () => { assert.strictEqual(withDiffs.code, 0); let diffPayload = JSON.parse(withDiffs.stdout).data; - assert.deepStrictEqual(diffPayload.evidence[0].diff.regions, [ + assert.deepStrictEqual(diffPayload.evidence.items[0].diff.regions, [ { x: 10, y: 20, width: 30, height: 40 }, ]); @@ -549,14 +670,89 @@ describe('context CLI integration', () => { completeDynamicRegions ); 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', + '/api/sdk/context/builds/build-123?details=summary&limit=10', + '/api/sdk/context/builds/build-123?details=summary&limit=10&cursor=build-page-2', + '/api/sdk/context/builds/build-123?details=diffs&limit=10', '/api/sdk/context/builds/build-123', ]); }); }); + it('renders the same compact build facts for a human', async () => { + await withBuildContextApi(async ({ apiUrl, requests }) => { + let result = await runCLI( + ['context', 'build', 'build-123', '--source', 'cloud', '--no-color'], + { + cwd: mkdtempSync(join(tmpdir(), 'vizzly-context-human-')), + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 0, result.stderr); + assert.match(result.stdout, /Attention:\s+yes/); + assert.match(result.stdout, /Evidence:\s+10 of 11 · more available/); + assert.match(result.stdout, /--cursor build-page-2 --source cloud/); + assert.ok(!result.stdout.includes('Eyes')); + assert.ok(!result.stdout.includes('Memory')); + assert.deepStrictEqual(requests, [ + '/api/sdk/context/builds/build-123?details=summary&limit=10', + ]); + }); + }); + + it('keeps partial compact comparison facts honest and readable', async () => { + await withBuildContextApi(async ({ apiUrl }) => { + let result = await runCLI( + ['context', 'comparison', 'partial', '--source', 'cloud', '--no-color'], + { + cwd: mkdtempSync(join(tmpdir(), 'vizzly-context-partial-')), + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + + assert.strictEqual(result.code, 0, result.stderr); + assert.match(result.stdout, /@unknown\/unknown/); + assert.match(result.stdout, /Images:\s+unavailable/); + assert.match(result.stdout, /Similar history:\s+0 of 0/); + assert.match(result.stdout, /Recent history:\s+0/); + }); + }); + + it('fails loudly when compact API output breaks its bounds or schema', async () => { + await withBuildContextApi(async ({ apiUrl }) => { + let env = { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }; + let cwd = mkdtempSync(join(tmpdir(), 'vizzly-context-guard-')); + let oversized = await runCLI( + ['--json', 'context', 'build', 'oversized', '--agent'], + { cwd, env } + ); + let invalid = await runCLI( + ['--json', 'context', 'build', 'invalid', '--agent'], + { cwd, env } + ); + + assert.strictEqual(oversized.code, 1); + assert.strictEqual( + JSON.parse(oversized.stderr).error.code, + 'COMPACT_CONTEXT_OVERSIZED' + ); + assert.strictEqual(invalid.code, 1); + assert.strictEqual( + JSON.parse(invalid.stderr).error.code, + 'COMPACT_CONTEXT_INVALID' + ); + }); + }); + it('normalizes focused comparison evidence through the real CLI', async () => { await withBuildContextApi(async ({ apiUrl }) => { let cwd = mkdtempSync(join(tmpdir(), 'vizzly-context-comparison-')); @@ -583,10 +779,10 @@ describe('context CLI integration', () => { 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, [ + assert.deepStrictEqual(payload.comparison.analysis.regions, [ { x: 10, y: 20, width: 30, height: 40 }, ]); - assert.deepStrictEqual(payload.comparison.diff.artifacts.diff_mask, { + assert.deepStrictEqual(payload.comparison.analysis.artifacts.diff_mask, { evidence_status: 'complete', available: true, complete: true, @@ -608,6 +804,59 @@ describe('context CLI integration', () => { payload.dynamic_regions, createDynamicRegionResolutionContext() ); + + let withDiffs = await runCLI( + [ + '--json', + 'context', + 'comparison', + 'comparison-1', + '--agent', + '--source', + 'cloud', + '--include', + 'diffs', + ], + { + cwd, + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + let withDiffsPayload = JSON.parse(withDiffs.stdout).data; + assert.ok( + withDiffsPayload.suggested_commands.some(command => + command.command.includes( + '--cursor similar-page-2 --include diffs --source cloud' + ) + ) + ); + + let full = await runCLI( + [ + '--json', + 'context', + 'comparison', + 'comparison-1', + '--agent', + '--source', + 'cloud', + '--full', + ], + { + cwd, + env: { + VIZZLY_API_URL: apiUrl, + VIZZLY_TOKEN: 'vzt_test_token', + }, + } + ); + let fullPayload = JSON.parse(full.stdout).data; + assert.strictEqual(fullPayload.resource, 'comparison_context'); + assert.ok(!fullPayload.suggested_commands); + assert.strictEqual(fullPayload.comparison.analysis.regions.length, 1); }); }); @@ -685,9 +934,9 @@ describe('context CLI integration', () => { let payload = JSON.parse(result.stdout).data; assert.strictEqual(payload.source, 'cloud'); assert.strictEqual(payload.build.id, 'build-123'); - assert.strictEqual(payload.evidence[0].id, 'comparison-1'); + assert.strictEqual(payload.evidence.items[0].id, 'comparison-1'); assert.deepStrictEqual(requests, [ - '/api/sdk/context/builds/build-123?details=summary', + '/api/sdk/context/builds/build-123?details=summary&limit=10', ]); }); }); diff --git a/tests/commands/context.test.js b/tests/commands/context.test.js index 5964ebf4..56144a7c 100644 --- a/tests/commands/context.test.js +++ b/tests/commands/context.test.js @@ -14,8 +14,8 @@ describe('commands/context', () => { assert.deepStrictEqual( validateContextBuildOptions({ source: 'cloud', - include: 'screenshots,diffs,comments', - offset: 0, + include: 'diffs', + cursor: 'opaque-page-2', }), [] ); @@ -25,24 +25,19 @@ describe('commands/context', () => { assert.deepStrictEqual( validateContextBuildOptions({ source: 'moon', - include: 'screenshots,logs', + include: 'screenshots', }), [ '--source must be one of: auto, cloud, local', - '--include must contain only: screenshots, diffs, comments', + '--include must contain only: diffs', ] ); }); - it('rejects invalid context limits and offsets', () => { + it('rejects invalid cursors, review offsets, and result limits', () => { assert.ok( - validateContextComparisonOptions({ similarLimit: 51 }).includes( - '--similar-limit must be an integer between 1 and 50' - ) - ); - assert.ok( - validateContextComparisonOptions({ recentLimit: 4.5 }).includes( - '--recent-limit must be an integer between 1 and 50' + validateContextComparisonOptions({ cursor: '' }).includes( + '--cursor must be a non-empty opaque cursor' ) ); assert.ok( diff --git a/tests/context/local-workspace-provider.test.js b/tests/context/local-workspace-provider.test.js index 3e48c471..0fc8c028 100644 --- a/tests/context/local-workspace-provider.test.js +++ b/tests/context/local-workspace-provider.test.js @@ -383,4 +383,101 @@ describe('context/local-workspace-provider', () => { assert.strictEqual(context.comparisons[0].needs_review, true); assert.strictEqual(context.comparisons[0].diff.regions.length, 1); }); + + it('pages bounded local context and keeps cursors tied to their target', () => { + let projectRoot = '/tmp/vizzly-local-compact-context'; + let paths = createWorkspacePaths(projectRoot); + let comparisons = Array.from({ length: 12 }, (_, index) => ({ + id: `comp-${index}`, + name: 'Dashboard', + originalName: 'Dashboard', + status: 'failed', + current: `/images/current/${index}.png`, + baseline: `/images/baselines/${index}.png`, + diff: `/images/diffs/${index}.png`, + diffPercentage: index + 0.5, + properties: { browser: 'chrome' }, + })); + let comparisonDetails = Object.fromEntries( + comparisons.map(comparison => [ + comparison.id, + { diffClusters: [{ x: 1, y: 2, width: 3, height: 4 }] }, + ]) + ); + let provider = createLocalWorkspaceContextProvider( + { projectRoot }, + { + readJsonIfExists: path => { + if (path === paths.report) { + return { timestamp: 1234, comparisons }; + } + if (path === paths.comparisonDetails) return comparisonDetails; + return null; + }, + } + ); + + let buildSummary = provider.getBuildContext('current', { + details: 'summary', + limit: 10, + }); + assert.strictEqual(buildSummary.evidence.items.length, 10); + assert.strictEqual(buildSummary.evidence.page.has_more, true); + assert.ok(!buildSummary.evidence.items[0].diff.regions); + + let nextBuildPage = provider.getBuildContext('current', { + details: 'summary', + limit: 10, + cursor: buildSummary.evidence.page.next_cursor, + }); + assert.deepStrictEqual( + nextBuildPage.evidence.items.map(item => item.id), + ['comp-10', 'comp-11'] + ); + + let buildDiffs = provider.getBuildContext('current', { + details: 'diffs', + limit: 10, + }); + assert.strictEqual(buildDiffs.evidence.items[0].diff.regions.length, 1); + assert.throws( + () => + provider.getBuildContext('current', { + details: 'diffs', + limit: 10, + cursor: buildSummary.evidence.page.next_cursor, + }), + /cursor is invalid|results changed/ + ); + + let comparisonSummary = provider.getComparisonContext('comp-0', { + details: 'summary', + limit: 10, + }); + assert.ok(!comparisonSummary.comparison.analysis.diff_regions); + assert.ok(!comparisonSummary.history.recent_by_name.items[0].analysis); + assert.strictEqual( + comparisonSummary.history.recent_by_name.page.has_more, + true + ); + + let comparisonDiffs = provider.getComparisonContext('comp-0', { + details: 'diffs', + limit: 10, + }); + assert.strictEqual( + comparisonDiffs.comparison.analysis.diff_regions.length, + 1 + ); + + assert.throws( + () => + provider.getComparisonContext('comp-1', { + details: 'summary', + limit: 10, + cursor: comparisonSummary.history.recent_by_name.page.next_cursor, + }), + /cursor is invalid|results changed/ + ); + }); }); From d598176fc978a17ee425703999c491f2a70b82af Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Fri, 28 Aug 2026 12:57:53 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20Close=20agent=20context=20co?= =?UTF-8?q?ntract=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve typed API recovery signals, bind local cursors to snapshot contents, keep local and cloud compact shapes aligned, and enforce payload bounds after adding follow-up commands. --- README.md | 4 +- docs/json-output.md | 10 +- src/api/client.js | 1 + src/api/core.js | 23 +++- src/cli.js | 2 - src/commands/context.js | 46 ++++--- src/context/local-workspace-provider.js | 115 ++++++++++++------ tests/api/client.test.js | 30 +++++ tests/api/core.test.js | 16 +++ tests/commands/context-cli.test.js | 33 +++++ .../context/local-workspace-provider.test.js | 55 +++++++++ 11 files changed, 275 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index ee067ac1..461e7f87 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,6 @@ evidence in one place. ```bash # Cloud context for a build or comparison vizzly context build abc123 --source cloud -vizzly context build abc123 --source cloud --agent --json --cursor eyJ2IjoxfQ vizzly context comparison def456 --source cloud --agent --json # Local workspace context from .vizzly/ @@ -113,7 +112,8 @@ vizzly context review-queue --source local --json `--json` is the durable automation path. `--agent` gives you the compact API handoff for prompt assembly. Build handoffs contain up to 10 records; use the -returned next-page command or opaque `--cursor` to continue safely. Add +exact next-page command returned in `suggested_commands` to continue safely. +That command carries the API's opaque `--cursor`. Add `--include diffs` for raw diff diagnostics, or `--full` when you need the whole payload. diff --git a/docs/json-output.md b/docs/json-output.md index 76180d3b..6be1f57e 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -264,7 +264,6 @@ 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 --cursor eyJ2IjoxfQ vizzly context build abc123 --source cloud --agent --json --include diffs vizzly context build abc123 --source cloud --agent --json --full vizzly context build current --source local --json @@ -320,7 +319,7 @@ Compact agent JSON: "id": "cmp-1", "screenshot_name": "Dashboard", "result": "changed", - "review_state": "pending", + "approval_status": "pending", "needs_review": true, "screenshot": { "id": "current-1", @@ -348,7 +347,7 @@ Compact agent JSON: "returned": 1, "total": 12, "has_more": true, - "next_cursor": "eyJ2IjoxfQ" + "next_cursor": "opaque-cursor-returned-by-api" } }, "suggested_commands": [ @@ -366,7 +365,7 @@ Compact agent JSON: }, { "label": "Load next evidence page", - "command": "vizzly --json context build abc123 --agent --cursor eyJ2IjoxfQ --source cloud" + "command": "vizzly --json context build abc123 --agent --cursor opaque-cursor-returned-by-api --source cloud" } ] } @@ -452,7 +451,6 @@ 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 cmp-1 --source cloud --agent --json --cursor eyJ2IjoxfQ vizzly context comparison cmp-1 --source cloud --agent --json --include diffs vizzly context comparison build-detail-screenshots --source local --json ``` @@ -471,7 +469,7 @@ Agent comparison JSON: "id": "cmp-1", "screenshot_name": "Dashboard", "result": "changed", - "review_state": "pending", + "approval_status": "pending", "screenshot": { "url": "https://.../current.png" }, diff --git a/src/api/client.js b/src/api/client.js index 35f3ef33..d79a37be 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -130,6 +130,7 @@ export function createApiClient(options = {}) { let error = parseApiError(response.status, errorBody, url); throw new VizzlyError(error.message, error.code, { status: error.status, + details: error.details, }); } diff --git a/src/api/core.js b/src/api/core.js index 8f35c276..8e081424 100644 --- a/src/api/core.js +++ b/src/api/core.js @@ -254,9 +254,23 @@ export function shouldRetryWithRefresh(status, isRetry, hasRefreshToken) { */ export function parseApiError(status, body, url) { let message = `API request failed: ${status}`; + let responseDetails = null; + let responseCode = null; + let responseMessage = body; if (body) { - message += ` - ${body}`; + try { + let parsedBody = JSON.parse(body); + responseDetails = parsedBody.details ?? null; + responseCode = parsedBody.code ?? parsedBody.details?.code ?? null; + responseMessage = parsedBody.error ?? parsedBody.message ?? body; + } catch { + // Plain-text API errors remain valid and are surfaced unchanged. + } + } + + if (responseMessage) { + message += ` - ${responseMessage}`; } message += ` (URL: ${url})`; @@ -268,7 +282,12 @@ export function parseApiError(status, body, url) { if (status === 429) code = 'RATE_LIMITED'; if (status >= 500) code = 'SERVER_ERROR'; - return { message, code, status }; + return { + message, + code: responseCode || code, + status, + details: responseDetails, + }; } /** diff --git a/src/cli.js b/src/cli.js index 109de1df..4ebc0db0 100644 --- a/src/cli.js +++ b/src/cli.js @@ -995,7 +995,6 @@ 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 --cursor eyJ2IjoxfQ $ vizzly context build abc123 --source cloud --agent --json --include diffs $ vizzly context build abc123 --source cloud --agent --json --full ` @@ -1031,7 +1030,6 @@ contextCmd Examples: $ vizzly context comparison def456 --source cloud $ vizzly context comparison def456 --source local - $ vizzly context comparison def456 --source cloud --cursor eyJ2IjoxfQ $ vizzly context comparison def456 --source cloud --json $ vizzly context comparison def456 --source cloud --agent --json ` diff --git a/src/commands/context.js b/src/commands/context.js index 2414023d..21fbe693 100644 --- a/src/commands/context.js +++ b/src/commands/context.js @@ -136,7 +136,7 @@ function getCompactPage(collection, label) { return collection; } -function validateCompactContext(context, resource, details) { +function validateCompactContext(context, resource) { if (!context || typeof context !== 'object') { throw new VizzlyError( `Vizzly returned an invalid compact ${resource} context`, @@ -144,6 +144,13 @@ function validateCompactContext(context, resource, details) { ); } + if (context.resource !== `${resource}_context` || !context[resource]?.id) { + throw new VizzlyError( + `Vizzly returned an invalid compact ${resource} context`, + 'COMPACT_CONTEXT_INVALID' + ); + } + if (resource === 'build') { getCompactPage(context.evidence, 'build evidence'); } else { @@ -152,19 +159,23 @@ function validateCompactContext(context, resource, details) { getCompactPage(history.recent_by_name, 'recent history'); } - let { source: _source, ...providerContext } = context; - let bytes = Buffer.byteLength(JSON.stringify(providerContext)); + return context; +} + +function validateCompactOutputSize(payload, resource, details) { + let bytes = Buffer.byteLength(JSON.stringify(payload)); let maxBytes = details === 'diffs' ? COMPACT_DIFFS_MAX_BYTES : COMPACT_SUMMARY_MAX_BYTES; + if (bytes > maxBytes) { throw new VizzlyError( - `Vizzly returned an oversized compact ${resource} context`, + `Vizzly produced an oversized compact ${resource} context`, 'COMPACT_CONTEXT_OVERSIZED', { bytes, max_bytes: maxBytes } ); } - return context; + return payload; } function validateSourceOption(value) { @@ -253,7 +264,7 @@ function createClient(config, createApiClient) { * @returns {Object} Context payload with explicit source provenance. */ function attachContextSource(context, source) { - return { ...context, source: context?.source || source }; + return { ...context, source }; } async function loadContextConfig(globalOptions, options, deps) { @@ -755,6 +766,13 @@ function displayCompactComparisonContext(output, context) { printComparisonList(output, similar); } + let recent = context.history?.recent_by_name?.items || []; + if (recent.length > 0) { + output.blank(); + output.print(' Recent Diffs'); + printComparisonList(output, recent); + } + if (context.links?.comparison_url) { output.labelValue('Comparison URL', context.links.comparison_url); } @@ -1244,11 +1262,11 @@ export async function contextBuildCommand( output.stopSpinner(); if (compact) { - let compactContext = validateCompactContext(context, 'build', details); - let payload = buildCompactBuildPayload( - compactContext, - include, - options.cursor + let compactContext = validateCompactContext(context, 'build'); + let payload = validateCompactOutputSize( + buildCompactBuildPayload(compactContext, include, options.cursor), + 'build', + details ); if (globalOptions.json) { @@ -1318,12 +1336,12 @@ export async function contextComparisonCommand( output.stopSpinner(); if (compact) { - let compactContext = validateCompactContext( - context, + let compactContext = validateCompactContext(context, 'comparison'); + let payload = validateCompactOutputSize( + buildCompactComparisonPayload(compactContext, include), 'comparison', details ); - let payload = buildCompactComparisonPayload(compactContext, include); if (globalOptions.json) { output.data(payload); diff --git a/src/context/local-workspace-provider.js b/src/context/local-workspace-provider.js index fa9686a0..bfb5d7b8 100644 --- a/src/context/local-workspace-provider.js +++ b/src/context/local-workspace-provider.js @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { existsSync as defaultExistsSync, readFileSync } from 'node:fs'; import { basename, isAbsolute, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -253,35 +254,6 @@ function buildReviewState(build, reviewSummary) { }; } -function mapLocalScreenshot(snapshot, comparison) { - let mapped = mapLocalComparison(snapshot, comparison); - let baselineBuildId = snapshot.baselineMetadata?.buildId || null; - - return { - id: mapped.screenshot.id, - name: mapped.screenshot.name, - browser: mapped.screenshot.browser, - viewport: { - width: mapped.screenshot.viewport_width, - height: mapped.screenshot.viewport_height, - }, - url: mapped.screenshot.original_url, - baseline: mapped.baseline - ? { - id: mapped.baseline.id, - build_id: baselineBuildId, - name: mapped.baseline.name, - browser: mapped.baseline.browser, - viewport: { - width: mapped.baseline.viewport_width, - height: mapped.baseline.viewport_height, - }, - url: mapped.baseline.original_url, - } - : null, - }; -} - function mapLocalComparison(snapshot, comparison) { let details = snapshot.comparisonDetails[comparison.id] || {}; let comparisonName = comparison.originalName || comparison.name; @@ -487,6 +459,56 @@ function projectLocalHistoryItem(comparison) { }; } +function compactLocalCollection(items) { + return { + total: Array.isArray(items) ? items.length : null, + included: false, + details_available: Array.isArray(items) && items.length > 0, + }; +} + +function createLocalDynamicRegions(comparison = null) { + let confirmedRegions = comparison?.analysis?.confirmed_regions; + let hotspotAnalysis = comparison?.analysis?.hotspot_analysis; + + return { + decision: null, + patterns: compactLocalCollection([]), + confirmed_regions: compactLocalCollection(confirmedRegions), + exclusions: { + total: null, + included: false, + details_available: false, + }, + hotspot_analysis: hotspotAnalysis + ? { + total_builds_analyzed: hotspotAnalysis.total_builds_analyzed ?? null, + confidence: hotspotAnalysis.confidence ?? null, + confidence_score: hotspotAnalysis.confidence_score ?? null, + data_source: hotspotAnalysis.data_source ?? null, + coverage: hotspotAnalysis.coverage ?? null, + confirmed_region_coverage: + hotspotAnalysis.confirmed_region_coverage ?? null, + } + : null, + }; +} + +function createLocalSnapshotRevision(snapshot) { + let revisionInput = { + serverInfo: snapshot.serverInfo, + reportData: snapshot.reportData, + comparisonDetails: snapshot.comparisonDetails, + baselineMetadata: snapshot.baselineMetadata, + hotspotFile: snapshot.hotspotFile, + regionFile: snapshot.regionFile, + }; + + return createHash('sha256') + .update(JSON.stringify(revisionInput)) + .digest('base64url'); +} + function buildReviewSummary(comparisons = []) { let approved = comparisons.filter( comparison => mapApprovalStatus(comparison.status) === 'approved' @@ -737,8 +759,8 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { let mappedComparisons = snapshot.reportData.comparisons.map(comparison => mapLocalComparison(snapshot, comparison) ); - let mappedScreenshots = snapshot.reportData.comparisons.map(comparison => - mapLocalScreenshot(snapshot, comparison) + let mappedScreenshots = mappedComparisons.map(comparison => + projectLocalScreenshot(comparison.screenshot, comparison.baseline) ); let reviewSummary = buildReviewSummary(snapshot.reportData.comparisons); let reviewState = buildReviewState(resolvedBuild, reviewSummary); @@ -781,7 +803,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { if (!query.details) return context; - let revision = String(snapshot.reportData.timestamp || resolvedBuild.id); + let revision = createLocalSnapshotRevision(snapshot); let limit = query.limit || 10; let cursorQuery = `${query.details}:${limit}`; let cursor = readLocalCursor( @@ -805,6 +827,16 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { baseline: context.baseline, status: context.status, summary: context.summary, + preview: null, + signature_properties: [], + dynamic_regions: { + exclusions: { + total: null, + included: false, + details_available: false, + }, + item_details_included: false, + }, evidence: createLocalPage(evidence, { limit, offset: cursor?.offset || 0, @@ -814,6 +846,10 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { stream: 'evidence', revision, }), + comments: { + build: compactLocalCollection(context.comments.build), + screenshot_count: context.comments.screenshot_count, + }, links: context.links, }; } @@ -878,7 +914,7 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { if (!query.details) return context; - let revision = String(snapshot.reportData.timestamp || context.build.id); + let revision = createLocalSnapshotRevision(snapshot); let limit = query.limit || 10; let cursorQuery = `${query.details}:${limit}`; let cursor = readLocalCursor( @@ -899,11 +935,14 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { source: LOCAL_CONTEXT_SOURCE, scope: context.scope, build: context.build, + signature_properties: [], comparison: projectLocalFocusedComparison( context.comparison, query.details === 'diffs' ), + dynamic_regions: createLocalDynamicRegions(context.comparison), history: { + active_stream: activeStream || null, similar_by_fingerprint: createLocalPage([], { limit, offset: similarOffset, @@ -923,8 +962,16 @@ export function createLocalWorkspaceContextProvider(options = {}, deps = {}) { revision, }), }, - review: context.review, + review: { + review_summary: context.review.review_summary, + assignments: compactLocalCollection(context.review.assignments), + build_comments: compactLocalCollection(context.review.build_comments), + screenshot_comments: compactLocalCollection( + context.review.screenshot_comments + ), + }, links: context.links, + details: query.details, }; } diff --git a/tests/api/client.test.js b/tests/api/client.test.js index 9e7f9866..ed954bfa 100644 --- a/tests/api/client.test.js +++ b/tests/api/client.test.js @@ -265,6 +265,36 @@ describe('api/client', () => { ); }); + it('surfaces typed context errors from the API', async () => { + let client = createApiClient({ + token: 'test-token', + baseUrl: 'https://api.test', + }); + + mockFetch.mock.mockImplementation(async () => ({ + ok: false, + status: 409, + headers: new Map(), + text: async () => + JSON.stringify({ + error: 'Context evidence changed; request the first page again', + details: { code: 'CONTEXT_CURSOR_STALE' }, + }), + })); + + await assert.rejects( + () => client.request('/api/sdk/context/builds/build-123'), + error => { + assert.strictEqual(error.code, 'CONTEXT_CURSOR_STALE'); + assert.strictEqual( + error.context.details.code, + 'CONTEXT_CURSOR_STALE' + ); + return true; + } + ); + }); + it('includes status code in error context for 5xx errors', async () => { let client = createApiClient({ token: 'test-token', diff --git a/tests/api/core.test.js b/tests/api/core.test.js index 87a85d67..b97c2200 100644 --- a/tests/api/core.test.js +++ b/tests/api/core.test.js @@ -179,6 +179,22 @@ describe('api/core', () => { assert.strictEqual(result.code, 'SERVER_ERROR'); }); + + it('preserves structured API error codes and messages', () => { + let result = parseApiError( + 409, + JSON.stringify({ + error: 'Context evidence changed; request the first page again', + details: { code: 'CONTEXT_CURSOR_STALE' }, + }), + 'https://api.test/context' + ); + + assert.strictEqual(result.code, 'CONTEXT_CURSOR_STALE'); + assert.strictEqual(result.details.code, 'CONTEXT_CURSOR_STALE'); + assert.match(result.message, /Context evidence changed/); + assert.ok(!result.message.includes('{"error"')); + }); }); describe('isAuthError', () => { diff --git a/tests/commands/context-cli.test.js b/tests/commands/context-cli.test.js index 6a627ee3..257bb0f5 100644 --- a/tests/commands/context-cli.test.js +++ b/tests/commands/context-cli.test.js @@ -324,6 +324,29 @@ async function withBuildContextApi(callback) { return; } + if (req.url.startsWith('/api/sdk/context/builds/near-limit')) { + let payload = { + resource: 'build_context', + build: { id: 'near-limit' }, + evidence: { + items: [], + page: { + limit: 10, + returned: 0, + total: 0, + has_more: false, + next_cursor: null, + }, + }, + padding: '', + }; + let remainingBytes = + 64 * 1024 - Buffer.byteLength(JSON.stringify(payload)) - 16; + payload.padding = 'x'.repeat(remainingBytes); + res.end(JSON.stringify(payload)); + return; + } + if (req.url.startsWith('/api/sdk/context/builds/invalid')) { res.end(JSON.stringify({ resource: 'build_context' })); return; @@ -462,6 +485,7 @@ async function withBuildContextApi(callback) { res.end( JSON.stringify({ resource: 'build_context', + source: 'local_workspace', review_flow: 'legacy', scope: { organization: { slug: 'acme' }, @@ -739,6 +763,10 @@ describe('context CLI integration', () => { ['--json', 'context', 'build', 'invalid', '--agent'], { cwd, env } ); + let nearLimit = await runCLI( + ['--json', 'context', 'build', 'near-limit', '--agent'], + { cwd, env } + ); assert.strictEqual(oversized.code, 1); assert.strictEqual( @@ -750,6 +778,11 @@ describe('context CLI integration', () => { JSON.parse(invalid.stderr).error.code, 'COMPACT_CONTEXT_INVALID' ); + assert.strictEqual(nearLimit.code, 1); + assert.strictEqual( + JSON.parse(nearLimit.stderr).error.code, + 'COMPACT_CONTEXT_OVERSIZED' + ); }); }); diff --git a/tests/context/local-workspace-provider.test.js b/tests/context/local-workspace-provider.test.js index 0fc8c028..59516b07 100644 --- a/tests/context/local-workspace-provider.test.js +++ b/tests/context/local-workspace-provider.test.js @@ -424,6 +424,9 @@ describe('context/local-workspace-provider', () => { assert.strictEqual(buildSummary.evidence.items.length, 10); assert.strictEqual(buildSummary.evidence.page.has_more, true); assert.ok(!buildSummary.evidence.items[0].diff.regions); + assert.strictEqual(buildSummary.preview, null); + assert.deepStrictEqual(buildSummary.signature_properties, []); + assert.strictEqual(buildSummary.comments.build.total, 0); let nextBuildPage = provider.getBuildContext('current', { details: 'summary', @@ -460,6 +463,13 @@ describe('context/local-workspace-provider', () => { comparisonSummary.history.recent_by_name.page.has_more, true ); + assert.strictEqual(comparisonSummary.details, 'summary'); + assert.strictEqual(comparisonSummary.history.active_stream, null); + assert.deepStrictEqual(comparisonSummary.signature_properties, []); + assert.strictEqual( + comparisonSummary.dynamic_regions.confirmed_regions.total, + 0 + ); let comparisonDiffs = provider.getComparisonContext('comp-0', { details: 'diffs', @@ -480,4 +490,49 @@ describe('context/local-workspace-provider', () => { /cursor is invalid|results changed/ ); }); + + it('invalidates a cursor when local evidence changes without a new timestamp', () => { + let projectRoot = '/tmp/vizzly-local-changing-context'; + let paths = createWorkspacePaths(projectRoot); + let revision = 1; + let readJsonIfExists = path => { + if (path === paths.report) { + return { + timestamp: 1234, + comparisons: Array.from({ length: 11 }, (_, index) => ({ + id: `comp-${index}`, + name: `Screenshot ${index}`, + status: index === 0 && revision === 2 ? 'passed' : 'failed', + properties: {}, + })), + }; + } + if (path === paths.comparisonDetails) return {}; + return null; + }; + let firstProvider = createLocalWorkspaceContextProvider( + { projectRoot }, + { readJsonIfExists } + ); + let firstPage = firstProvider.getBuildContext('current', { + details: 'summary', + limit: 10, + }); + + revision = 2; + let changedProvider = createLocalWorkspaceContextProvider( + { projectRoot }, + { readJsonIfExists } + ); + + assert.throws( + () => + changedProvider.getBuildContext('current', { + details: 'summary', + limit: 10, + cursor: firstPage.evidence.page.next_cursor, + }), + /cursor is invalid|results changed/ + ); + }); });