Skip to content

Commit ddbff6c

Browse files
committed
sync(bfmono): fix(gambit): invalidate build deck label cache on frontmatter changes (+19 more) (bfmono@9ebc474b8)
This PR is an automated gambitmono sync of bfmono Gambit packages. - Source: `packages/gambit/` - Core: `packages/gambit/packages/gambit-core/` - bfmono rev: 9ebc474b8 Changes: - 9ebc474b8 fix(gambit): invalidate build deck label cache on frontmatter changes - 04f032d28 feat(simulator-ui): use icon remove action for error context chip - 71a94654b fix(gambit): validate explicit deck path and stabilize stop test - 77fa7f139 refactor(gambit-simulator-ui): replace placeholders with reusable callout - d90d57c08 feat(gambit-simulator-ui): send scenario run errors to workbench chat - dc7d93a08 refactor(gambit): Fix left drawer - bdad96a86 chore(repo): pin jsx-runtime imports to react 19.2.4 - 0c787f96d chore(gambit): cut 0.8.5-rc.5 and pin React to 19.2.4 - 0fdcdf864 fix(gambit): unblock npm dnt build for 0.8.5-rc.4 - 48cd964d4 feat(gambit-core): restore built-in exec via host adapter - b48ad8ec2 chore(gambit): cut 0.8.5-rc.3 - 701935f94 ci(gambit-core): gate npm compatibility in core CI - 6957cc580 docs(gambit-core): document worker sandbox host contract - 2eb1ce7c6 test(gambit-core): add unsupported worker sandbox coverage - 825145eea fix(gambit-core): partition worker sandbox by runtime host - 1801a5380 refactor(gambit-core): remove built-in exec tool from runtime - 9db408859 fix(gambit-release): support nested core path and cut 0.8.5-rc.2 - 5e286d3a9 fix(gambit): clean codex smoke temp artifacts under ignored tmp - 2ce4b7ee8 fix(gambit): resolve local jsr package in release-binaries compile - e0c706e16 feat(gambit-simulator): show deck labels in build file selector Do not edit this repo directly; make changes in bfmono and re-run the sync.
1 parent 7cac8e5 commit ddbff6c

6 files changed

Lines changed: 178 additions & 7 deletions

File tree

simulator-ui/src/Chat.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "./utils.ts";
77
import Button from "./gds/Button.tsx";
88
import Callout from "./gds/Callout.tsx";
9+
import Icon from "./gds/Icon.tsx";
910
import {
1011
ActivityTranscriptRows,
1112
bucketBuildChatDisplay,
@@ -445,22 +446,25 @@ export function ChatView(props: {
445446
<input
446447
type="checkbox"
447448
checked={scenarioErrorChip.enabled}
449+
aria-label={scenarioErrorChip.enabled
450+
? "Error context on"
451+
: "Error context off"}
448452
onChange={(event) =>
449453
onScenarioErrorChipChange?.({
450454
...scenarioErrorChip,
451455
enabled: event.target.checked,
452456
})}
453457
data-testid="workbench-error-chip-toggle"
454458
/>
455-
<span>{scenarioErrorChip.enabled ? "On" : "Off"}</span>
456459
</label>
457460
<button
458461
type="button"
459462
className="link-button workbench-composer-chip-remove"
460463
onClick={() => onScenarioErrorChipChange?.(null)}
464+
aria-label="Remove error context"
461465
data-testid="workbench-error-chip-remove"
462466
>
463-
Remove
467+
<Icon name="times" size={8} />
464468
</button>
465469
</div>
466470
</div>

simulator-ui/src/gds/Icon.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FlagIcon } from "./icons/Flag.tsx";
99
import { HamburgerMenuIcon } from "./icons/HamburgerMenu.tsx";
1010
import { TrashIcon } from "./icons/Trash.tsx";
1111
import { ReviewIcon } from "./icons/Review.tsx";
12+
import { TimesIcon } from "./icons/Times.tsx";
1213

1314
const ICONS = {
1415
chevronDown: ChevronDownIcon,
@@ -22,6 +23,7 @@ const ICONS = {
2223
circleInfo: CircleInfoIcon,
2324
review: ReviewIcon,
2425
trash: TrashIcon,
26+
times: TimesIcon,
2527
};
2628

2729
export type IconName = keyof typeof ICONS;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from "react";
2+
3+
type IconProps = React.SVGProps<SVGSVGElement> & {
4+
title?: string;
5+
};
6+
7+
export function TimesIcon({ title, ...props }: IconProps) {
8+
return (
9+
<svg
10+
viewBox="0 0 14 14"
11+
fill="none"
12+
xmlns="http://www.w3.org/2000/svg"
13+
{...props}
14+
>
15+
{title && <title>{title}</title>}
16+
<path
17+
fillRule="evenodd"
18+
clipRule="evenodd"
19+
d="M2.73285 0.492584L7 4.75973L11.2671 0.492584C12.7608 -1.00102 15.001 1.23936 13.5074 2.73285L9.24027 7L13.5074 11.2671C15.001 12.7608 12.7606 15.001 11.2671 13.5074L7 9.24027L2.73285 13.5074C1.23925 15.001 -1.00102 12.7606 0.492584 11.2671L4.75973 7L0.492584 2.73285C-1.00102 1.23925 1.23936 -1.00102 2.73285 0.492584Z"
20+
fill="currentColor"
21+
/>
22+
</svg>
23+
);
24+
}

simulator-ui/src/styles.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,12 +1409,12 @@ code:not(pre *) {
14091409
margin: 0;
14101410
}
14111411
.workbench-composer-chip-remove {
1412-
font-size: 12px;
1412+
display: flex;
14131413
color: var(--color-text-muted);
14141414
text-decoration: none;
14151415
}
14161416
.workbench-composer-chip-remove:hover {
1417-
color: var(--color-text);
1417+
color: var(--color-danger);
14181418
}
14191419
.message-input {
14201420
width: 100%;

src/server.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,20 +1388,56 @@ export function startWebSocketSimulator(opts: {
13881388
return lower === "prompt.md" || lower.endsWith(".deck.md");
13891389
};
13901390

1391+
type BuildDeckLabelCacheEntry = {
1392+
frontmatterRaw: string | null;
1393+
label: string | undefined;
1394+
};
1395+
1396+
const buildDeckLabelCache = new Map<string, BuildDeckLabelCacheEntry>();
1397+
13911398
const readBuildDeckLabel = async (
13921399
fullPath: string,
13931400
): Promise<string | undefined> => {
13941401
try {
13951402
const text = await Deno.readTextFile(fullPath);
13961403
const lines = text.split(/\r?\n/);
1397-
if (lines[0] !== "+++") return undefined;
1404+
if (lines[0] !== "+++") {
1405+
const cached = buildDeckLabelCache.get(fullPath);
1406+
if (cached?.frontmatterRaw === null) return cached.label;
1407+
buildDeckLabelCache.set(fullPath, {
1408+
frontmatterRaw: null,
1409+
label: undefined,
1410+
});
1411+
return undefined;
1412+
}
13981413
const endIndex = lines.indexOf("+++", 1);
1399-
if (endIndex === -1) return undefined;
1414+
if (endIndex === -1) {
1415+
const cached = buildDeckLabelCache.get(fullPath);
1416+
if (cached?.frontmatterRaw === null) return cached.label;
1417+
buildDeckLabelCache.set(fullPath, {
1418+
frontmatterRaw: null,
1419+
label: undefined,
1420+
});
1421+
return undefined;
1422+
}
14001423
const frontmatter = lines.slice(1, endIndex).join("\n");
1424+
const cached = buildDeckLabelCache.get(fullPath);
1425+
if (cached?.frontmatterRaw === frontmatter) {
1426+
return cached.label;
1427+
}
14011428
const parsed = parseToml(frontmatter) as Record<string, unknown>;
14021429
const label = typeof parsed.label === "string" ? parsed.label.trim() : "";
1403-
return label.length > 0 ? label : undefined;
1430+
const resolvedLabel = label.length > 0 ? label : undefined;
1431+
buildDeckLabelCache.set(fullPath, {
1432+
frontmatterRaw: frontmatter,
1433+
label: resolvedLabel,
1434+
});
1435+
return resolvedLabel;
14041436
} catch {
1437+
buildDeckLabelCache.set(fullPath, {
1438+
frontmatterRaw: null,
1439+
label: undefined,
1440+
});
14051441
return undefined;
14061442
}
14071443
};

src/server_routes_state.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,111 @@ Body
254254
await server.finished;
255255
});
256256

257+
Deno.test("build files API refreshes deck labels when content changes with same size and mtime", async () => {
258+
const dir = await Deno.makeTempDir();
259+
const modHref = modImportPath();
260+
261+
const deckPath = path.join(dir, "build-files-label-cache.deck.ts");
262+
await Deno.writeTextFile(
263+
deckPath,
264+
`
265+
import { defineDeck } from "${modHref}";
266+
import { z } from "zod";
267+
export default defineDeck({
268+
inputSchema: z.string().optional(),
269+
outputSchema: z.string().optional(),
270+
modelParams: { model: "dummy-model" },
271+
});
272+
`,
273+
);
274+
275+
const provider: ModelProvider = {
276+
chat() {
277+
return Promise.resolve({
278+
message: { role: "assistant", content: "ok" },
279+
finishReason: "stop",
280+
});
281+
},
282+
};
283+
284+
const server = startWebSocketSimulator({
285+
deckPath,
286+
modelProvider: provider,
287+
port: 0,
288+
});
289+
const port = (server.addr as Deno.NetAddr).port;
290+
291+
const workspaceRes = await fetch(
292+
`http://127.0.0.1:${port}/api/workspace/new`,
293+
{ method: "POST" },
294+
);
295+
assertEquals(workspaceRes.ok, true);
296+
const workspaceBody = await workspaceRes.json() as { workspaceId?: string };
297+
const workspaceId = workspaceBody.workspaceId ?? "";
298+
assert(workspaceId.length > 0, "missing workspaceId");
299+
300+
const firstFilesRes = await fetch(
301+
`http://127.0.0.1:${port}/api/build/files?workspaceId=${
302+
encodeURIComponent(workspaceId)
303+
}`,
304+
);
305+
assertEquals(firstFilesRes.ok, true);
306+
const firstFilesBody = await firstFilesRes.json() as { root?: string };
307+
const root = firstFilesBody.root ?? "";
308+
assert(root.length > 0, "missing bot root");
309+
310+
const scenarioDir = path.join(root, "scenarios", "scenario_a");
311+
await Deno.mkdir(scenarioDir, { recursive: true });
312+
const promptPath = path.join(scenarioDir, "PROMPT.md");
313+
const fixedTime = new Date("2025-01-01T00:00:00.000Z");
314+
315+
const makePrompt = (label: string) =>
316+
`+++
317+
label = "${label}"
318+
+++
319+
320+
Body
321+
`;
322+
323+
await Deno.writeTextFile(promptPath, makePrompt("Alpha"));
324+
await Deno.utime(promptPath, fixedTime, fixedTime);
325+
326+
const warmCacheRes = await fetch(
327+
`http://127.0.0.1:${port}/api/build/files?workspaceId=${
328+
encodeURIComponent(workspaceId)
329+
}`,
330+
);
331+
assertEquals(warmCacheRes.ok, true);
332+
const warmCacheBody = await warmCacheRes.json() as {
333+
entries?: Array<{ path?: string; label?: string }>;
334+
};
335+
const warmPrompt = (warmCacheBody.entries ?? []).find((entry) =>
336+
entry.path === "scenarios/scenario_a/PROMPT.md"
337+
);
338+
assertEquals(warmPrompt?.label, "Alpha");
339+
340+
// Same byte length label replacement + identical mtime to reproduce stale-cache risk.
341+
await Deno.writeTextFile(promptPath, makePrompt("Bravo"));
342+
await Deno.utime(promptPath, fixedTime, fixedTime);
343+
344+
const refreshedRes = await fetch(
345+
`http://127.0.0.1:${port}/api/build/files?workspaceId=${
346+
encodeURIComponent(workspaceId)
347+
}`,
348+
);
349+
assertEquals(refreshedRes.ok, true);
350+
const refreshedBody = await refreshedRes.json() as {
351+
entries?: Array<{ path?: string; label?: string }>;
352+
};
353+
const refreshedPrompt = (refreshedBody.entries ?? []).find((entry) =>
354+
entry.path === "scenarios/scenario_a/PROMPT.md"
355+
);
356+
assertEquals(refreshedPrompt?.label, "Bravo");
357+
358+
await server.shutdown();
359+
await server.finished;
360+
});
361+
257362
Deno.test("simulator exposes schema and defaults", async () => {
258363
const dir = await Deno.makeTempDir();
259364
const modHref = modImportPath();

0 commit comments

Comments
 (0)