Skip to content

Commit 73088a3

Browse files
committed
🤖 feat: goal status board and agent-browser self-check for artifacts
- Goal status board: the host keeps `$XUM_SCRATCH_DIR/artifacts/goal.status.html` current at goal events (create, pause, resume, budget edits, completion) and continuation-turn starts - `artifacts.capabilities` route and an agent-browser banner in the HTML viewer when the agent can check its own artifacts - Artifact write helpers next to the read paths (host and runtime) for host-maintained files - artifact_list description points agents at agent-browser for self-checks - Codex review round 1 fixes: host artifact writer pins the real artifacts root and re-checks the opened file after rename, writers no longer mkdir -p, agent-browser probe uses the execution cwd and tool_env (trust in the cache key), cleared goal renders No active goal, per-workspace agent-browser answer, gh probes from the repo root, board follows the app theme (data-xum-theme), todo/plan changes re-render the board, only SUCCESS counts as passed, notify only on visible changes, tabular numerals - Codex review round 2 fixes: goal observer key covers cost, turns and completion summary, finalize re-reads the durable goal and notifies only for the same goal, runtime board writes land in distinct wall-clock seconds - Codex review round 3 fixes: the runtime board wait records the host time of the previous write and waits until that + 1000 ms instead of rounding to the next host second --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$123.74`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=123.74 -->
1 parent 944275f commit 73088a3

22 files changed

Lines changed: 2042 additions & 17 deletions

‎src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.test.tsx‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import "../../../../../tests/ui/dom";
33

44
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
5-
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
5+
import { act, cleanup, fireEvent, render, renderHook, waitFor } from "@testing-library/react";
66
import type { ReactNode } from "react";
77
import { installDom } from "../../../../../tests/ui/dom";
88
import { APIProvider, type APIClient } from "@/browser/contexts/API";
@@ -19,6 +19,7 @@ import { DESKTOP_ONLY_PREVIEW_NOTICE } from "./executableFrames";
1919
import { ArtifactsPanel } from "./ArtifactsPanel";
2020
import { ARTIFACT_TABLE_MAX_COLUMNS, ARTIFACT_TABLE_MAX_ROWS } from "./DataTable";
2121
import { JSON_TREE_MAX_NODES } from "./jsonData";
22+
import { useAgentBrowserAvailable } from "./useAgentBrowserAvailable";
2223

2324
function ok(
2425
path: string,
@@ -39,6 +40,8 @@ function ok(
3940
let files: Record<string, ArtifactReadResult> = {};
4041
let selected = "";
4142
let readInputs: Array<{ path: string; maxBytes?: number | null }> = [];
43+
let agentBrowserAvailable: boolean | null = null;
44+
let capabilityRequests = 0;
4245

4346
function Wrapper(props: { children: ReactNode }) {
4447
const api: TestApiOverrides<APIClient> = {
@@ -71,6 +74,10 @@ function Wrapper(props: { children: ReactNode }) {
7174
: { success: false as const, error: `Artifact not found: ${input.path}` }
7275
);
7376
},
77+
capabilities: () => {
78+
capabilityRequests++;
79+
return Promise.resolve({ agentBrowserAvailable });
80+
},
7481
},
7582
};
7683
return (
@@ -100,6 +107,8 @@ describe("ArtifactViewer renderers", () => {
100107
versions: {},
101108
getIsRosetta: () => Promise.resolve(false),
102109
};
110+
agentBrowserAvailable = null;
111+
capabilityRequests = 0;
103112
});
104113

105114
afterEach(() => {
@@ -269,6 +278,56 @@ describe("ArtifactViewer renderers", () => {
269278
await waitFor(() => expect(view.queryByRole("dialog") == null).toBe(true));
270279
});
271280

281+
test("warns above HTML artifacts only when agent-browser is known to be missing", async () => {
282+
const warning = "Not checked by the agent: agent-browser is not available on this runtime.";
283+
for (const value of [true, null]) {
284+
agentBrowserAvailable = value;
285+
capabilityRequests = 0;
286+
const view = renderArtifact("page.html", { "page.html": ok("page.html", "<p>hi</p>") });
287+
await view.findByTestId("artifact-frame");
288+
await waitFor(() => expect(capabilityRequests).toBeGreaterThan(0));
289+
// Let the answer render before asserting the warning stays absent.
290+
await new Promise((resolve) => setTimeout(resolve, 0));
291+
expect(view.queryByText(warning)).toBeNull();
292+
cleanup();
293+
}
294+
295+
agentBrowserAvailable = false;
296+
const html = renderArtifact("page.html", { "page.html": ok("page.html", "<p>hi</p>") });
297+
expect(await html.findByText(warning)).toBeTruthy();
298+
expect(await html.findByTestId("artifact-frame")).toBeTruthy();
299+
cleanup();
300+
301+
const markdown = renderArtifact("notes.md", { "notes.md": ok("notes.md", "# Notes") });
302+
expect(await markdown.findByText("Notes")).toBeTruthy();
303+
expect(markdown.queryByText(warning)).toBeNull();
304+
});
305+
306+
test("an agent-browser answer for one workspace never shows for another", async () => {
307+
// ws-a answers false; ws-b's request fails, which must read as unknown, not ws-a's false.
308+
const client = createTestApiClient({
309+
artifacts: {
310+
capabilities: (input: { workspaceId: string }) =>
311+
input.workspaceId === "ws-a"
312+
? Promise.resolve({ agentBrowserAvailable: false })
313+
: Promise.reject(new Error("unreachable")),
314+
},
315+
} satisfies TestApiOverrides<APIClient>);
316+
const hook = renderHook(
317+
(props: { workspaceId: string }) => useAgentBrowserAvailable(props.workspaceId),
318+
{
319+
initialProps: { workspaceId: "ws-a" },
320+
wrapper: (props: { children: ReactNode }) => (
321+
<APIProvider client={client}>{props.children}</APIProvider>
322+
),
323+
}
324+
);
325+
await waitFor(() => expect(hook.result.current).toBe(false));
326+
hook.rerender({ workspaceId: "ws-b" });
327+
await new Promise((resolve) => setTimeout(resolve, 0));
328+
expect(hook.result.current).toBeNull();
329+
});
330+
272331
test("resolves relative Markdown images through the artifacts API", async () => {
273332
const view = renderArtifact("notes/report.md", {
274333
"notes/report.md": ok("notes/report.md", "# R\n\n![chart](img/c.png)"),

‎src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type React from "react";
12
import { Check, Copy, Download, ExternalLink } from "lucide-react";
23
import { Mermaid } from "@/browser/features/Messages/Mermaid";
34
import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard";
@@ -14,7 +15,8 @@ import { JsonArtifact } from "./JsonArtifact";
1415
import { MarkdownArtifact } from "./MarkdownArtifact";
1516
import { canMountExecutableArtifactFrames, DESKTOP_ONLY_PREVIEW_NOTICE } from "./executableFrames";
1617
import { SandboxedArtifactFrame, type ArtifactFrameKey } from "./SandboxedArtifactFrame";
17-
import { Notice, SourceText } from "./SourceText";
18+
import { Notice, NoteBar, SourceText } from "./SourceText";
19+
import { useAgentBrowserAvailable } from "./useAgentBrowserAvailable";
1820

1921
// Every renderer here goes through React elements, so artifact content (agent-written,
2022
// therefore untrusted) is always escaped. HTML and SVG only ever render inside
@@ -81,6 +83,28 @@ function PdfArtifact(props: { result: Extract<ArtifactReadResult, { status: "ok"
8183
);
8284
}
8385

86+
export const AGENT_BROWSER_MISSING_WARNING =
87+
"Not checked by the agent: agent-browser is not available on this runtime.";
88+
89+
/**
90+
* HTML and SVG are the artifacts the agent can check with agent-browser; when the runtime has
91+
* none, say so above the frame (null means unknown, which shows nothing).
92+
*/
93+
function SandboxedArtifactWithCheckNotice(
94+
props: React.ComponentProps<typeof SandboxedArtifactFrame>
95+
) {
96+
const agentBrowserAvailable = useAgentBrowserAvailable(props.workspaceId);
97+
// One stable tree, so the answer arriving never remounts (reloads) the frame.
98+
return (
99+
<div className="flex h-full min-h-0 flex-col">
100+
{agentBrowserAvailable === false && <NoteBar>{AGENT_BROWSER_MISSING_WARNING}</NoteBar>}
101+
<div className="min-h-0 flex-1">
102+
<SandboxedArtifactFrame {...props} />
103+
</div>
104+
</div>
105+
);
106+
}
107+
84108
export function ArtifactViewer(props: {
85109
result: ArtifactReadResult;
86110
workspaceId: string;
@@ -134,7 +158,7 @@ export function ArtifactViewer(props: {
134158
return <SourceText content={result.content} note={DESKTOP_ONLY_PREVIEW_NOTICE} />;
135159
}
136160
return (
137-
<SandboxedArtifactFrame
161+
<SandboxedArtifactWithCheckNotice
138162
workspaceId={props.workspaceId}
139163
path={result.path}
140164
kind={result.kind}

‎src/browser/features/RightSidebar/ArtifactsTab/artifactBridge.test.ts‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,19 @@ function runBridgeScript() {
6868
listeners.set(type, listener);
6969
},
7070
};
71+
// The script mirrors the theme onto the root element (data-xum-theme).
72+
const fakeDocument = {
73+
documentElement: { setAttribute: () => undefined },
74+
addEventListener: () => undefined,
75+
};
7176
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- runs our own generated script
72-
const run = new Function("window", "CustomEvent", buildArtifactBridgeScript("dark")) as (
73-
window: unknown,
74-
customEvent: unknown
75-
) => void;
76-
run(fakeWindow, class {});
77+
const run = new Function(
78+
"window",
79+
"document",
80+
"CustomEvent",
81+
buildArtifactBridgeScript("dark")
82+
) as (window: unknown, document: unknown, customEvent: unknown) => void;
83+
run(fakeWindow, fakeDocument, class {});
7784
const keydown = (key: string, target: unknown, shiftKey = false) =>
7885
listeners.get("keydown")?.({
7986
key,
@@ -114,4 +121,45 @@ describe("buildArtifactBridgeScript", () => {
114121
// Bun's transpiler throws on a syntax error, which is all this needs to prove.
115122
expect(() => new Bun.Transpiler({ loader: "js" }).transformSync(script)).not.toThrow();
116123
});
124+
125+
test("mirrors the app theme into data-xum-theme for script-free artifacts", () => {
126+
const attributes = new Map<string, string>();
127+
type FakeMessageListener = (event: { source: unknown; data: unknown }) => void;
128+
const listeners: FakeMessageListener[] = [];
129+
const parent = {};
130+
const fakeWindow = {
131+
parent,
132+
addEventListener: (type: string, listener: FakeMessageListener) => {
133+
if (type === "message") listeners.push(listener);
134+
},
135+
dispatchEvent: () => true,
136+
};
137+
const fakeDocument = {
138+
documentElement: {
139+
setAttribute: (name: string, value: string) => attributes.set(name, value),
140+
},
141+
};
142+
class FakeCustomEvent {
143+
constructor(
144+
readonly type: string,
145+
readonly init: unknown
146+
) {}
147+
}
148+
// eslint-disable-next-line @typescript-eslint/no-implied-eval -- runs the generated frame script
149+
const run = new Function(
150+
"window",
151+
"document",
152+
"CustomEvent",
153+
buildArtifactBridgeScript("light")
154+
) as (window: unknown, document: unknown, customEvent: unknown) => void;
155+
run(fakeWindow, fakeDocument, FakeCustomEvent);
156+
expect(attributes.get("data-xum-theme")).toBe("light");
157+
158+
const send = (data: unknown, source: unknown = parent) =>
159+
listeners.forEach((listener) => listener({ source, data }));
160+
send({ xumArtifact: 1, type: "theme", theme: "dark" }, {});
161+
expect(attributes.get("data-xum-theme")).toBe("light");
162+
send({ xumArtifact: 1, type: "theme", theme: "dark" });
163+
expect(attributes.get("data-xum-theme")).toBe("dark");
164+
});
117165
});

‎src/browser/features/RightSidebar/ArtifactsTab/artifactBridge.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ export function postArtifactTheme(frameWindow: Window | null | undefined, theme:
107107
* Source of the inline <script> injected as the frame's first script (inline, so the CSP's
108108
* 'unsafe-inline' allows it). It forwards Escape and Shift+F to the host and exposes a
109109
* read-only `window.xum.theme`, updated from theme messages, plus a `xumthemechange` event.
110+
* It also mirrors the theme into `data-xum-theme` on the root element, so plain CSS (the goal
111+
* status board, which has no scripts) can follow the app theme instead of the OS preference.
110112
* Only the initial theme is interpolated, and only as a JSON string literal.
111113
*/
112114
export function buildArtifactBridgeScript(initialTheme: ArtifactTheme): string {
@@ -117,12 +119,18 @@ export function buildArtifactBridgeScript(initialTheme: ArtifactTheme): string {
117119
var api = {};
118120
Object.defineProperty(api, "theme", { enumerable: true, get: function () { return theme; } });
119121
Object.defineProperty(window, "xum", { value: Object.freeze(api) });
122+
function applyThemeAttribute() {
123+
var root = document.documentElement;
124+
if (root) root.setAttribute("data-xum-theme", theme);
125+
}
126+
applyThemeAttribute();
120127
window.addEventListener("message", function (event) {
121128
if (event.source !== host) return;
122129
var data = event.data;
123130
if (!data || typeof data !== "object" || data.xumArtifact !== ${ARTIFACT_BRIDGE_VERSION}) return;
124131
if (data.type === "theme" && (data.theme === "dark" || data.theme === "light")) {
125132
theme = data.theme;
133+
applyThemeAttribute();
126134
window.dispatchEvent(new CustomEvent("xumthemechange", { detail: { theme: theme } }));
127135
}
128136
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { useEffect, useState } from "react";
2+
import { useAPI, type APIClient } from "@/browser/contexts/API";
3+
4+
/**
5+
* Last answer per API client and workspace. The viewer remounts on every file change, so a
6+
* fresh mount starts from the last answer instead of flashing the warning in and out.
7+
*/
8+
const lastKnown = new WeakMap<APIClient, Map<string, boolean | null>>();
9+
10+
interface Answer {
11+
api: APIClient;
12+
workspaceId: string;
13+
value: boolean | null;
14+
}
15+
16+
/**
17+
* Whether the agent can look at its own HTML artifacts (`agent-browser` on the runtime's PATH).
18+
* null while unknown, including when the probe or the request failed.
19+
*/
20+
export function useAgentBrowserAvailable(workspaceId: string): boolean | null {
21+
const { api } = useAPI();
22+
// The answer remembers which (api, workspace) it is for: after either changes, an answer for
23+
// the old pair must not show (a failed request for the new pair would otherwise keep it).
24+
const [answer, setAnswer] = useState<Answer | null>(null);
25+
useEffect(() => {
26+
if (!api) return;
27+
let cancelled = false;
28+
// Promise.resolve() first, so a client without the route rejects instead of throwing here.
29+
Promise.resolve()
30+
.then(() => api.artifacts.capabilities({ workspaceId }))
31+
.then((result) => {
32+
const perClient = lastKnown.get(api) ?? new Map<string, boolean | null>();
33+
perClient.set(workspaceId, result.agentBrowserAvailable);
34+
lastKnown.set(api, perClient);
35+
if (!cancelled) setAnswer({ api, workspaceId, value: result.agentBrowserAvailable });
36+
})
37+
.catch(() => {
38+
// Unknown: no warning.
39+
});
40+
return () => {
41+
cancelled = true;
42+
};
43+
}, [api, workspaceId]);
44+
if (!api) return null;
45+
if (answer?.api === api && answer.workspaceId === workspaceId) return answer.value;
46+
return lastKnown.get(api)?.get(workspaceId) ?? null;
47+
}

‎src/browser/stories/mocks/orpc.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,12 @@ export interface MockORPCClientOptions {
204204
/** Optional file contents for memory.read keyed by virtual path. */
205205
memoryFileContents?: Map<string, string>;
206206
/** Artifacts tab: listing for artifacts.list and read results keyed by relative path. */
207-
artifacts?: { listing: ArtifactListing; files: Record<string, ArtifactReadResult> };
207+
artifacts?: {
208+
listing: ArtifactListing;
209+
files: Record<string, ArtifactReadResult>;
210+
/** artifacts.capabilities answer; omitted means unknown (null). */
211+
agentBrowserAvailable?: boolean | null;
212+
};
208213
/** Initial updater status for update.onStatus (About dialog stories). */
209214
updateStatus?: UpdateStatus;
210215
/** Release channel for update.getChannel. */
@@ -2192,6 +2197,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
21922197
: { success: false as const, error: `Artifact not found: ${input.path}` }
21932198
);
21942199
},
2200+
capabilities: () =>
2201+
Promise.resolve({ agentBrowserAvailable: artifacts.agentBrowserAvailable ?? null }),
21952202
},
21962203
// Memory curation surfaces (Memory tab / Settings → Memory). Backed by
21972204
// the `memoryFiles` option; mutations update the in-memory set so

‎src/common/orpc/schemas/api.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ import {
6161
MemoryFileInfoSchema,
6262
MemorySaveErrorSchema,
6363
} from "./memory";
64-
import { ArtifactListingSchema, ArtifactReadResultSchema } from "./artifacts";
64+
import {
65+
ArtifactCapabilitiesSchema,
66+
ArtifactListingSchema,
67+
ArtifactReadResultSchema,
68+
} from "./artifacts";
6569
import { ResultSchema } from "./result";
6670
import { SshPromptEventSchema, SshPromptResponseInputSchema } from "./ssh";
6771
import {
@@ -1293,6 +1297,11 @@ export const artifacts = {
12931297
}),
12941298
output: ResultSchema(ArtifactReadResultSchema, z.string()),
12951299
},
1300+
/** Runtime tooling probes (cached per workspace); never fails, null means unknown. */
1301+
capabilities: {
1302+
input: z.object({ workspaceId: z.string() }),
1303+
output: ArtifactCapabilitiesSchema,
1304+
},
12961305
};
12971306

12981307
/**

‎src/common/orpc/schemas/artifacts.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,12 @@ export const ArtifactReadResultSchema = z.discriminatedUnion("status", [
5757
z.object({ status: z.literal("binary"), ...ArtifactFileMetaSchema }),
5858
]);
5959
export type ArtifactReadResult = z.infer<typeof ArtifactReadResultSchema>;
60+
61+
export const ArtifactCapabilitiesSchema = z.object({
62+
/**
63+
* Whether `agent-browser` is on the runtime's PATH, so the agent can look at its own HTML
64+
* artifacts. null when not probed yet or the probe failed.
65+
*/
66+
agentBrowserAvailable: z.boolean().nullable(),
67+
});
68+
export type ArtifactCapabilities = z.infer<typeof ArtifactCapabilitiesSchema>;

‎src/common/utils/tools/toolDefinitions.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3319,6 +3319,8 @@ export const TOOL_DEFINITIONS = {
33193319
"HTML (.html) runs in a sandbox with no network: inline your JS/CSS or reference files next to it by relative path; scripts may also load from cdnjs, unpkg, jsDelivr (/npm/), code.jquery.com and cdn.tailwindcss.com if the user allows it. Send no secrets into HTML artifacts. " +
33203320
'To show JSON as a table, write {"$xum": "table", "columns": ["name", "value"], "rows": [{"name": "a", "value": 1}]}; "columns" is optional and each row is an object keyed by column or an array of cells. ' +
33213321
"Files over 10 MB are listed but not previewed. " +
3322+
"After writing an HTML artifact, if `agent-browser` is available, open file://$XUM_SCRATCH_DIR/artifacts/<file> at phone (390px) and desktop widths, take screenshots, and attach them to yourself with attach_file to catch broken layouts. " +
3323+
"That file:// page has no sandbox or CSP, so CDN-loaded content can look different than in the Artifacts tab. " +
33223324
"Update a file in place to update its artifact. Each workspace has its own folder, so a sub-agent's artifacts show in the sub-agent workspace, not its parent's. " +
33233325
"Call this tool to see what already exists, for example after a context reset.",
33243326
schema: z.object({}).strict(),

‎src/node/orpc/router.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ import {
3131
} from "@/node/services/agentPlugins/workspacePluginOperations";
3232
import { handlerGen } from "@orpc/experimental-effect";
3333
import { Effect } from "effect";
34-
import { listArtifacts, readArtifact } from "@/node/services/artifactsOperations";
34+
import {
35+
getArtifactsCapabilities,
36+
listArtifacts,
37+
readArtifact,
38+
} from "@/node/services/artifactsOperations";
3539
import {
3640
assertMemoryEnabled,
3741
consolidateMemoryEffect,
@@ -1522,6 +1526,10 @@ export const router = (authToken?: string) => {
15221526
.input(schemas.artifacts.read.input)
15231527
.output(schemas.artifacts.read.output)
15241528
.handler(({ context, input, signal }) => readArtifact(context, input, signal)),
1529+
capabilities: t
1530+
.input(schemas.artifacts.capabilities.input)
1531+
.output(schemas.artifacts.capabilities.output)
1532+
.handler(({ context, input }) => getArtifactsCapabilities(context, input)),
15251533
},
15261534
// Memory handlers run Effect generators via handlerGen (client aborts
15271535
// interrupt the fiber); the wire contracts are unchanged.

0 commit comments

Comments
 (0)