Skip to content

Commit e19cd55

Browse files
cameroncookecodex
andcommitted
feat(snapshot): Render user-defined context
Render snapshot image sidecar context recursively as Markdown bullets under a Context section. Preserve supported scalar values and nested objects while skipping unsupported or empty values. Co-Authored-By: Codex CLI <noreply@openai.com>
1 parent 6a1a0e5 commit e19cd55

3 files changed

Lines changed: 113 additions & 17 deletions

File tree

packages/mcp-core/src/tools/get-snapshot-details.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ const snapshotFixture = {
121121
unchanged: [],
122122
};
123123

124+
const LONG_CONTEXT_VALUE = "x".repeat(220);
125+
124126
const headImageInfo = {
125127
content_hash: "abc123",
126128
display_name: "login_screen.png",
@@ -132,11 +134,35 @@ const headImageInfo = {
132134
image_url:
133135
"/api/0/organizations/sentry/preprodartifacts/snapshots/231949/images/auth_login_screen.png/download/",
134136
context: {
137+
empty_object: {},
138+
empty_string: "",
139+
metadata: {
140+
enabled: true,
141+
metrics: {
142+
attempts: 2,
143+
ratio: 0.25,
144+
},
145+
unsupported_array: ["hidden"],
146+
unsupported_null: null,
147+
},
148+
deep: {
149+
level1: {
150+
level2: {
151+
level3: {
152+
level4: {
153+
level5: "visible",
154+
},
155+
},
156+
},
157+
},
158+
},
159+
long_value: LONG_CONTEXT_VALUE,
135160
preview: {
136161
container_display_name: "Auth Login",
137162
display_name: "login_screen.png",
138163
},
139164
simulator: { device_name: "iPhone 16" },
165+
test_name: "LoginUITests.testLoginScreen",
140166
},
141167
};
142168

@@ -621,8 +647,28 @@ describe("get_snapshot_details", () => {
621647
expect(textParts[0]!.text).toContain("**Diff**: 12.5%");
622648
expect(textParts[0]!.text).toContain("**Group**: auth");
623649
expect(textParts[0]!.text).toContain("1080×1920");
624-
expect(textParts[0]!.text).toContain("**Container**: Auth Login");
625-
expect(textParts[0]!.text).toContain("**Device**: iPhone 16");
650+
expect(textParts[0]!.text).toContain("### Context");
651+
expect(textParts[0]!.text).toContain(
652+
"- **metadata**:\n - **enabled**: true\n - **metrics**:\n - **attempts**: 2\n - **ratio**: 0.25",
653+
);
654+
expect(textParts[0]!.text).toContain(" - **level5**: visible");
655+
expect(textParts[0]!.text).toContain(
656+
`- **long_value**: ${LONG_CONTEXT_VALUE}`,
657+
);
658+
expect(textParts[0]!.text).not.toContain("more context lines omitted");
659+
expect(textParts[0]!.text).toContain(
660+
"- **preview**:\n - **container_display_name**: Auth Login\n - **display_name**: login_screen.png",
661+
);
662+
expect(textParts[0]!.text).toContain(
663+
"- **simulator**:\n - **device_name**: iPhone 16",
664+
);
665+
expect(textParts[0]!.text).toContain(
666+
"- **test_name**: LoginUITests.testLoginScreen",
667+
);
668+
expect(textParts[0]!.text).not.toContain("empty_object");
669+
expect(textParts[0]!.text).not.toContain("empty_string");
670+
expect(textParts[0]!.text).not.toContain("unsupported_array");
671+
expect(textParts[0]!.text).not.toContain("unsupported_null");
626672
expect(imageParts.length).toBe(3);
627673
expect(imageParts[0]!.mimeType).toBe("image/png");
628674
expect(imageParts[1]!.mimeType).toBe("image/jpeg");

packages/mcp-core/src/tools/get-snapshot-details.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { blobToBase64 } from "../internal/blob-utils";
1414
import {
1515
formatSnapshotDiffPercent,
1616
getSnapshotImageDisplayName,
17+
renderSnapshotImageContext,
1718
renderSnapshotImageTreeSection,
1819
type SnapshotImageEntry,
1920
type SnapshotImageTreeItem,
@@ -25,12 +26,6 @@ interface SnapshotDiffPair {
2526
diff?: number | null;
2627
}
2728

28-
interface SnapshotImageContext {
29-
preview?: { container_display_name?: string; display_name?: string };
30-
simulator?: { device_name?: string };
31-
test_name?: string;
32-
}
33-
3429
interface SnapshotImageInfo {
3530
content_hash?: string;
3631
display_name?: string | null;
@@ -40,7 +35,7 @@ interface SnapshotImageInfo {
4035
height?: number;
4136
description?: string | null;
4237
image_url?: string;
43-
context?: SnapshotImageContext;
38+
context?: unknown;
4439
[key: string]: unknown;
4540
}
4641

@@ -173,14 +168,10 @@ function formatImageMetadata(img: SnapshotImageInfo): string[] {
173168
if (img.width || img.height)
174169
lines.push(`- **Dimensions**: ${img.width}×${img.height}`);
175170
if (img.description) lines.push(`- **Description**: ${img.description}`);
176-
if (img.context?.preview?.container_display_name)
177-
lines.push(
178-
`- **Container**: ${img.context.preview.container_display_name}`,
179-
);
180-
if (img.context?.simulator?.device_name)
181-
lines.push(`- **Device**: ${img.context.simulator.device_name}`);
182-
if (img.context?.test_name)
183-
lines.push(`- **Test**: ${img.context.test_name}`);
171+
const contextLines = renderSnapshotImageContext(img.context);
172+
if (contextLines.length > 0) {
173+
lines.push("", "### Context", ...contextLines);
174+
}
184175
return lines;
185176
}
186177

packages/mcp-core/src/tools/snapshot-formatting.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ export function renderSnapshotImageTreeSection(
5353
return lines;
5454
}
5555

56+
export function renderSnapshotImageContext(context: unknown): string[] {
57+
if (!isPlainObject(context)) {
58+
return [];
59+
}
60+
61+
const contextLines = renderContextObject(context, 0);
62+
if (contextLines.length === 0) {
63+
return [];
64+
}
65+
66+
return contextLines;
67+
}
68+
5669
function createBranch(label: string): TreeBranch {
5770
return { kind: "branch", label, children: new Map(), leaves: [] };
5871
}
@@ -129,3 +142,49 @@ function renderEntry(
129142
}
130143
return lines.join("\n");
131144
}
145+
146+
function renderContextObject(
147+
context: Record<string, unknown>,
148+
depth: number,
149+
): string[] {
150+
const prefix = " ".repeat(depth);
151+
return Object.entries(context).flatMap(([key, value]) => {
152+
const formatted = formatSupportedContextValue(value);
153+
if (formatted !== null) {
154+
return [`${prefix}- **${key}**: ${formatted}`];
155+
}
156+
157+
if (!isPlainObject(value)) {
158+
return [];
159+
}
160+
161+
const children = renderContextObject(value, depth + 1);
162+
return children.length > 0 ? [`${prefix}- **${key}**:`, ...children] : [];
163+
});
164+
}
165+
166+
function formatSupportedContextValue(value: unknown): string | null {
167+
if (typeof value === "string") {
168+
const normalized = value.trim().replace(/\s+/g, " ");
169+
return normalized.length > 0 ? normalized : null;
170+
}
171+
172+
if (typeof value === "number") {
173+
return Number.isFinite(value) ? String(value) : null;
174+
}
175+
176+
if (typeof value === "boolean") {
177+
return String(value);
178+
}
179+
180+
return null;
181+
}
182+
183+
function isPlainObject(value: unknown): value is Record<string, unknown> {
184+
if (value === null || typeof value !== "object") {
185+
return false;
186+
}
187+
188+
const prototype = Object.getPrototypeOf(value);
189+
return prototype === Object.prototype || prototype === null;
190+
}

0 commit comments

Comments
 (0)