("read_item", { id });
+ if (alive) {
+ setDetail(data);
+ setLoadError(undefined);
+ if(data.observation?.refresh === "pending") timer=setTimeout(load,1000);
+ }
+ } catch (error) {
+ if (!alive) return;
+ const retrying = isConnectionFailure(error);
+ setLoadError({
+ message: retrying
+ ? "Connection interrupted. Retrying item…"
+ : String(error),
+ retrying,
+ });
+ if (retrying) {
+ timer = setTimeout(load, delay);
+ delay = Math.min(delay * 2, 30000);
+ }
+ }
+ }
+ void load();
return () => {
alive = false;
+ clearTimeout(timer);
};
- }, [id, entry?.item.revision, detailVersion, onError]);
+ }, [id, entry?.item.revision, linkedRevision, detailVersion]);
useEffect(() => {
- if (detail?.item.id === id)
+ if (
+ detail?.item.id === id && !detail.refreshError &&
+ (!detail.observation || detail.observation.refresh === "fresh")
+ )
void action("item_action", {
id,
action: "read",
revision: detail.item.revision,
});
- }, [detail?.item.id, detail?.item.revision, id, action]);
- if (!detail || detail.item.id !== id || !entry) return Loading item…
;
+ }, [detail?.item.id, detail?.item.revision, detail?.refreshError, detail?.observation?.refresh, id, action]);
+ if (!detail || detail.item.id !== id || !entry)
+ return (
+ <>
+
+ {loadError?.message ?? "Loading item…"}
+
+ {loadError && !loadError.retrying && (
+ setDetailVersion((v) => v + 1)}>
+ Retry loading item
+
+ )}
+ >
+ );
const i = detail.item;
+ const summaryParagraph = i.body
+ .split(/\n\s*\n/)
+ .map((paragraph) => paragraph.replace(/^ {0,3}#{1,6}(?:[ \t]+.*)?$/gm, "").trim())
+ .find(Boolean) ?? "";
+ const summary = summaryParagraph.slice(0, 600);
return (
<>
-
- {i.id} · {i.state} · {i.kind}
-
- {i.title}
-
- onLaunch(entry)}>
- Launch agent
-
- void mutate("item_action", { id, action: "unread" })}
- >
- Mark unread
-
-
- void mutate("item_action", { id, action: "acknowledge" })
- }
- >
- Acknowledge new
-
- void mutate("item_action", { id, action: "acted" })}
- >
- Record action
-
-
- {i.body || "No description."}
-
- Priority {entry.priority === null ? "pending" : `P${entry.priority}`}
-
-
- {entry.prioritySource} ·{" "}
- {entry.assessment.rationale ||
- entry.assessment.error ||
- entry.assessment.status}
+ {detail.observation?.refresh === "pending" &&
Refreshing saved details…
}
+ {(detail.refreshError || detail.observation?.refresh === "cooldown") && (
+
+ {detail.refreshError || "Refresh deferred; showing saved information."}{" "}
+ {detail.observation?.refresh === "cooldown" && detail.observation.retryAt &&
+ <>Retry after {new Date(detail.observation.retryAt).toLocaleString()}. >}
+ setDetailVersion(v => v + 1)}>Retry detail refresh
+
+ )}
+ {loadError && (
+
+ {loadError.message}
+
+ )}
+
+ {i.title}
+
+
+ {i.id} · {i.state}
+ {i.kind === "pr" ? " · PR" : ""} · {workStatus}
-
-
setPriority(e.target.value)}
- >
- Use labels or inference
- {[0, 1, 2, 3].map((p) => (
-
- P{p}
-
+
+ {i.body && (
+
+ {summary}
+ {summaryParagraph.length > 600 ? "…" : ""}
+
+ )}
+
+ {(i.pr ?? entry.item.pr) && (
+
+ )}
+ {!!(detail.linkedPullRequests ?? entry.linkedPullRequests)?.length && (
+
+ Linked pull requests
+ {(detail.linkedPullRequests ?? entry.linkedPullRequests)!.map((pr) => (
+
))}
-
-
- void mutate("item_action", {
- id,
- action: "priority",
- priority: priority === "" ? null : Number(priority),
- })
- }
- >
- Save priority
-
-
- Relationships
- {detail.relationships.map((l) => (
-
- {l.target}{" "}
- {l.manual ? "Local link" : `Reference: ${l.evidence}`}
- {l.manual && (
+
+ )}
+ {i.kind === "pr" && }
+ {conversation}
+
+ Item details
+ {history}
+ {i.body !== summary && (
+
+ Full description
+ {i.body}
+
+ )}
+
+
+ Priority
+ {
+ const value = e.target.value;
+ const previous = entry.user.override?.toString() ?? "";
+ setPriority(value);
+ setPriorityBusy(true);
+ setPriorityError("");
+ const saved = await action(
+ "item_action",
+ {
+ id,
+ action: "priority",
+ priority: value === "" ? null : Number(value),
+ },
+ setPriorityError,
+ );
+ if (!saved) setPriority(previous);
+ setPriorityBusy(false);
+ }}
+ >
+
+ Auto ·{" "}
+ {entry.priority === null ? "Pending" : `P${entry.priority}`}
+
+ {[0, 1, 2, 3].map((p) => (
+
+ P{p}
+
+ ))}
+
+
+ onLaunch(entry)}>New run
+
+ {priorityBusy && Saving… }
+ {priorityError && {priorityError}
}
+ {entry.user.override === null && entry.assessment.error && (
+ {entry.assessment.error}
+ )}
+
+ Actions
+
- void mutate("set_relationship", {
- source: id,
- target: l.target,
- remove: true,
- })
+ void mutate("item_action", { id, action: "unread" })
}
>
- Remove
+ Mark unread
- )}
-
- ))}
-
- {detail.tickets.map((t) => (
-
- {t.id}: {t.summary || t.error} · {t.status}
-
- ))}
- {i.head && (
-
- Head: {i.head} · {i.branch}
-
- )}
- {i.checks && (
+
+ void mutate("item_action", { id, action: "acknowledge" })
+ }
+ >
+ Acknowledge new
+
+
+ void mutate("item_action", { id, action: "acted" })
+ }
+ >
+ Record action
+
+
+
- Checks
- {JSON.stringify(i.checks, null, 2)}
+ Priority details
+
+ {entry.prioritySource} ·{" "}
+ {entry.assessment.rationale ||
+ entry.assessment.error ||
+ entry.assessment.status}
+
- )}
- Activity
- {i.activity
- .filter((a) => a.kind !== "reference")
- .map((a, n) => (
-
-
- {a.kind} · {a.author}
-
- {a.body}
-
+ setLinksOpen(e.currentTarget.open)}
+ >
+ Relationships
+ {detail.relationships.map((l) => (
+
+ {l.target}{" "}
+
+ {l.manual ? "Local link" : `Reference: ${l.evidence}`}
+
+ {l.manual && (
+
+ void mutate("set_relationship", {
+ source: id,
+ target: l.target,
+ remove: true,
+ })
+ }
+ >
+ Remove
+
+ )}
+
+ ))}
+
+
+ {detail.tickets.map((t) => (
+
+ {t.id}: {t.summary || t.error} · {t.status}
+
))}
- Your action history
- {detail.user.actions.map((a) => (
-
- {a.kind} · {new Date(a.at).toLocaleString()}
-
- ))}
+ {i.head && (
+
+ Head: {i.head} · {i.branch}
+
+ )}
+ {Boolean(i.checks) && (
+
+ Checks
+ {JSON.stringify(i.checks, null, 2)}
+
+ )}
+ {i.activity.some((a) => a.kind !== "reference") && (
+
+ Activity
+ {i.activity
+ .filter((a) => a.kind !== "reference")
+ .map((a, n) => (
+
+
+ {a.kind} · {a.author}
+
+ {a.body}
+
+ ))}
+
+ )}
+ {detail.user.actions.length > 0 && (
+
+ Your action history
+ {detail.user.actions.map((a) => (
+
+ {a.kind} · {new Date(a.at).toLocaleString()}
+
+ ))}
+
+ )}
+
>
);
}
@@ -676,25 +1588,48 @@ function Launch({
snap,
close,
started,
+ initialRun,
+ changed,
}: {
- entry: Entry;
+ entry?: Entry;
+ initialRun?: Run;
+ changed: () => Promise;
snap: Snapshot;
close: () => void;
- started: (id: string) => void;
+ started: (run: Run) => void;
}) {
- const [profileId, setProfileId] = useState(snap.profiles[0]?.id ?? "");
- const p = snap.profiles.find((p) => p.id === profileId)!;
+ const [profileId, setProfileId] = useState(
+ initialRun?.profile.id ??
+ (entry?.item.kind === "pr"
+ ? snap.repositories.find((r) => r.id === entry.item.repository)
+ ?.reviewProfileId ||
+ snap.config.reviewProfileId ||
+ "review"
+ : snap.repositories.find((r) => r.id === entry?.item.repository)
+ ?.implementationProfileId ||
+ snap.config.implementationProfileId ||
+ "implement"),
+ );
+ const p =
+ snap.profiles.find((p) => p.id === profileId) ?? initialRun?.profile;
const expand = (p: Profile) =>
p.prompt
- .replaceAll("{{title}}", entry.item.title)
- .replaceAll("{{body}}", entry.item.body)
- .replaceAll("{{url}}", entry.item.url);
- const [prompt, setPrompt] = useState(p ? expand(p) : "");
+ .replaceAll("{{title}}", entry?.item.title ?? "")
+ .replaceAll("{{body}}", entry?.item.body ?? "")
+ .replaceAll("{{url}}", entry?.item.url ?? "");
+ const [prompt, setPrompt] = useState(
+ initialRun?.prompt ?? (p ? expand(p) : ""),
+ );
const [model, setModel] = useState(p?.model ?? "");
const [effort, setEffort] = useState(p?.effort ?? "");
- const [skillIds, setSkills] = useState(p?.skills ?? []);
- const [base, setBase] = useState("");
- const [prepared, setPrepared] = useState();
+ const [skillIds, setSkills] = useState(
+ initialRun?.skills.map((s) => s.id) ?? p?.skills ?? [],
+ );
+ const [base, setBase] = useState(initialRun?.base ?? "");
+ const [prepared, setPrepared] = useState(initialRun);
+ const [editing, setEditing] = useState(false);
+ const [uncertain, setUncertain] = useState(false);
+ const initialized = useRef(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const dialogRef = useModalFocus(close);
@@ -702,23 +1637,56 @@ function Launch({
setBusy(true);
setError("");
try {
- setPrepared(
- await call("prepare_launch", {
- itemId: entry.item.id,
- profileId,
- prompt,
- model,
- effort,
- skillIds,
- base,
- }),
- );
+ const run = await call("prepare_launch", {
+ itemId: entry?.item.id ?? initialRun?.itemId,
+ profileId,
+ prompt,
+ model,
+ effort,
+ skillIds,
+ base,
+ });
+ setPrepared(run);
+ setEditing(false);
+ void changed().catch(() => {});
} catch (e) {
setError(String(e));
} finally {
setBusy(false);
}
}
+ useEffect(() => {
+ if (!initialized.current) {
+ initialized.current = true;
+ if (!initialRun) void prepare();
+ }
+ }, []);
+ async function checkStart(id: string, failure = "") {
+ setBusy(true);
+ try {
+ const { run } = await call<{ run: Run; attention: Attention[] }>(
+ "read_run",
+ { id },
+ );
+ if (!run || run.id !== id)
+ throw new Error("Run status could not be verified.");
+ if (run.status !== "prepared") {
+ started(run);
+ return;
+ }
+ setUncertain(false);
+ setError(
+ `${failure} The run is still prepared. You can Start again.`.trim(),
+ );
+ } catch (e) {
+ setUncertain(true);
+ setError(
+ `${failure} Start status is uncertain. Check status before trying again. ${String(e)}`.trim(),
+ );
+ } finally {
+ setBusy(false);
+ }
+ }
return (
×
- {prepared ? "Review agent launch" : "Launch agent"}
+
+ {editing
+ ? "Edit agent launch"
+ : prepared
+ ? "Review agent launch"
+ : "Launch agent"}
+
{error && {error}
}
- {entry.item.id} · {entry.item.title}
+ {entry?.item.id ?? initialRun?.itemId} · {entry?.item.title}
- {!prepared ? (
+ {!prepared && !editing ? (
+
+ {busy ? "Preparing review…" : "Unable to prepare launch."}
+ {!busy && (
+ <>
+ void prepare()}>
+ Retry preparation
+
+ setEditing(true)}>Edit
+ >
+ )}
+
+ ) : editing ? (
<>
Launch profile
@@ -769,67 +1755,70 @@ function Launch({
rows={8}
/>
-
-
- Model
- setModel(e.target.value)}
- placeholder="Use local default"
- />
-
-
- Reasoning effort
- setEffort(e.target.value)}
- >
- {["", "low", "medium", "high", "xhigh", "max", "ultra"].map(
- (v) => (
-
- {v || "Local default"}
-
- ),
- )}
-
-
-
-
- Base reference
- setBase(e.target.value)}
- placeholder={
- entry.item.kind === "pr" ? "PR head" : "Repository default"
- }
- />
-
-
- Skills
- {snap.skills.map((s) => (
-
+
+ Advanced
+
+
+ Model
- setSkills(
- e.target.checked
- ? [...skillIds, s.id]
- : skillIds.filter((v) => v !== s.id),
- )
- }
+ value={model}
+ onChange={(e) => setModel(e.target.value)}
+ placeholder="Use local default"
/>
- {s.name}
- ))}
-
+
+ Reasoning effort
+ setEffort(e.target.value)}
+ >
+ {["", "low", "medium", "high", "xhigh", "max", "ultra"].map(
+ (v) => (
+
+ {v || "Local default"}
+
+ ),
+ )}
+
+
+
+
+ Base reference
+ setBase(e.target.value)}
+ placeholder={
+ entry?.item.kind === "pr" ? "PR head" : "Repository default"
+ }
+ />
+
+
+ Skills
+ {snap.skills.map((s) => (
+
+
+ setSkills(
+ e.target.checked
+ ? [...skillIds, s.id]
+ : skillIds.filter((v) => v !== s.id),
+ )
+ }
+ />
+ {s.name}
+
+ ))}
+
+
- Workspace: a dedicated worktree · {p.sandbox} · approvals:{" "}
- {p.approval}
+ Mode: {p?.mode ?? "default"} · Permissions: {p?.sandbox} ·{" "}
+ {p?.approval}
Publishing:{" "}
- {p.publishing === "ask"
+ {p?.publishing === "ask"
? "Requires your decision"
: "Authorized by profile"}
@@ -842,67 +1831,135 @@ function Launch({
>
) : (
- <>
-
- Workspace
- {prepared.worktree}
- Branch / base
-
- {prepared.branch} / {prepared.base}
-
- Permissions
-
- {prepared.profile.sandbox} · {prepared.profile.approval}
-
- Publishing
-
- {prepared.profile.publishing === "ask"
- ? "Ask before publishing"
- : "Authorized"}
-
- Skills
-
- {prepared.skills.map((s) => s.name).join(", ") ||
- "None selected"}
-
-
- {prepared.prompt}
-
- {
- setPrepared(undefined);
- setError("");
- }}
- >
- Edit launch
-
- {
- setBusy(true);
- setError("");
- try {
- const r = await call("start_run", { id: prepared.id });
- started(r.id);
- } catch (e) {
- setError(String(e));
- setBusy(false);
- }
- }}
- >
- Start agent
-
-
- >
+ prepared && (
+ <>
+
+ Profile
+ {prepared.profile.name}
+ Agent mode
+ {prepared.profile.mode ?? "default"}
+ Workspace
+ {prepared.worktree}
+ Branch / base
+
+ {prepared.branch} / {prepared.base}
+
+ Permissions
+
+ {prepared.profile.sandbox} · {prepared.profile.approval}
+
+ Publishing
+
+ {prepared.profile.publishing === "ask"
+ ? "Ask before publishing"
+ : "Authorized"}
+
+ Skills
+
+ {prepared.skills.map((s) => s.name).join(", ") ||
+ "None selected"}
+
+
+ {prepared.prompt}
+
+ {
+ setEditing(true);
+ setError("");
+ }}
+ >
+ Edit
+
+ {
+ setBusy(true);
+ setError("");
+ try {
+ const r = await call("start_run", {
+ id: prepared.id,
+ });
+ started(r);
+ } catch (e) {
+ setUncertain(true);
+ await checkStart(prepared.id, String(e));
+ }
+ }}
+ >
+ Start agent
+
+ {uncertain && (
+ void checkStart(prepared.id)}
+ >
+ Check start status
+
+ )}
+
+ >
+ )
)}
);
}
+// Older journals split a provider's JSON params into contiguous chunks without
+// an end marker. Frame once, then decode once, including across fetched pages.
+function transcriptEvents(events: JournalEvent[]) {
+ const decoded: JournalEvent[] = [];
+ let pending: JournalEvent[] = [];
+ let depth = 0, quoted = false, escaped = false;
+ const flush = () => {
+ decoded.push(...pending);
+ pending = [];
+ depth = 0;
+ quoted = escaped = false;
+ };
+ for (const event of events) {
+ const method = event.data.method ?? "";
+ if (!method.endsWith("/chunk")) {
+ flush();
+ decoded.push(event);
+ continue;
+ }
+ if (pending.length &&
+ (pending[0].data.method !== method || pending[0].entity !== event.entity)) flush();
+ pending.push(event);
+ for (const char of String(event.data.params?.delta ?? "")) {
+ if (quoted) {
+ if (escaped) escaped = false;
+ else if (char === "\\") escaped = true;
+ else if (char === '"') quoted = false;
+ } else if (char === '"') quoted = true;
+ else if (char === "{" || char === "[") depth++;
+ else if (char === "}" || char === "]") depth--;
+ }
+ if (depth === 0 && !quoted) {
+ try {
+ const params: unknown = JSON.parse(pending.map((part) => part.data.params?.delta ?? "").join(""));
+ if (!params || typeof params !== "object" || Array.isArray(params)) throw new Error("Invalid provider params");
+ decoded.push({ ...pending[0], data: { method: method.slice(0, -6), params: params as Record } });
+ pending = [];
+ } catch {
+ flush();
+ }
+ } else if (depth < 0) flush();
+ }
+ flush();
+ return decoded;
+}
function transcriptBlocks(events: JournalEvent[]) {
- const blocks: { key: number; id: string; label: string; text: string }[] = [];
- for (const e of events) {
+ const blocks: {
+ key: number;
+ id: string;
+ label: string;
+ text: string;
+ details?: string;
+ }[] = [];
+ for (const e of transcriptEvents(events)) {
const p = e.data.params ?? {};
const m = e.data.method ?? "";
const item = p.item as
@@ -918,7 +1975,18 @@ function transcriptBlocks(events: JournalEvent[]) {
| undefined;
let label = "";
let text = "";
- if (m.includes("agentMessage/delta")) {
+ let details: string | undefined;
+ if (m === "codefactory/messageDelivery") {
+ label = "Message delivery";
+ text =
+ p.state === "queued"
+ ? "Message saved. Awaiting agent delivery."
+ : p.state === "delivering"
+ ? "Message saved. Delivering to agent…"
+ : p.state === "delivered"
+ ? "Message delivered to agent."
+ : String(p.error ?? "Agent delivery is uncertain. Check this session before sending again.");
+ } else if (m.includes("agentMessage/delta")) {
label = "Agent";
text = String(p.delta ?? "");
} else if (m.includes("commandExecution/outputDelta")) {
@@ -957,7 +2025,17 @@ function transcriptBlocks(events: JournalEvent[]) {
text = String(p.delta ?? "");
} else if (m === "error") {
label = "Run error";
- text = JSON.stringify(p.error ?? p);
+ const error = p.error ?? p;
+ text =
+ typeof error === "string"
+ ? error
+ : typeof error === "object" &&
+ error !== null &&
+ "message" in error &&
+ typeof error.message === "string"
+ ? error.message
+ : "The agent reported an error.";
+ if (typeof error !== "string") details = JSON.stringify(error, null, 2);
}
if (!label || !text) continue;
const id = String(p.itemId ?? item?.id ?? "");
@@ -967,7 +2045,8 @@ function transcriptBlocks(events: JournalEvent[]) {
if (existing) {
if (m.endsWith("/delta") || m.endsWith("Delta")) existing.text += text;
else existing.text = text;
- } else blocks.push({ key: e.seq, id, label, text });
+ if (details) existing.details = details;
+ } else blocks.push({ key: e.seq, id, label, text, details });
}
return blocks;
}
@@ -975,45 +2054,38 @@ function transcriptBlocks(events: JournalEvent[]) {
function Agent({
run,
attention,
- recoveries,
- confirmation,
- message,
- setMessage,
- clearMessage,
- inspect,
action,
openAttention,
onError,
+ title,
+ message,
+ setMessage,
+ embedded = false,
+ consoleView,
+ setConsoleView,
+ recovery,
}: {
+ embedded?: boolean;
+ consoleView: boolean;
+ setConsoleView: (value: boolean) => void;
+ recovery?: QuestionRecovery;
run: Run;
- attention: Attention[];
- recoveries: QuestionRecovery[];
- confirmation?: string;
+ title: string;
message: string;
- setMessage: (text: string) => void;
- clearMessage: (sent: string) => void;
- inspect: boolean;
+ setMessage: React.Dispatch>;
+ attention: Attention[];
action: Action;
openAttention: (id: string) => void;
onError: (e: string) => void;
}) {
const [events, setEvents] = useState([]);
+ const [historyError, setHistoryError] = useState<{
+ message: string;
+ retrying: boolean;
+ }>();
const [cursor, setCursor] = useState(0);
const [hasMore, setMore] = useState(false);
const [busy, setBusy] = useState(false);
- const transcript = useRef(null);
- useEffect(() => {
- if (inspect) transcript.current?.focus();
- }, [inspect]);
- const recovery = recoveries.find((r) =>
- [
- "checking",
- "clarifying",
- "requesting-form",
- "awaiting-answer",
- "answered",
- ].includes(r.stage),
- );
const pageCursor = useRef(0);
const [historyVersion, setHistoryVersion] = useState(0);
useEffect(() => {
@@ -1032,10 +2104,17 @@ function Agent({
pageCursor.current = p.cursor;
setCursor(p.cursor);
setMore(p.hasMore);
+ setHistoryError(undefined);
delay = 1000;
}
} catch (e) {
- if (!stop) onError(String(e));
+ if (!stop)
+ setHistoryError({
+ message: isConnectionFailure(e)
+ ? "Connection interrupted. Retrying conversation…"
+ : String(e),
+ retrying: isConnectionFailure(e),
+ });
delay = Math.min(delay * 2, 30000);
}
if (!stop) timer = setTimeout(load, document.hidden ? 15000 : delay);
@@ -1045,91 +2124,105 @@ function Agent({
stop = true;
clearTimeout(timer);
};
- }, [run.id, onError, historyVersion]);
+ }, [run.id, historyVersion]);
+ const blocks = transcriptBlocks(events);
+ const conversation = blocks.filter((b) =>
+ ["Agent", "You", "Run error", "Message delivery"].includes(b.label),
+ );
+ const activity = blocks.flatMap((b) =>
+ b.details
+ ? [{ ...b, label: "Error details", text: b.details }]
+ : ["Agent", "You", "Run error", "Message delivery"].includes(b.label)
+ ? []
+ : [b],
+ );
return (
<>
-
- {run.status === "waiting" && }
- {run.status}
-
- {confirmation && (
-
- {confirmation}
-
- )}
- {recovery && (
-
-
- {recovery.stage === "checking"
- ? "Checking for a pending question"
- : recovery.stage === "clarifying"
- ? `Clarifying whether input is needed · attempt ${recovery.attempts} of 2`
- : recovery.stage === "requesting-form"
- ? `Recovering question · attempt ${recovery.attempts} of 2`
- : recovery.stage === "awaiting-answer"
- ? "Question recovered · waiting for your answer"
- : "Restoring original task mode"}
- void action("interrupt_run", { id: run.id })}>
- Take over
-
-
+ {!embedded && (
+ <>
+ {title}
+ a.blocking)} />
+ >
)}
- {run.profile.name}
-
- {run.worktree}
-
{run.error && {run.error}
}
+ {embedded && (
+
+ {run.profile.name} · {run.status}
+
+ )}
- {run.turnId && (
+ setConsoleView(!consoleView)}>
+ {consoleView ? "View conversation" : "View session console"}
+
+ {consoleView && run.turnId && (
void action("interrupt_run", { id: run.id })}>
- Interrupt
-
- )}
- {["interrupted", "failed"].includes(run.status) && (
-
- void action("start_run", { id: run.id, resume: true })
- }
- >
- Resume agent
-
- )}
- {["idle", "interrupted", "failed"].includes(run.status) && (
- void action("cleanup_run", { id: run.id })}>
- Clean up worktree
+ Interrupt session
)}
+ {recovery &&
+ ["checking", "clarifying", "requesting-form", "awaiting-answer", "answered"].includes(recovery.stage) && (
+
+
+ {recovery.stage === "checking"
+ ? "Checking whether the agent needs input…"
+ : recovery.stage === "clarifying"
+ ? `Clarifying whether input is needed · attempt ${recovery.attempts} of 2`
+ : recovery.stage === "awaiting-answer"
+ ? "Question ready for your answer."
+ : recovery.stage === "answered"
+ ? "Returning to the selected task mode…"
+ : `Preparing a question · attempt ${recovery.attempts} of 2`}
+
+ {
+ setConsoleView(true);
+ void action("interrupt_run", { id: run.id });
+ }}
+ >
+ Take over session
+
+
+ )}
{attention.map((a) => (
openAttention(a.id)}
>
- {a.blocking && }
{a.blocking
? "Waiting for your answer"
: "Question · agent continues"}{" "}
— Answer
))}
-
- Technical transcript
-
-
- {transcriptBlocks(events).map((b) => (
+ {historyError && (
+
+ {historyError.message}
+
+ )}
+
+ {(consoleView ? blocks : conversation).map((b) => (
- {b.label}
- {b.text}
+ {b.label === "Run error" ? "Agent error" : b.label}
+ {consoleView ? (
+ {b.text}
+ ) : (
+ {b.text}
+ )}
))}
- {!events.length &&
Waiting for agent output…
}
+ {!conversation.length && (
+
+ {["running", "waiting", "starting", "queued"].includes(run.status)
+ ? "Waiting for an agent update…"
+ : "No agent messages yet."}
+
+ )}
{hasMore &&
Loading more history after event {cursor}…
}
{events.length >= 1000 && (
@@ -1148,14 +2241,17 @@ function Agent({
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
- const sent = message;
+ const submitted = message;
try {
- await call("send_message", {
+ const sent = await action("send_message", {
id: run.id,
text: message,
- expectedTurn: run.turnId,
+ expectedTurn: ["running", "waiting"].includes(run.status)
+ ? run.turnId
+ : "",
});
- clearMessage(sent);
+ if (!sent) return;
+ setMessage((current) => (current === submitted ? "" : current));
} catch (e) {
onError(String(e));
} finally {
@@ -1176,28 +2272,65 @@ function Agent({
disabled={
busy ||
!message.trim() ||
- !["idle", "running", "waiting"].includes(run.status) ||
- !!recovery
+ !["idle", "running", "waiting", "interrupted", "failed"].includes(
+ run.status,
+ ) || !!recovery && ["checking", "clarifying", "requesting-form", "awaiting-answer", "answered"].includes(recovery.stage)
}
>
Send message
+
+ Details
+ {run.profile.name}
+ {run.turnId && ["running", "waiting"].includes(run.status) && (
+ void action("interrupt_run", { id: run.id })}>
+ Interrupt
+
+ )}
+ {!consoleView && activity.length > 0 && (
+
+ {activity.map((b) => (
+
+ {b.label}
+ {b.text}
+
+ ))}
+
+ )}
+
+ Workspace: {run.worktree}
+
+ {["idle", "interrupted", "failed", "terminated"].includes(run.status) && (
+ <>
+
+ Cleanup retires the retained session and removes a clean worktree.
+ Conversation history stays available; modified files prevent
+ removal.
+
+ void action("cleanup_run", { id: run.id })}>
+ Clean up worktree
+
+ >
+ )}
+
>
);
}
function AttentionDialog({
attention: a,
+ context,
close,
answer,
- dismiss,
chat,
+ dismiss,
}: {
attention: Attention;
+ context: string;
close: () => void;
answer: (value: unknown) => Promise
;
- dismiss: () => Promise;
chat: () => void;
+ dismiss?: () => Promise;
}) {
const [values, setValues] = useState>({});
const [error, setError] = useState("");
@@ -1227,10 +2360,13 @@ function AttentionDialog({
×
Agent needs attention
+ {context}
- {a.blocking
- ? "The agent is waiting for your response."
- : "The agent continues while you answer."}
+ {a.origin === "text"
+ ? "The agent stopped without a usable question form. Review its reply, answer here, or choose No reply needed."
+ : a.blocking
+ ? "The agent is waiting for your response."
+ : "The agent continues while you answer."}
{error && {error}
}
{questions ? (
@@ -1289,7 +2425,11 @@ function AttentionDialog({
) : (
<>
- {JSON.stringify(a.params, null, 2)}
+
+
+ Technical details
+ {JSON.stringify(a.params, null, 2)}
+
{a.method.includes("permissions") ? (
<>
@@ -1299,7 +2439,7 @@ function AttentionDialog({
void submit({ permissions: {}, scope: "turn" })
}
>
- Deny permissions
+ Deny
- Approve requested permissions for this turn
+ Allow once
>
) : a.method.includes("elicitation") ? (
@@ -1324,134 +2464,271 @@ function AttentionDialog({
key={n}
onClick={() => void submit({ decision: d })}
>
- {typeof d === "string" ? d : JSON.stringify(d)}
+ {decisionLabel(d)}
))
)}
>
)}
- {a.origin === "text" && (
- void dismiss()}>
+
+ {a.origin === "text" ? "Open session console" : "Open agent chat"}
+
+ {dismiss && (
+ {
+ setBusy(true);
+ try {
+ await dismiss();
+ } catch (e) {
+ setError(String(e));
+ setBusy(false);
+ }
+ }}
+ >
No reply needed
)}
-
- {a.origin === "text"
- ? "Inspect technical transcript"
- : "Open agent chat"}
-
);
}
-function Settings({
- snap,
- action,
- onError,
+function decisionLabel(decision: unknown): string {
+ const labels: Record = {
+ accept: "Allow once",
+ acceptForSession: "Allow for this session",
+ decline: "Deny",
+ cancel: "Cancel",
+ };
+ if (typeof decision === "string") return labels[decision] ?? decision;
+ if (decision && typeof decision === "object") {
+ if ("acceptWithExecpolicyAmendment" in decision)
+ return "Allow and update command policy";
+ if ("applyNetworkPolicyAmendment" in decision)
+ return "Apply network access decision";
+ }
+ return "Apply requested decision";
+}
+function ApprovalDetails({ params }: { params: Attention["params"] }) {
+ const readable = (value: unknown): React.ReactNode => {
+ if (Array.isArray(value))
+ return value.map((v, i) => {readable(v)}
);
+ if (value && typeof value === "object")
+ return (
+
+ {Object.entries(value).map(([key, v]) => (
+
+
+ {key.replace(/([a-z])([A-Z])/g, "$1 $2").replaceAll("_", " ")}
+
+ {readable(v)}
+
+ ))}
+
+ );
+ return String(value);
+ };
+ return (
+
+ {[
+ ["Command", params.command],
+ ["Reason", params.reason],
+ ["Working directory", params.cwd],
+ [
+ "Requested access",
+ params.permissions ??
+ params.additionalPermissions ??
+ params.networkApprovalContext ??
+ params.grantRoot,
+ ],
+ ["File changes", params.changes],
+ ]
+ .filter(([, value]) => value != null)
+ .map(([label, value]) => (
+
+ {String(label)}
+ {readable(value)}
+
+ ))}
+
+ );
+}
+function SaveForm({
+ save,
+ children,
}: {
- snap: Snapshot;
- action: Action;
- onError: (e: string) => void;
+ save: () => Promise;
+ children: React.ReactNode;
}) {
+ const [status, setStatus] = useState("");
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ return (
+
+ );
+}
+function Settings({ snap, action }: { snap: Snapshot; action: Action }) {
+ async function save(name: string, args: Record) {
+ let error = "Could not save.";
+ if (
+ !(await action(name, args, (message) => {
+ error = message;
+ }))
+ )
+ throw new Error(error.replace(/^Error: /, ""));
+ }
const [config, setConfig] = useState(snap.config);
const [labels, setLabels] = useState(
- JSON.stringify(snap.config.priorityLabels, null, 2),
+ Object.entries(snap.config.priorityLabels).map(([label, priority]) => ({
+ label,
+ priority,
+ })),
);
- const [repo, setRepo] = useState("");
- const [checkout, setCheckout] = useState("");
- const [base, setBase] = useState("");
- const [token, setToken] = useState("");
const [profile, setProfile] = useState(snap.profiles[0]);
+ const [skillDirectory, setSkillDirectory] = useState("/");
const [skillName, setSkillName] = useState("");
const [skillContent, setSkillContent] = useState(
"---\nname: my-skill\ndescription: Explain when this skill applies.\n---\n\n",
);
const [diagnostics, setDiagnostics] =
useState();
+ const [diagnosticState, setDiagnosticState] = useState("");
+ const [diagnosticError, setDiagnosticError] = useState("");
return (
-
- Repositories
- {snap.repositories.map((r) => (
-
- {r.id} · {r.checkout || "Managed clone"} · {r.error || "Connected"}
-
- ))}
-
-
- void action("discover_skills", { cwd: checkout || "/" })
- }
- >
- Discover local skills
-
-
-
-
-
- Managed skills
+
+
+
+ Managed skills
{
@@ -1695,14 +2935,10 @@ function Settings({
))}
- {
- e.preventDefault();
- void action("save_skill", {
- name: skillName,
- content: skillContent,
- });
- }}
+
+ save("save_skill", { name: skillName, content: skillContent })
+ }
>
Skill name
@@ -1722,19 +2958,30 @@ function Settings({
/>
Save skill
-
-
-
- Connection diagnostics
+
+
+
+ Connection diagnostics
- void call("diagnostics")
- .then(setDiagnostics)
- .catch((e) => onError(String(e)))
- }
+ disabled={diagnosticState === "Checking…"}
+ onClick={async () => {
+ setDiagnosticState("Checking…");
+ setDiagnosticError("");
+ try {
+ setDiagnostics(
+ await call("diagnostics"),
+ );
+ setDiagnosticState("Checked.");
+ } catch (e) {
+ setDiagnosticState("");
+ setDiagnosticError(String(e));
+ }
+ }}
>
Check connection
+ {diagnosticState && {diagnosticState}
}
+ {diagnosticError && {diagnosticError}
}
{diagnostics !== undefined && (
@@ -1753,7 +3000,7 @@ function Settings({
GitHub and Codex reuse local sign-in. The setup skill can guide
authorization using Work computer access.
-
+
);
}
diff --git a/web/src/modal-focus.ts b/web/src/modal-focus.ts
index 4f098bc..f77f9a5 100644
--- a/web/src/modal-focus.ts
+++ b/web/src/modal-focus.ts
@@ -19,7 +19,7 @@ export function useModalFocus(close: () => void) {
const controls = () =>
[
...dialog.querySelectorAll(
- "button, a[href], input:not([type=hidden]), select, textarea, [tabindex]",
+ "summary, button, a[href], input:not([type=hidden]), select, textarea, [tabindex]",
),
].filter(
(element) =>
@@ -79,9 +79,21 @@ export function useModalFocus(close: () => void) {
stack.splice(stack.indexOf(dialog), 1);
if (
opener?.isConnected &&
+ !opener.matches("body, html, .page") &&
(!stack.length || stack.at(-1)!.contains(opener))
)
opener.focus();
+ else if (!stack.length) {
+ const heading = doc.querySelector(
+ ".detail h1, .detail h2",
+ );
+ const fallback =
+ heading ?? doc.querySelector("nav button[aria-current]");
+ if (fallback) {
+ if (heading) heading.tabIndex = -1;
+ fallback.focus();
+ }
+ }
};
}, []);
return ref;
diff --git a/web/src/reports.tsx b/web/src/reports.tsx
index c1f6af3..28290ec 100644
--- a/web/src/reports.tsx
+++ b/web/src/reports.tsx
@@ -181,7 +181,7 @@ export function ReportedIssues({ cursor }: { cursor: number }) {
}
}
return (
-
+
Reported issues
diff --git a/web/src/reviews.tsx b/web/src/reviews.tsx
new file mode 100644
index 0000000..751881c
--- /dev/null
+++ b/web/src/reviews.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from "react";
+import { call, openLink } from "./bridge";
+import type { PRReviews } from "./types";
+
+export function PostedReviews({
+ itemId,
+ head,
+ version,
+}: {
+ itemId: string;
+ head: string;
+ version: string;
+}) {
+ const [data, setData] = useState
();
+ const [error, setError] = useState("");
+ const [refresh, setRefresh] = useState(0);
+ useEffect(() => {
+ let alive = true;
+ let timer: ReturnType;
+ async function load() {
+ let delay = 60000;
+ try {
+ const result = await call("read_pr_reviews", { id: itemId });
+ if(result.refresh === "pending") delay = 1000;
+ if (alive) {
+ setData(result);
+ setError("");
+ }
+ } catch (e) {
+ if (alive) setError(String(e));
+ }
+ if (alive) timer = setTimeout(load, delay);
+ }
+ void load();
+ return () => {
+ alive = false;
+ clearTimeout(timer);
+ };
+ }, [itemId, version, refresh]);
+ function link(url: string, label: string) {
+ return (
+ {
+ e.preventDefault();
+ void openLink(url)
+ .then((r) => {
+ if (r.isError) setError("The host could not open GitHub.");
+ })
+ .catch(() => setError("Could not open GitHub."));
+ }}
+ >
+ {label}
+
+ );
+ }
+ const saved = data?.itemId === itemId ? data : undefined;
+ const verified = saved?.syncedAt && !saved.syncedAt.startsWith("0001-");
+ return (
+
+ Posted reviews
+ setRefresh((v) => v + 1)}>Refresh reviews
+ {(error || saved?.error) && {error || saved?.error}
}
+ {!saved && !error && Loading posted reviews…
}
+ {saved && (
+
+ {verified
+ ? `Last checked ${new Date(saved.syncedAt).toLocaleString()}`
+ : "Publication has not been verified."}
+
+ )}
+ {saved?.refresh === "pending" && Refreshing saved reviews…
}
+ {saved?.refresh === "paused" && Repository monitoring is paused.
}
+ {saved?.refresh === "cooldown" && Refresh is waiting for its retry time.
}
+ {verified && !saved?.reviews.length && (
+ No posted reviews found at the last successful check.
+ )}
+ {saved?.reviews.map((r) => (
+
+
+ {link(r.url, `Review by ${r.author}`)} ·{" "}
+ {r.state.replaceAll("_", " ")} ·{" "}
+
+ {new Date(r.submittedAt).toLocaleString()}
+
+
+
+ Commit {r.head.slice(0, 12)} ·{" "}
+ {!head ? "Head comparison unavailable" : r.head === head ? "Current head" : "Earlier head"}
+
+ {r.body && {r.body}
}
+ {r.comments.map((c) => (
+
+
+ {link(c.url, `${c.path}${c.line ? `:${c.line}` : ""}`)} ·{" "}
+ {c.author}
+
+
{c.body}
+
+ ))}
+
+ ))}
+
+ );
+}
diff --git a/web/src/style.css b/web/src/style.css
index 4d4730d..35f42f2 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -1,9 +1,9 @@
:root {
font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif);
+ font-size: 14px;
+ font-synthesis: none;
color: #22332d;
background: #f6f8f5;
- font-synthesis: none;
- font-size: 14px;
--panel: #fff;
--background: #f6f8f5;
--line: #dce4dd;
@@ -34,8 +34,7 @@ main {
background: var(--background);
}
main {
- max-width: 1400px;
- margin: auto;
+ width: 100%;
}
button,
input,
@@ -45,10 +44,11 @@ textarea {
color: inherit;
}
button {
+ min-height: 30px;
+ padding: 5px 8px;
border: 1px solid var(--line);
+ border-radius: 4px;
background: var(--panel);
- padding: 9px 13px;
- border-radius: 7px;
cursor: pointer;
}
button:hover {
@@ -58,66 +58,30 @@ button:disabled {
opacity: 0.5;
cursor: default;
}
-:where(button, input, select, textarea, a[href]):focus-visible {
+:where(button, input, select, textarea, a[href], summary):focus-visible {
outline: 3px solid var(--accent);
- outline-offset: 3px;
+ outline-offset: 2px;
}
input,
select,
textarea {
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 6px;
- padding: 9px;
+ min-width: 0;
max-width: 100%;
+ padding: 6px 8px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: var(--panel);
}
textarea {
- resize: vertical;
width: 100%;
-}
-header {
- padding: 24px 30px;
- display: flex;
- justify-content: space-between;
- align-items: center;
- border-bottom: 1px solid var(--line);
-}
-.brand {
- display: flex;
- gap: 12px;
- align-items: center;
-}
-.mark {
- display: grid;
- place-items: center;
- background: #285d45;
- color: white;
- width: 42px;
- height: 42px;
- border-radius: 10px;
- font-weight: 800;
-}
-h1 {
- font-size: 22px;
- margin: 0;
-}
-h2 {
- font-size: 20px;
-}
-h3 {
- font-size: 15px;
-}
-small,
-.item-meta,
-.connection {
- color: var(--muted);
- font-size: 12px;
+ resize: vertical;
}
nav {
display: flex;
flex-wrap: wrap;
- gap: 8px;
- padding: 12px 30px;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 10px;
border-bottom: 1px solid var(--line);
}
nav button {
@@ -127,109 +91,196 @@ nav button {
nav button[aria-current] {
color: var(--accent);
background: var(--tint);
- font-weight: 700;
+ font-weight: 600;
}
-.attention {
- margin-left: auto;
+.pr-status {
+ overflow-wrap: anywhere;
}
-.badge {
- background: var(--tint);
+.pr-info {
+ padding: 6px 0;
+ border-bottom: 1px solid var(--line);
+}
+.posted-review {
+ overflow-wrap: anywhere;
+ border-top: 1px solid var(--line);
+}
+.posted-review a {
color: var(--accent);
- font-size: 11px;
- padding: 3px 6px;
- border-radius: 4px;
- margin-left: 6px;
}
-.toolbar {
- display: flex;
- gap: 12px;
- padding: 20px 30px;
- align-items: center;
+.session-console {
+ font-family: monospace;
}
-.toolbar h2 {
- margin: 0 0 6px;
+.session-console pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
}
-.toolbar input {
+.attention,
+.connection {
margin-left: auto;
- min-width: 230px;
}
-.syncs {
- padding: 0 30px 16px;
- display: flex;
- gap: 15px;
+.connection,
+small,
+.item-meta {
color: var(--muted);
font-size: 12px;
- flex-wrap: wrap;
}
-.split {
- display: grid;
- grid-template-columns: minmax(280px, 40%) 1fr;
- border-top: 1px solid var(--line);
- min-height: 55vh;
+.page {
+ min-width: 0;
+ padding: 10px;
+}
+h1 {
+ font-size: 19px;
+ line-height: 1.35;
+ margin: 10px 0 4px;
+ overflow-wrap: anywhere;
+}
+h2 {
+ font-size: 18px;
+ margin: 8px 0;
+}
+h3 {
+ font-size: 14px;
+ margin: 8px 0;
+}
+p {
+ margin: 8px 0;
+}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+.toolbar input {
+ flex: 1;
}
.item-list {
- border-right: 1px solid var(--line);
- padding: 12px;
+ width: 100%;
}
.item {
- display: block;
- text-align: left;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
width: 100%;
- padding: 16px;
- border-radius: 8px;
- margin-bottom: 8px;
+ padding: 8px;
+ text-align: left;
+ border: 0;
+ border-bottom: 1px solid var(--line);
+ border-radius: 0;
+ background: transparent;
}
-.item h3 {
- line-height: 1.5;
- margin: 9px 0;
+.inbox-row {
+ position: relative;
+ padding: 8px;
+ border-bottom: 1px solid var(--line);
+}
+.inbox-row > .item {
+ flex-direction: row;
+ align-items: baseline;
+ gap: 6px;
+ padding: 0;
+ min-width: 0;
+ border-bottom: 0;
+}
+.inbox-row > .item::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+}
+.inbox-row > .item-meta {
+ margin-top: 3px;
}
-.item.selected {
+.inbox-row:hover {
background: var(--tint);
- border-color: var(--accent);
+}
+.source-link {
+ position: relative;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ max-width: 100%;
+ min-height: 24px;
+ color: var(--accent);
+ font-size: 12px;
+ overflow-wrap: anywhere;
+ text-underline-offset: 2px;
+}
+.item-title {
+ font-weight: 600;
+ line-height: 1.4;
+ overflow-wrap: anywhere;
}
.item-meta {
display: flex;
- justify-content: space-between;
- gap: 12px;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ overflow-wrap: anywhere;
}
.priority {
+ flex-shrink: 0;
+ white-space: nowrap;
font-size: 11px;
+ padding: 2px 5px;
+ border-radius: 3px;
background: var(--tint);
- padding: 3px 7px;
- border-radius: 4px;
- white-space: nowrap;
}
.p0,
.p1 {
background: #f6dfcf;
color: #904921;
}
+.badge {
+ padding: 2px 5px;
+ border-radius: 3px;
+ color: var(--accent);
+ background: var(--tint);
+ font-size: 11px;
+}
.dot {
display: inline-block;
- width: 7px;
- height: 7px;
+ width: 6px;
+ height: 6px;
+ margin-right: 6px;
border-radius: 50%;
background: #458061;
- margin-right: 7px;
}
-.detail {
- padding: 24px;
- min-width: 0;
- background: var(--panel);
-}
-.detail h2 {
- margin: 12px 0 20px;
+.back {
+ padding: 4px 0;
+ border: 0;
+ background: transparent;
+ color: var(--accent);
}
.body {
white-space: pre-wrap;
overflow-wrap: anywhere;
- line-height: 1.6;
+ line-height: 1.5;
}
-.buttons {
+.buttons,
+.item-tools {
display: flex;
flex-wrap: wrap;
- gap: 8px;
- margin: 12px 0;
+ align-items: center;
+ gap: 6px;
+ margin: 8px 0;
+}
+.priority-control {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
}
.primary {
background: #285d45;
@@ -240,41 +291,46 @@ nav button[aria-current] {
background: #387a59;
}
.empty {
- padding: 45px 25px;
- text-align: center;
+ padding: 16px 0;
color: var(--muted);
- line-height: 1.6;
}
.error {
- margin: 12px 24px;
- padding: 12px;
- background: #fbe5dd;
- color: #923e2b;
display: flex;
+ align-items: start;
justify-content: space-between;
- gap: 10px;
+ gap: 8px;
+ padding: 8px;
+ margin: 8px 10px;
+ background: #fbe5dd;
+ color: #923e2b;
overflow-wrap: anywhere;
}
.error button {
padding: 0 6px;
}
.settings {
- padding: 24px;
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
- gap: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
}
-.settings section {
- border: 1px solid var(--line);
- border-radius: 10px;
- background: var(--panel);
- padding: 22px;
+.settings-section {
+ min-width: 0;
+ padding-bottom: 4px;
+ border-bottom: 1px solid var(--line);
+}
+summary {
+ min-height: 30px;
+ padding: 6px 0;
+ cursor: pointer;
+}
+.settings-section > summary {
+ font-weight: 600;
}
label {
display: flex;
flex-direction: column;
- gap: 6px;
- margin: 12px 0;
+ gap: 4px;
+ margin: 8px 0;
}
label.check {
flex-direction: row;
@@ -282,24 +338,31 @@ label.check {
}
label.check small {
display: block;
- margin-top: 4px;
+ margin-top: 2px;
}
form button {
- margin: 6px 4px 6px 0;
+ margin: 4px 4px 4px 0;
}
fieldset {
+ min-width: 0;
+ padding: 8px;
+ margin: 8px 0;
border: 1px solid var(--line);
- border-radius: 7px;
- margin: 16px 0;
+ border-radius: 4px;
+}
+.form-fields {
+ border: 0;
+ padding: 0;
+ margin: 0;
}
pre {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
- max-height: 450px;
+ max-height: 360px;
overflow: auto;
- line-height: 1.6;
+ line-height: 1.5;
}
code {
overflow-wrap: anywhere;
@@ -307,124 +370,124 @@ code {
.overlay {
position: fixed;
inset: 0;
- background: #152a2266;
display: grid;
+ grid-template-columns: minmax(0, 1fr);
place-items: center;
- padding: 20px;
+ padding: 10px;
z-index: 10;
+ background: #152a2266;
}
.modal {
position: relative;
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 26px;
width: 680px;
max-width: 100%;
- max-height: 92vh;
+ max-height: 94vh;
+ padding: 16px;
+ padding-top: 36px;
overflow: auto;
- box-shadow: 0 20px 70px #0003;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 6px;
}
.close {
position: absolute;
- right: 12px;
- top: 12px;
+ top: 6px;
+ right: 6px;
}
.fields {
display: flex;
- gap: 15px;
+ gap: 8px;
}
.fields label {
flex: 1;
}
dt {
color: var(--muted);
- margin-top: 12px;
+ margin-top: 6px;
}
dd {
- margin: 4px 0;
+ margin: 2px 0;
overflow-wrap: anywhere;
}
.notice,
button.notice:hover {
display: block;
- background: #fff0cb;
- color: #785319;
width: 100%;
text-align: left;
- margin: 12px 0;
+ padding: 8px;
+ margin: 8px 0;
+ background: #fff0cb;
+ color: #785319;
}
.status {
font-size: 12px;
- text-transform: uppercase;
- letter-spacing: 1px;
color: var(--accent);
}
article {
+ padding: 6px 0;
border-bottom: 1px solid var(--line);
- padding: 10px 0;
}
.transcript {
- max-height: 55vh;
- overflow: auto;
- border: 1px solid var(--line);
- border-radius: 8px;
- padding: 12px;
+ margin: 8px 0;
+}
+.transcript .body {
+ margin: 4px 0;
+ overflow-wrap: anywhere;
}
.transcript pre {
- margin: 6px 0;
+ margin: 4px 0;
+ max-height: none;
+ overflow: visible;
}
-footer {
- padding: 20px 30px;
- font-size: 12px;
- color: var(--muted);
- border-top: 1px solid var(--line);
+.agent-details pre {
+ max-height: 240px;
+ overflow: auto;
}
-@media (max-width: 700px) {
- header,
- nav,
- .toolbar {
- padding: 16px;
- }
- .toolbar {
- flex-wrap: wrap;
- }
- .toolbar input {
- margin: 0;
- min-width: 0;
- }
- .split {
- grid-template-columns: 1fr;
- }
- .item-list {
- border-right: 0;
- max-height: 40vh;
- overflow: auto;
- }
- .settings {
- grid-template-columns: 1fr;
- padding: 12px;
- }
- .connection {
- max-width: 120px;
- }
+.priority-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+.priority-row label:first-child {
+ flex: 1;
+ min-width: 100px;
+}
+@media (max-width: 480px) {
.fields {
display: block;
}
+ .connection {
+ max-width: 140px;
+ }
}
-.status-icon {
- width: 1em;
- height: 1em;
- vertical-align: -0.15em;
- margin-right: 0.4em;
- flex-shrink: 0;
+.page:focus,
+.detail h1:focus {
+ outline: none;
}
-.recovery-progress button {
- color: var(--accent);
+.plugin-row,
+.repository-row {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 12px;
+ margin: 10px 0;
+ overflow-wrap: anywhere;
+}
+.plugin-list,
+.plugin-row p,
+.repository-row p {
+ min-width: 0;
}
+.reports-page > .toolbar {
+ flex-wrap: wrap;
+}
+.reports-page > .toolbar > * {
+ min-width: 0;
+ max-width: 100%;
+}
.report-layout {
display: grid;
grid-template-columns: minmax(200px, 1fr) minmax(280px, 2fr);
diff --git a/web/src/types.ts b/web/src/types.ts
index 3ae7bc4..039adca 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -23,6 +23,9 @@ export interface Item {
url: string;
}[];
checks?: unknown;
+ pr?: PullRequest;
+ reviewRequests?: string[];
+ reviewTeams?: string[];
}
export interface UserState {
new: boolean;
@@ -43,6 +46,8 @@ export interface Profile {
publishing: string;
}
export interface Skill {
+ disabled?: boolean;
+ description?: string;
id: string;
name: string;
path: string;
@@ -50,6 +55,12 @@ export interface Skill {
managed: boolean;
}
export interface Repository {
+ paused?: boolean;
+ autoReview?: boolean | null;
+ reviewProfileId?: string;
+ implementationProfileId?: string;
+ reviewPrompt?: string;
+ reviewError?: string;
id: string;
checkout: string;
base: string;
@@ -71,28 +82,16 @@ export interface Run {
error: string;
created: string;
}
-export interface QuestionRecovery {
- sourceText?: string;
- question?: string;
- confirmation?: string;
- originalBlocking?: boolean;
- diagnosticError?: string;
- textAttentionId?: string;
- id: string;
- runId: string;
- stage: string;
- attempts: number;
-}
export interface Attention {
- origin?: "rpc" | "async" | "text";
- turnId?: string;
- itemId?: string;
- recoveryId?: string;
id: string;
runId: string;
method: string;
blocking: boolean;
status: string;
+ origin?: "rpc" | "async" | "text";
+ turnId?: string;
+ itemId?: string;
+ recoveryId?: string;
params: {
questions?: Question[];
availableDecisions?: unknown[];
@@ -110,6 +109,16 @@ export interface Question {
options?: { label: string; description: string }[];
}
export interface Entry {
+ hasPullRequest?: boolean;
+ queues?: string[];
+ work?: {
+ tracked: boolean;
+ complete: boolean;
+ status: string;
+ runIds: string[];
+ attentionCount: number;
+ };
+ linkedPullRequests?: PullRequest[];
item: Item;
user: UserState;
unread: boolean;
@@ -123,7 +132,13 @@ export interface Entry {
};
}
export interface Config {
- questionHelperModel?: string;
+ githubRequestsPerHour?: number;
+ githubReserve?: number;
+ githubPollSeconds?: number;
+ autoReview?: boolean;
+ reviewProfileId?: string;
+ implementationProfileId?: string;
+ pluginCheckMinutes?: number;
id: string;
priorityLabels: Record;
assessmentPrompt: string;
@@ -131,9 +146,17 @@ export interface Config {
jiraUrl: string;
jiraEmail: string;
notifications: boolean;
+ questionHelperModel?: string;
+}
+export interface GitHubUsage {
+ notificationError?:string;
+ requestsLastHour:number; budget:number; reserve:number; nextRequest:string;
+ restRemaining?:number;restReset:string;graphqlRemaining?:number;graphqlReset:string;
}
export interface Snapshot {
- questionRecoveries?: QuestionRecovery[];
+ reviewVersions?: Record;
+ githubUsage?:GitHubUsage;
+ viewer?: { login: string; teams: string[] | null; teamError?: string };
cursor: number;
inbox: Entry[];
repositories: Repository[];
@@ -141,8 +164,28 @@ export interface Snapshot {
runs: Run[];
skills: Skill[];
attention: Attention[];
+ questionRecoveries?: QuestionRecovery[];
config: Config;
}
+export interface PublishedReview {
+ id: string;
+ url: string;
+ author: string;
+ state: string;
+ body: string;
+ head: string;
+ submittedAt: string;
+ comments: {id: string; url: string; author: string; body: string; path: string; line?: number}[];
+}
+export interface PRReviews {
+ refresh?: string;
+ retryAt?: string;
+ itemId: string;
+ reviews: PublishedReview[];
+ syncedAt: string;
+ lastAttempt: string;
+ error?: string;
+}
export interface JournalEvent {
seq: number;
kind: string;
@@ -156,8 +199,11 @@ export interface EventPage {
hasMore: boolean;
}
export interface Detail {
+ observation?: {observedAt:string;refresh:string;error?:string;retryAt?:string};
+ refreshError?: string;
item: Item;
user: UserState;
+ linkedPullRequests?: PullRequest[];
relationships: {
id: string;
source: string;
@@ -168,6 +214,33 @@ export interface Detail {
tickets: { id: string; summary: string; status: string; error: string }[];
}
+export interface PullRequest {
+ id: string;
+ title: string;
+ url: string;
+ state: string;
+ draft: boolean;
+ head: string;
+ reviewDecision: string;
+ ci: string;
+ checks: { name: string; status: string; conclusion: string; url: string }[];
+ syncedAt: string;
+ error?: string;
+ implementation?: boolean;
+}
+export interface QuestionRecovery {
+ sourceText?: string;
+ question?: string;
+ confirmation?: string;
+ originalBlocking?: boolean;
+ diagnosticError?: string;
+ textAttentionId?: string;
+ id: string;
+ runId: string;
+ stage: string;
+ attempts: number;
+}
+
export interface Diagnostics {
version: string;
at: string;
diff --git a/web/tests/factory.spec.ts b/web/tests/factory.spec.ts
index 2ba3175..268e10f 100644
--- a/web/tests/factory.spec.ts
+++ b/web/tests/factory.spec.ts
@@ -1,17 +1,19 @@
-import { test, expect, type Page, type Locator } from "@playwright/test";
+import {
+ test,
+ expect,
+ type Page,
+ type Locator,
+ type FrameLocator,
+} from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
import { readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
-import type {
- Snapshot,
- Run,
- Detail,
- IssueReport,
- ReportPage,
-} from "../src/types";
+import type { Snapshot, Run, Detail, JournalEvent, PRReviews, IssueReport, ReportPage } from "../src/types";
+import { fixtureUIOrigin, fixtureDaemonOrigin } from "../fixture-ports";
function initial(): Snapshot {
return {
+ viewer: { login: "alex", teams: [] },
cursor: 1,
inbox: [
{
@@ -49,7 +51,7 @@ function initial(): Snapshot {
repositories: [],
profiles: [
{
- id: "investigate",
+ id: "implement",
name: "Investigation",
prompt: "Investigate {{title}}.\n{{body}}",
skills: [],
@@ -78,12 +80,17 @@ async function host(
page: Page,
options: {
snapshot?: Snapshot;
+ initializeFailures?: number;
+ memoizeReads?: boolean;
+ transportFault?: (name: string, args: Record) => boolean;
+ toolError?: { name: string; message: string };
+ readItem?: (detail: Detail) => Promise;
+ readReviews?: (id: string) => Promise;
reports?: IssueReport[];
listReports?: (
result: ReportPage,
args: Record,
) => Promise;
- readItem?: (detail: Detail) => Promise;
readReport?: (report: IssueReport) => Promise;
addEvidence?: (
report: IssueReport,
@@ -91,37 +98,91 @@ async function host(
) => Promise;
resumeReport?: (report: IssueReport) => Promise;
failPreparation?: boolean;
+ failPriority?: boolean;
+ failSave?: boolean;
+ beforeSave?: () => Promise;
+ beforeMessage?: () => Promise;
+ queueMessages?: boolean;
+ failReadRun?: boolean;
+ loseStartResponse?: boolean;
+ beforeStartResponse?: () => Promise;
+ rejectStart?: boolean;
+ transcript?: JournalEvent[];
theme?: "light" | "dark";
- answerFailure?: "failed" | "uncertain";
- failRefreshAfterAnswer?: boolean;
- transcript?: string;
- recoveryInput?: boolean;
+ denyOpenLink?: boolean;
+ failOpenLink?: boolean;
+ surface?: "settings";
} = {},
) {
const state = options.snapshot ?? initial();
- const reports = options.reports ?? [];
- let answered = false;
- let relationships: Detail["relationships"] = [];
- const calls: { name: string; arguments: Record }[] = [];
- const openLinks: { url: string }[] = [];
+ const openedLinks: string[] = [];
await page.exposeFunction("hostOpenLink", (params: { url: string }) => {
- openLinks.push(params);
- return {};
+ openedLinks.push(params.url);
+ if (options.failOpenLink) throw new Error("Host link unavailable");
+ return options.denyOpenLink ? { isError: true } : {};
});
+ let relationships: Detail["relationships"] = [];
+ const calls: { name: string; arguments: Record }[] = [];
+ const pluginOperations: {
+ id: string;
+ action: string;
+ target: string;
+ status: string;
+ error?: string;
+ }[] = [];
+ const pluginRows = [
+ {
+ id: "review-kit@fixture",
+ name: "review-kit",
+ marketplace: "fixture",
+ installed: true,
+ enabled: true,
+ installedVersion: "1.0.0",
+ availableVersion: "2.0.0",
+ updateStatus: "available",
+ sourceType: "local",
+ authPolicy: "ON_USE",
+ installPolicy: "AVAILABLE",
+ },
+ {
+ id: "extra-kit@fixture",
+ name: "extra-kit",
+ marketplace: "fixture",
+ installed: false,
+ enabled: false,
+ installedVersion: "",
+ availableVersion: "1.0.0",
+ updateStatus: "not-installed",
+ sourceType: "local",
+ authPolicy: "ON_USE",
+ installPolicy: "AVAILABLE",
+ },
+ ];
+ const reports = options.reports ?? [];
await page.exposeFunction(
"hostCall",
async (p: { name: string; arguments: Record }) => {
calls.push(p);
const a = p.arguments;
+ if (options.transportFault?.(p.name, a))
+ throw new Error("McpServerError: Connection failed.");
+ if (options.toolError?.name === p.name)
+ return {
+ isError: true,
+ content: [{ type: "text", text: options.toolError.message }],
+ };
let data: unknown = null;
switch (p.name) {
+ case "open_settings":
+ data = {
+ ...structuredClone(state),
+ inbox: [],
+ runs: [],
+ attention: [],
+ };
+ break;
case "read_snapshot":
case "open_factory":
- if (answered && options.failRefreshAfterAnswer)
- return {
- isError: true,
- content: [{ type: "text", text: "Fixture refresh unavailable" }],
- };
data = structuredClone(state);
break;
case "read_changes":
@@ -195,6 +256,9 @@ async function host(
state.cursor++;
data = null;
break;
+ case "read_pr_reviews":
+ data = options.readReviews ? await options.readReviews(String(a.id)) : {itemId:String(a.id),reviews:[],syncedAt:new Date().toISOString(),lastAttempt:new Date().toISOString()};
+ break;
case "read_item": {
const entry = state.inbox.find((entry) => entry.item.id === a.id)!;
const detail = structuredClone({
@@ -223,6 +287,11 @@ async function host(
at: new Date().toISOString(),
});
} else if (a.action === "priority") {
+ if (options.failPriority)
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Priority save failed" }],
+ };
entry.user.override = a.priority as number | null;
entry.priority = entry.user.override ?? 1;
entry.prioritySource =
@@ -245,9 +314,70 @@ async function host(
});
state.cursor++;
break;
+ case "read_plugin_admin": {
+ const rows = pluginRows.filter(
+ (p) =>
+ (a.available || p.installed) &&
+ p.id.includes(String(a.query ?? "")),
+ );
+ data = {
+ inventory: {
+ checkedAt: new Date().toISOString(),
+ plugins: structuredClone(rows),
+ marketplaces: [{ name: "fixture", sourceType: "local" }],
+ },
+ operations: structuredClone(pluginOperations),
+ total: rows.length,
+ offset: 0,
+ };
+ break;
+ }
+ case "manage_codex_plugins": {
+ const op = {
+ id: `operation-${pluginOperations.length + 1}`,
+ action: String(a.action),
+ target: String(a.target ?? ""),
+ status: "succeeded",
+ };
+ pluginOperations.unshift(op);
+ const row = pluginRows.find((p) => p.id === a.target);
+ if (row && a.action === "update") {
+ row.installedVersion = row.availableVersion;
+ row.updateStatus = "current";
+ }
+ if (row && a.action === "remove") {
+ row.installed = false;
+ row.enabled = false;
+ row.installedVersion = "";
+ row.updateStatus = "not-installed";
+ }
+ if (row && a.action === "install") {
+ row.installed = true;
+ row.enabled = true;
+ row.installedVersion = row.availableVersion;
+ row.updateStatus = "current";
+ }
+ data = op;
+ state.cursor++;
+ break;
+ }
+ case "sync_repositories":
+ state.cursor++;
+ break;
case "save_repository":
+ await options.beforeSave?.();
+ if (options.failSave)
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Repository unavailable" }],
+ };
state.repositories = [
{
+ paused: Boolean(a.paused),
+ autoReview: a.autoReview as boolean | null,
+ reviewProfileId: String(a.reviewProfileId ?? ""),
+ implementationProfileId: String(a.implementationProfileId ?? ""),
+ reviewPrompt: String(a.reviewPrompt ?? ""),
id: String(a.id),
checkout: String(a.checkout),
base: String(a.base),
@@ -264,13 +394,17 @@ async function host(
content: [{ type: "text", text: "Fixture preparation denied" }],
};
const run: Run = {
- id: "run-1",
+ id: `run-${state.runs.length + 1}`,
itemId: String(a.itemId),
- profile: state.profiles[0],
+ profile: {
+ ...state.profiles.find((p) => p.id === a.profileId)!,
+ model: String(a.model),
+ effort: String(a.effort),
+ },
prompt: String(a.prompt),
skills: [],
- worktree: "/local/worktrees/run-1",
- branch: "code-factory/run-1",
+ worktree: `/local/worktrees/run-${state.runs.length + 1}`,
+ branch: `code-factory/run-${state.runs.length + 1}`,
base: "HEAD",
threadId: "",
turnId: "",
@@ -278,19 +412,39 @@ async function host(
error: "",
created: new Date().toISOString(),
};
- state.runs = [run];
+ state.runs.unshift(run);
data = run;
state.cursor++;
break;
}
+ case "read_run":
+ if (options.failReadRun)
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Status unavailable" }],
+ };
+ data = {
+ run: state.runs.find((r) => r.id === a.id),
+ attention: state.attention.filter((r) => r.runId === a.id),
+ };
+ break;
+ case "save_settings":
+ state.config = a as unknown as Snapshot["config"];
+ state.cursor++;
+ break;
case "start_run":
+ if (options.rejectStart)
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Start not accepted" }],
+ };
state.runs[0].status = "waiting";
state.runs[0].threadId = "thread-1";
state.runs[0].turnId = "turn-1";
state.attention = [
{
id: "attention-1",
- runId: "run-1",
+ runId: state.runs[0].id,
method: "item/tool/requestUserInput",
blocking: true,
status: "pending",
@@ -310,111 +464,245 @@ async function host(
},
},
];
- data = state.runs[0];
+ data = structuredClone(state.runs[0]);
state.cursor++;
+ await options.beforeStartResponse?.();
+ if (options.loseStartResponse)
+ return {
+ isError: true,
+ content: [{ type: "text", text: "Start response lost" }],
+ };
break;
- case "read_transcript":
- data = {
- events:
- Number(a.after) === 0
- ? [
- {
- seq: state.cursor,
- kind: "run.output",
- entity: "run-1",
- data: {
- method: "item/agentMessage/delta",
- params: {
- delta:
- options.transcript ?? "Inspecting reconnect logic",
- },
- },
- at: new Date().toISOString(),
- },
- {
- seq: state.cursor + 1,
- kind: "run.output",
- entity: String(a.id),
- at: new Date().toISOString(),
- data: {
- method: "item/completed",
- params: {
- codeFactorySource: "question-recovery",
- item: {
- id: "automatic-input",
- type: "userMessage",
- content: [
- {
- text: options.recoveryInput
- ? "Automatic question-only request"
- : "",
- },
- ],
- },
+ case "read_transcript": {
+ if (options.transcript) {
+ const events = options.transcript
+ .filter((event) => event.seq > Number(a.after))
+ .slice(0, Number(a.limit) || 100);
+ data = {
+ events,
+ cursor: events.at(-1)?.seq ?? Number(a.after),
+ hasMore: options.transcript.some(
+ (event) => event.seq > (events.at(-1)?.seq ?? Number(a.after)),
+ ),
+ };
+ } else
+ data = {
+ events:
+ Number(a.after) === 0
+ ? [
+ {
+ seq: state.cursor,
+ kind: "run.output",
+ entity: "run-1",
+ data: {
+ method: "item/agentMessage/delta",
+ params: { delta: "Inspecting reconnect logic" },
},
+ at: new Date().toISOString(),
},
- },
- ]
- : [],
- cursor: state.cursor,
- hasMore: false,
- };
+ ]
+ : [],
+ cursor: state.cursor,
+ hasMore: false,
+ };
break;
+ }
case "answer_request": {
- if (options.answerFailure === "failed")
- return {
- isError: true,
- content: [{ type: "text", text: "Fixture answer denied" }],
- };
- const request = state.attention.find((at) => at.id === a.id)!;
- const run = state.runs.find((r) => r.id === request.runId)!;
- state.attention = state.attention.filter((at) => at.id !== a.id);
- run.status = "idle";
- run.turnId = "";
+ const request = state.attention.find(
+ (request) => request.id === a.id,
+ )!;
+ state.attention = state.attention.filter(
+ (request) => request.id !== a.id,
+ );
+ const run = state.runs.find((run) => run.id === request.runId)!;
+ run.status = state.attention.some(
+ (request) => request.runId === run.id && request.blocking,
+ )
+ ? "waiting"
+ : "idle";
+ if (run.status === "idle") run.turnId = "";
+ state.cursor++;
+ break;
+ }
+ case "dismiss_question": {
+ state.attention = state.attention.filter(
+ (request) => request.id !== a.id,
+ );
state.cursor++;
- answered = true;
- if (options.answerFailure === "uncertain")
- return {
- isError: true,
- content: [{ type: "text", text: "Answer delivery uncertain" }],
- };
break;
}
- case "dismiss_question":
- state.attention = state.attention.filter((at) => at.id !== a.id);
+ case "save_profile":
+ state.profiles = [
+ ...state.profiles.filter((p) => p.id !== a.id),
+ a as unknown as Snapshot["profiles"][0],
+ ];
+ state.cursor++;
+ break;
+ case "save_skill":
+ state.skills = [
+ {
+ id: "managed-fixture",
+ name: String(a.name),
+ content: String(a.content),
+ path: "/local/skill",
+ managed: true,
+ },
+ ];
state.cursor++;
break;
- case "interrupt_run":
- state.questionRecoveries = [];
- state.runs.find((r) => r.id === a.id)!.status = "interrupted";
- state.runs.find((r) => r.id === a.id)!.turnId = "";
+ case "discover_skills":
state.cursor++;
break;
+ case "diagnostics":
+ data = {
+ version: "fixture",
+ at: "2026-10-21T00:00:00Z",
+ checks: [
+ {
+ id: "fixture-check",
+ state: "verified",
+ detail: "Fixture connection healthy",
+ at: "2026-10-21T00:00:00Z",
+ },
+ ],
+ };
+ break;
case "set_jira_credential":
break;
- case "send_message":
+ case "send_message": {
+ if (options.beforeMessage) await options.beforeMessage();
+ if (options.queueMessages) {
+ state.cursor++;
+ options.transcript?.push({
+ seq: state.cursor,
+ kind: "run.output",
+ entity: String(a.id),
+ at: new Date().toISOString(),
+ data: { method: "codefactory/messageDelivery", params: { itemId: "saved-message", state: "queued" } },
+ });
+ data = { delivery: "accepted", messageId: "saved-message", messageDelivery: { state: "queued" } };
+ break;
+ }
+ const run = state.runs.find((r) => r.id === a.id);
+ if (run && ["interrupted", "failed"].includes(run.status)) {
+ run.status = "running";
+ run.turnId = "restored-turn";
+ }
state.cursor++;
break;
+ }
default:
data = {};
}
return { content: [], structuredContent: { data } };
},
);
+ await page.route("**/settings-ui", async (route) => {
+ const response = await route.fetch({ url: `${fixtureUIOrigin}/` });
+ await route.fulfill({
+ response,
+ body: (await response.text()).replace(
+ "
route.fulfill({
contentType: "text/html",
- body: ``,
+ body: ``,
}),
);
await page.goto("/host");
- const ui = page.frameLocator("iframe");
+ const ui = page.frameLocator('iframe[title="Code Factory app"]');
+ await expect(
+ ui.getByLabel(
+ options.surface === "settings" ? "GitHub repository" : "Search inbox",
+ ),
+ ).toBeVisible();
+ return { ui, state, calls, openedLinks, openLinks: openedLinks };
+}
+async function showSettings(page: Page) {
+ await page.locator("iframe").evaluate((frame: HTMLIFrameElement) => {
+ frame.src = "/settings-ui";
+ });
await expect(
- ui.getByRole("heading", { name: "Repository inbox" }),
+ page
+ .frameLocator("iframe")
+ .getByRole("heading", { name: "Settings", exact: true }),
).toBeVisible();
- return { ui, state, calls, openLinks };
+}
+async function openItem(ui: FrameLocator, name: RegExp) {
+ if (!(await ui.locator(".item-list").count()))
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await ui.getByRole("button", { name }).click();
+ await showItemDetails(ui);
+}
+async function showItemDetails(ui: FrameLocator) {
+ const summary = ui.locator(".item-details > summary");
+ await expect(summary).toBeVisible();
+ if (await summary.count()) {
+ if (
+ !(await summary
+ .locator("..")
+ .evaluate((e) => (e as HTMLDetailsElement).open))
+ )
+ await summary.click();
+ }
+}
+async function showAgents(ui: FrameLocator) {
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await ui.getByLabel("Inbox filter").selectOption("all");
+ const retainedRows = ui.locator(".inbox-row").filter({
+ has: ui
+ .getByRole("status", { name: "Work status" })
+ .filter({ hasNotText: "Not started" }),
+ });
+ await (
+ (await retainedRows.count())
+ ? retainedRows.first().getByRole("button")
+ : ui.locator(".item-list button.item").first()
+ ).click();
+ await showItemDetails(ui);
+ const summary = ui.getByText("Work history", { exact: true });
+ if (
+ !(await summary
+ .locator("..")
+ .evaluate((e) => (e as HTMLDetailsElement).open))
+ )
+ await summary.click();
+}
+async function openWaiting(ui: FrameLocator) {
+ await ui.getByRole("button", { name: /^Messages/ }).click();
+ await ui
+ .getByRole("region", { name: "Waiting for you" })
+ .getByRole("button")
+ .first()
+ .click();
+}
+async function linkTarget(ui: FrameLocator) {
+ await showItemDetails(ui);
+ const input = ui.getByLabel("Link target");
+ if (!(await input.isVisible()))
+ await ui.getByText("Relationships", { exact: true }).click();
+ return input;
+}
+async function triage(ui: FrameLocator, name: string) {
+ await showItemDetails(ui);
+ const button = ui.getByRole("button", { name, exact: true });
+ if (!(await button.isVisible()))
+ await ui.getByText("Actions", { exact: true }).click();
+ await button.click();
}
async function tabTo(page: Page, target: Locator) {
+ await target.waitFor({ state: "attached" });
+ if (!(await target.isVisible())) {
+ const details = target.locator(
+ "xpath=ancestor::details[contains(@class, 'item-details')]",
+ );
+ if (await details.count())
+ await details.locator(":scope > summary").click();
+ }
for (let attempt = 0; attempt < 30; attempt++) {
if (
await target.evaluate(
@@ -498,25 +786,25 @@ for (const theme of ["light", "dark"] as const) {
);
}
await scanOwnedApp(page, `${theme}-inbox`);
- await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
- await ui.getByLabel("Link target").fill("owner/repo#42");
+ await openItem(ui, /Fix reconnect regression/);
+ await (await linkTarget(ui)).fill("owner/repo#42");
await ui
.getByRole("button", { name: "Add local link", exact: true })
.click();
await expect(
ui.locator(".detail p").filter({ hasText: "owner/repo#42" }),
).toBeVisible();
- await ui
- .getByRole("button", { name: "Record action", exact: true })
- .click();
+ await triage(ui, "Record action");
+ await ui.getByText("Your action history", { exact: true }).click();
await expect(
ui.locator(".detail p").filter({ hasText: /^plugin ·/ }),
).toBeVisible();
await scanOwnedApp(page, `${theme}-detail`);
- await ui.getByRole("button", { name: "Launch agent", exact: true }).click();
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
await expect(
ui.getByRole("dialog", { name: "Launch agent" }),
).toBeVisible();
+ await ui.getByRole("button", { name: "Edit", exact: true }).click();
await scanOwnedApp(page, `${theme}-launch`);
await ui
.getByRole("button", { name: "Review launch", exact: true })
@@ -525,7 +813,10 @@ for (const theme of ["light", "dark"] as const) {
ui.getByRole("heading", { name: "Review agent launch" }),
).toBeVisible();
await scanOwnedApp(page, `${theme}-review`);
- await ui.getByRole("button", { name: "Start agent", exact: true }).click();
+ await ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
await expect(ui.getByText("Inspecting reconnect logic")).toBeVisible();
await scanOwnedApp(page, `${theme}-agents`);
await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
@@ -535,10 +826,17 @@ for (const theme of ["light", "dark"] as const) {
).toBeEnabled();
await scanOwnedApp(page, `${theme}-question`);
await page.keyboard.press("Escape");
- await ui.getByRole("button", { name: "Settings", exact: true }).click();
+ await showSettings(page);
await expect(
- ui.getByRole("heading", { name: "Connection diagnostics" }),
+ ui.getByText("Connection diagnostics", { exact: true }),
).toBeVisible();
+ for (const section of [
+ "Priorities & notifications",
+ "Launch profiles",
+ "Managed skills",
+ "Connection diagnostics",
+ ])
+ await ui.getByText(section, { exact: true }).click();
await scanOwnedApp(page, `${theme}-settings`);
});
}
@@ -551,7 +849,8 @@ test("keyboard launch review traps focus, preserves edits and restores its opene
ui.getByRole("button", { name: /Fix reconnect regression/ }),
);
await page.keyboard.press("Enter");
- const opener = ui.getByRole("button", { name: "Launch agent", exact: true });
+ await showItemDetails(ui);
+ const opener = ui.getByRole("button", { name: "New run", exact: true });
await tabTo(page, opener);
await page.keyboard.press("Enter");
const dialog = ui.getByRole("dialog", { name: "Launch agent" });
@@ -559,12 +858,17 @@ test("keyboard launch review traps focus, preserves edits and restores its opene
await expect(close).toBeFocused();
await expect(close).toHaveCSS("outline-style", "solid");
await expect(close).toHaveCSS("outline-width", "3px");
+ await expect(
+ dialog.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
await page.keyboard.press("Shift+Tab");
await expect(
- dialog.getByRole("button", { name: "Review launch", exact: true }),
+ dialog.getByRole("button", { name: "Start agent", exact: true }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(close).toBeFocused();
+ await tabTo(page, dialog.getByRole("button", { name: "Edit", exact: true }));
+ await page.keyboard.press("Enter");
const prompt = dialog.getByLabel("Task prompt");
await tabTo(page, prompt);
await page.keyboard.press("ControlOrMeta+A");
@@ -600,7 +904,7 @@ test("keyboard launch review traps focus, preserves edits and restores its opene
await expect(dialog).toHaveCount(0);
await expect(opener).toBeFocused();
expect(calls.filter((call) => call.name === "prepare_launch")).toHaveLength(
- 1,
+ 2,
);
expect(calls.filter((call) => call.name === "start_run")).toHaveLength(0);
await expect(
@@ -620,19 +924,15 @@ test("keyboard attention stays contained through updates and Escape restores the
ui.getByRole("button", { name: /Fix reconnect regression/ }),
);
await page.keyboard.press("Enter");
- await tabTo(
- page,
- ui.getByRole("button", { name: "Launch agent", exact: true }),
- );
- await page.keyboard.press("Enter");
- await tabTo(
- page,
- ui.getByRole("button", { name: "Review launch", exact: true }),
- );
+ await showItemDetails(ui);
+ await tabTo(page, ui.getByRole("button", { name: "New run", exact: true }));
await page.keyboard.press("Enter");
+
await tabTo(
page,
- ui.getByRole("button", { name: "Start agent", exact: true }),
+ ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true }),
);
await page.keyboard.press("Enter");
const opener = ui.getByRole("button", { name: /Waiting for your answer/ });
@@ -677,10 +977,10 @@ test("keyboard attention stays contained through updates and Escape restores the
expect(calls.filter((call) => call.name === "answer_request")).toHaveLength(
1,
);
- await expect(ui.getByRole("status", { name: "Run status" })).toHaveText(
- "idle",
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Inactive",
);
- await expect(ui.getByRole("status", { name: "Run status" })).toHaveAttribute(
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveAttribute(
"aria-live",
"polite",
);
@@ -689,16 +989,25 @@ test("keyboard attention stays contained through updates and Escape restores the
test("launch preparation failure is announced inside the dialog without losing keyboard edits", async ({
page,
}) => {
- const { ui, calls } = await host(page, { failPreparation: true });
+ const options = { failPreparation: true };
+ const { ui, calls } = await host(page, options);
await tabTo(
page,
ui.getByRole("button", { name: /Fix reconnect regression/ }),
);
await page.keyboard.press("Enter");
- const opener = ui.getByRole("button", { name: "Launch agent", exact: true });
+ await showItemDetails(ui);
+ const opener = ui.getByRole("button", { name: "New run", exact: true });
await tabTo(page, opener);
await page.keyboard.press("Enter");
const dialog = ui.getByRole("dialog", { name: "Launch agent" });
+ await expect(dialog.getByRole("alert")).toContainText(
+ "Fixture preparation denied",
+ );
+ options.failPreparation = false;
+ await dialog.getByRole("button", { name: "Retry preparation" }).click();
+ await dialog.getByRole("button", { name: "Edit", exact: true }).click();
+ options.failPreparation = true;
await tabTo(page, dialog.getByLabel("Task prompt"));
await page.keyboard.press("ControlOrMeta+A");
await page.keyboard.type("Retain this failed preparation draft.");
@@ -724,41 +1033,39 @@ test("launch preparation failure is announced inside the dialog without losing k
test("item actions refresh visible history while unread, New and drafts remain independent", async ({
page,
}) => {
- const { ui, calls } = await host(page);
+ const { ui, state, calls } = await host(page);
const row = ui.getByRole("button", { name: /Fix reconnect regression/ });
await row.click();
- await expect(
- ui.getByRole("heading", { name: "Your action history" }),
- ).toBeVisible();
- await expect(row.locator(".dot")).toHaveCount(0);
- await expect(row.getByText("New", { exact: true })).toBeVisible();
- await ui.getByLabel("Link target").fill("owner/repo#draft");
+ await expect(ui.locator(".item-list")).toHaveCount(0);
+ await expect.poll(() => state.inbox[0].unread).toBe(false);
+ expect(state.inbox[0].user.new).toBe(true);
+ await (await linkTarget(ui)).fill("owner/repo#draft");
await ui.getByLabel("Priority override").selectOption("2");
- await ui.getByRole("button", { name: "Record action", exact: true }).click();
- await expect(
- ui.locator(".detail p").filter({ hasText: /^plugin ·/ }),
- ).toHaveCount(1);
- await expect(row).toContainText("Acted on");
- await expect(row.getByText("New", { exact: true })).toBeVisible();
- await ui.getByRole("button", { name: "Mark unread", exact: true }).click();
- await expect(row.locator(".dot")).toHaveCount(1);
- await ui
- .getByRole("button", { name: "Acknowledge new", exact: true })
- .click();
- await expect(row.getByText("New", { exact: true })).toHaveCount(0);
- await expect(row.locator(".dot")).toHaveCount(1);
+ await triage(ui, "Record action");
await expect(
ui.locator(".detail p").filter({ hasText: /^plugin ·/ }),
).toHaveCount(1);
+ expect(state.inbox[0].user.new).toBe(true);
+ await triage(ui, "Mark unread");
+ await expect.poll(() => state.inbox[0].unread).toBe(true);
+ await triage(ui, "Acknowledge new");
+ await expect.poll(() => state.inbox[0].user.new).toBe(false);
+ expect(state.inbox[0].unread).toBe(true);
await expect(ui.getByLabel("Link target")).toHaveValue("owner/repo#draft");
await expect(ui.getByLabel("Priority override")).toHaveValue("2");
expect(
calls.filter(
- (call) => call.name === "item_action" && call.arguments.action === "read",
+ (c) => c.name === "item_action" && c.arguments.action === "read",
),
).toHaveLength(1);
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await expect(row.locator(".dot")).toHaveCount(1);
+ await expect(row.locator("..").getByText("New", { exact: true })).toHaveCount(
+ 0,
+ );
+ await expect(row.locator("..")).toContainText("Acted on");
expect(
- calls.filter((call) => ["prepare_launch", "start_run"].includes(call.name)),
+ calls.filter((c) => ["prepare_launch", "start_run"].includes(c.name)),
).toHaveLength(0);
});
@@ -766,17 +1073,17 @@ test("unlink removes the visible relationship without clearing unrelated detail
page,
}) => {
const { ui, calls } = await host(page);
- await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
- await ui.getByLabel("Link target").fill("owner/repo#42");
+ await openItem(ui, /Fix reconnect regression/);
+ await (await linkTarget(ui)).fill("owner/repo#42");
await ui.getByRole("button", { name: "Add local link", exact: true }).click();
const link = ui.locator(".detail p").filter({ hasText: "owner/repo#42" });
await expect(link).toContainText("Local link");
- await ui.getByLabel("Link target").fill("owner/repo#next-draft");
+ await (await linkTarget(ui)).fill("owner/repo#next-draft");
await ui.getByLabel("Priority override").selectOption("3");
await link.getByRole("button", { name: "Remove", exact: true }).click();
await expect(link).toHaveCount(0);
await expect(
- ui.getByRole("heading", { name: "Fix reconnect regression", level: 2 }),
+ ui.getByRole("heading", { name: "Fix reconnect regression", level: 1 }),
).toBeVisible();
await expect(ui.getByLabel("Link target")).toHaveValue(
"owner/repo#next-draft",
@@ -815,17 +1122,17 @@ test("a delayed relationship refresh cannot replace a later selection or its dra
},
});
try {
- await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
- await ui.getByLabel("Link target").fill("owner/repo#42");
+ await openItem(ui, /Fix reconnect regression/);
+ await (await linkTarget(ui)).fill("owner/repo#42");
await ui
.getByRole("button", { name: "Add local link", exact: true })
.click();
await expect.poll(() => delayed).toBe(true);
- await ui.getByRole("button", { name: /Second retained item/ }).click();
+ await openItem(ui, /Second retained item/);
await expect(
- ui.getByRole("heading", { name: "Second retained item", level: 2 }),
+ ui.getByRole("heading", { name: "Second retained item", level: 1 }),
).toBeVisible();
- await ui.getByLabel("Link target").fill("owner/repo#second-draft");
+ await (await linkTarget(ui)).fill("owner/repo#second-draft");
await ui.getByLabel("Priority override").selectOption("2");
release();
await expect.poll(() => completed).toBe(true);
@@ -838,7 +1145,7 @@ test("a delayed relationship refresh cannot replace a later selection or its dra
),
);
await expect(
- ui.getByRole("heading", { name: "Second retained item", level: 2 }),
+ ui.getByRole("heading", { name: "Second retained item", level: 1 }),
).toBeVisible();
await expect(ui.getByLabel("Link target")).toHaveValue(
"owner/repo#second-draft",
@@ -852,9 +1159,9 @@ test("triage does not launch, launch edits and attention answer survive reopenin
page,
}) => {
const { ui, calls } = await host(page);
- await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await openItem(ui, /Fix reconnect regression/);
await expect(
- ui.getByRole("heading", { name: "Fix reconnect regression", level: 2 }),
+ ui.getByRole("heading", { name: "Fix reconnect regression", level: 1 }),
).toBeVisible();
await expect
.poll(() =>
@@ -864,23 +1171,32 @@ test("triage does not launch, launch edits and attention answer survive reopenin
)
.toBe(true);
expect(calls.some((c) => c.name === "start_run")).toBe(false);
- await ui.getByRole("button", { name: "Launch agent", exact: true }).click();
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
+ await ui.getByRole("button", { name: "Edit", exact: true }).click();
await ui
.getByLabel("Task prompt")
.fill("Investigate the reconnect race with a focused test.");
+ await ui.getByText("Advanced", { exact: true }).click();
await ui.getByLabel("Model", { exact: true }).fill("local-model");
await ui.getByRole("button", { name: "Review launch" }).click();
await expect(
ui.getByRole("heading", { name: "Review agent launch" }),
).toBeVisible();
expect(calls.some((c) => c.name === "start_run")).toBe(false);
- expect(calls.find((c) => c.name === "prepare_launch")?.arguments.prompt).toBe(
- "Investigate the reconnect race with a focused test.",
- );
- expect(calls.find((c) => c.name === "prepare_launch")?.arguments.model).toBe(
- "local-model",
- );
- await ui.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect
+ .poll(
+ () =>
+ calls.filter((c) => c.name === "prepare_launch").at(-1)?.arguments
+ .prompt,
+ )
+ .toBe("Investigate the reconnect race with a focused test.");
+ expect(
+ calls.filter((c) => c.name === "prepare_launch").at(-1)?.arguments.model,
+ ).toBe("local-model");
+ await ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
await expect(ui.getByText("Inspecting reconnect logic")).toBeVisible();
await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
await expect(
@@ -902,22 +1218,21 @@ test("triage does not launch, launch edits and attention answer survive reopenin
(c) => c.name === "read_snapshot",
).length;
await page.reload();
- await expect(
- ui.getByRole("heading", { name: "Repository inbox" }),
- ).toBeVisible();
+ await expect(ui.getByLabel("Search inbox")).toBeVisible();
expect(
calls.filter((c) => c.name === "read_snapshot").length,
).toBeGreaterThan(snapshotsBeforeReopen);
await expect(
- ui.getByRole("button", { name: "Needs attention 1", exact: true }),
+ ui.getByRole("button", { name: /^Messages\s*1$/ }),
).toBeVisible();
- await ui.getByRole("button", { name: /^Agents/ }).click();
- await expect(
- ui.getByRole("button", { name: /Investigation · waiting/ }),
- ).toHaveCount(1);
- await ui.getByRole("button", { name: /Investigation · waiting/ }).click();
+ await showAgents(ui);
+ await expect(ui.getByRole("button", { name: /Investigation ·/ })).toHaveCount(
+ 1,
+ );
+ await ui.getByRole("button", { name: /Investigation ·/ }).click();
+ await ui.getByText("Details", { exact: true }).click();
await expect(
- ui.getByText("/local/worktrees/run-1", { exact: true }),
+ ui.getByText("/local/worktrees/run-2", { exact: true }),
).toBeVisible();
await expect(ui.getByText("Inspecting reconnect logic")).toBeVisible();
await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
@@ -928,7 +1243,7 @@ test("triage does not launch, launch edits and attention answer survive reopenin
await expect(
question.getByRole("radio", { name: "Broad All connection issues" }),
).toBeVisible();
- expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(2);
expect(calls.filter((c) => c.name === "start_run")).toHaveLength(1);
expect(calls.filter((c) => c.name === "answer_request")).toHaveLength(0);
await ui.getByRole("radio", { name: /Focused/ }).check();
@@ -944,27 +1259,27 @@ test("triage does not launch, launch edits and attention answer survive reopenin
answer: { answers: { scope: { answers: ["Focused"] } } },
},
]);
- await ui.getByRole("button", { name: "Inbox", exact: true }).click();
- await ui.getByRole("button", { name: /^Agents/ }).click();
- await ui.getByRole("button", { name: /Investigation · idle/ }).click();
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await showAgents(ui);
+ await ui.getByRole("button", { name: /Investigation ·/ }).click();
await ui.getByLabel("Message agent").fill("Explain the test result.");
await ui.getByRole("button", { name: "Send message" }).click();
await expect
.poll(() => calls.filter((c) => c.name === "send_message").length)
.toBe(1);
expect(calls.find((c) => c.name === "send_message")?.arguments).toEqual({
- id: "run-1",
+ id: "run-2",
text: "Explain the test result.",
expectedTurn: "",
});
expect(calls.filter((c) => c.name === "start_run").length).toBe(1);
- expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(2);
});
-test("plugin settings accept repository and secure credential without echo", async ({
+test("settings save repository and priority rows without unused integrations", async ({
page,
}) => {
- const { ui, calls } = await host(page);
- await ui.getByRole("button", { name: "Settings", exact: true }).click();
+ const { ui, calls, state } = await host(page, { surface: "settings" });
+ await expect(ui.getByLabel("Assessment prompt")).toBeHidden();
await ui.getByLabel("GitHub repository").fill("owner/repo");
await ui
.getByLabel("Existing checkout (optional)")
@@ -973,337 +1288,267 @@ test("plugin settings accept repository and secure credential without echo", asy
.getByRole("button", { name: "Save repository", exact: true })
.click();
await expect(
- ui.getByText("owner/repo · /home/user/project · Connected"),
+ ui.getByRole("heading", { name: "Settings", exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByRole("button", { name: "Issues", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ ui.getByRole("button", { name: "Edit repository owner/repo", exact: true }),
).toBeVisible();
- const helper = ui.getByLabel("Question helper model (optional)");
- await expect(helper).toHaveValue("");
- await helper.fill("fixture-helper-model");
+ await expect(ui.getByText("Integrations", { exact: true })).toHaveCount(0);
+ await expect(ui.getByLabel("Jira API token")).toHaveCount(0);
+ expect(calls.filter((c) => c.name === "set_jira_credential")).toHaveLength(0);
+ await ui.getByText("Priorities & notifications", { exact: true }).click();
+ await ui.getByLabel("Priority label 1", { exact: true }).fill("urgent");
+ await ui.getByLabel("Mapped priority 1", { exact: true }).selectOption("0");
+ await ui
+ .getByLabel("Question helper model (optional)")
+ .fill("fixture-small-model");
await ui.getByRole("button", { name: "Save settings", exact: true }).click();
- await expect
- .poll(
- () =>
- calls.find((c) => c.name === "save_settings")?.arguments
- .questionHelperModel,
- )
- .toBe("fixture-helper-model");
- const input = ui.getByLabel("Jira API token");
- await expect(input).toHaveAttribute("type", "password");
- await input.fill("secure-test-token");
- await ui.getByRole("button", { name: "Save credential securely" }).click();
- await expect(input).toHaveValue("");
+ const form = ui
+ .getByRole("button", { name: "Save settings", exact: true })
+ .locator("xpath=ancestor::form");
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+ expect(
+ calls.find((c) => c.name === "save_settings")?.arguments.priorityLabels,
+ ).toEqual({ urgent: 0, P1: 1, P2: 2, P3: 3 });
expect(
- calls.find((c) => c.name === "set_jira_credential")?.arguments.token,
- ).toBe("secure-test-token");
- await expect(ui.locator("body")).not.toContainText("secure-test-token");
+ calls.find((c) => c.name === "save_settings")?.arguments
+ .questionHelperModel,
+ ).toBe("fixture-small-model");
+ await ui.getByText("System defaults", { exact: true }).click();
+ await ui.getByLabel("Request budget per hour", { exact: true }).fill("120");
+ await ui
+ .getByRole("button", { name: "Save system defaults", exact: true })
+ .click();
+ const systemForm = ui
+ .getByRole("button", { name: "Save system defaults", exact: true })
+ .locator("xpath=ancestor::form");
+ await expect(systemForm.getByRole("status")).toHaveText("Saved.");
+ const settingsCalls = calls.filter((c) => c.name === "save_settings");
+ expect(settingsCalls).toHaveLength(2);
+ expect(settingsCalls[1].arguments).toMatchObject({
+ priorityLabels: { urgent: 0, P1: 1, P2: 2, P3: 3 },
+ assessmentPrompt: "Assess urgency.",
+ questionHelperModel: "fixture-small-model",
+ githubRequestsPerHour: 120,
+ });
+ expect(state.config.priorityLabels).toEqual({ urgent: 0, P1: 1, P2: 2, P3: 3 });
+ expect(state.config.githubRequestsPerHour).toBe(120);
});
-type ToolCall = { name: string; arguments: Record };
-type ToolResult = {
- isError?: boolean;
- content?: { type: string; text?: string }[];
- structuredContent?: { data: unknown };
-};
-async function assembledHost(
- page: Page,
- options: {
- loseFirstStartResponse?: boolean;
- beforeFirstStartResponse?: () => Promise;
- } = {},
-) {
- const calls: ToolCall[] = [];
- const acceptedStarts: Run[] = [];
- const daemonOrigin = new URL(
- `http://127.0.0.1:${process.env.CODE_FACTORY_TEST_HOST_PORT ?? "4174"}`,
- ).origin;
- async function request(p: ToolCall): Promise {
- calls.push(p);
- const response = await fetch(`${daemonOrigin}/mcp`, {
- method: "POST",
- headers: {
- Authorization:
- "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "Content-Type": "application/json",
- Accept: "application/json, text/event-stream",
- "MCP-Protocol-Version": "2026-07-28",
- "Mcp-Method": "tools/call",
- "Mcp-Name": p.name,
- },
- body: JSON.stringify({
- jsonrpc: "2.0",
- id: 1,
- method: "tools/call",
- params: {
- ...p,
- _meta: {
- "io.modelcontextprotocol/protocolVersion": "2026-07-28",
- "io.modelcontextprotocol/clientInfo": {
- name: "browser-host",
- version: "1",
- },
- "io.modelcontextprotocol/clientCapabilities": {},
- },
- },
- }),
- });
- const result = (await response.json()) as {
- result?: ToolResult;
- error?: unknown;
- };
- if (!response.ok || result.error || !result.result)
- throw new Error("Daemon MCP request failed");
- return result.result;
- }
- await page.exposeFunction("daemonCall", async (p: ToolCall) => {
- const result = await request(p);
- if (p.name === "start_run" && !p.arguments.resume && !result.isError) {
- acceptedStarts.push(result.structuredContent!.data as Run);
- if (acceptedStarts.length === 1) {
- await options.beforeFirstStartResponse?.();
- if (options.loseFirstStartResponse)
- throw new Error(
- "Start response lost after the fixture daemon accepted it.",
- );
- }
- }
- return result;
- });
- await page.route("**/assembled-host", (route) =>
- route.fulfill({
- contentType: "text/html",
- body: ``,
- }),
- );
- const forbidden: string[] = [];
- page.on("request", (r) => {
- if (
- r.frame().parentFrame() &&
- (new URL(r.url()).origin === daemonOrigin || /localhost/.test(r.url()))
- )
- forbidden.push(r.url());
- });
- await page.goto("/assembled-host");
- const ui = page.frameLocator("iframe");
- await expect(
- ui.getByRole("heading", { name: "Repository inbox" }),
- ).toBeVisible();
- async function daemon(
- name: string,
- args: Record = {},
- ): Promise {
- const result = await request({ name, arguments: args });
- if (result.isError)
- throw new Error(
- result.content?.map((item) => item.text ?? "").join("\n") ||
- "Fixture tool failed",
- );
- return result.structuredContent!.data as T;
- }
- return { ui, calls, acceptedStarts, forbidden, daemon };
+function retained(
+ snapshot: Snapshot,
+ id: string,
+ status = "idle",
+ created = "2026-10-21T00:00:00Z",
+): Run {
+ return {
+ id,
+ itemId: snapshot.inbox[0].item.id,
+ profile: structuredClone(snapshot.profiles[0]),
+ prompt: "Retained task",
+ skills: [],
+ worktree: `/local/${id}`,
+ branch: `factory/${id}`,
+ base: "HEAD",
+ threadId: `thread-${id}`,
+ turnId: "",
+ status,
+ error: "",
+ created,
+ };
}
-test("repeated Start after a lost first response reports rejection and retains exactly one daemon run", async ({
+test("item switching restores saved priorities and keeps relationship drafts by item", async ({
page,
}) => {
- let release!: () => void;
- const gate = new Promise((resolve) => {
- release = resolve;
- });
- const { ui, calls, acceptedStarts, forbidden, daemon } = await assembledHost(
- page,
- {
- loseFirstStartResponse: true,
- beforeFirstStartResponse: () => gate,
- },
+ const snapshot = initial();
+ snapshot.inbox[0].user.override = 3;
+ const second = structuredClone(snapshot.inbox[0]);
+ second.item.id = "owner/repo#2";
+ second.item.title = "Second item";
+ second.user.override = 0;
+ snapshot.inbox.push(second);
+ const { ui, calls } = await host(page, { snapshot });
+ await openItem(ui, /Fix reconnect regression/);
+ await expect(ui.getByLabel("Priority override")).toHaveValue("3");
+ await (await linkTarget(ui)).fill("owner/repo#draft-one");
+ await openItem(ui, /Second item/);
+ await expect(ui.getByLabel("Priority override")).toHaveValue("0");
+ await expect(ui.getByLabel("Link target")).toHaveValue("");
+ await (await linkTarget(ui)).fill("owner/repo#draft-two");
+ await ui.getByLabel("Priority override").selectOption("2");
+ await expect(ui.getByLabel("Priority override")).toBeEnabled();
+ await openItem(ui, /Fix reconnect regression/);
+ await expect(ui.getByLabel("Priority override")).toHaveValue("3");
+ await expect(ui.getByLabel("Link target")).toHaveValue(
+ "owner/repo#draft-one",
);
- const before = await daemon("list_runs");
- try {
- await ui.getByRole("button", { name: /Assembled daemon fixture/ }).click();
- await ui.getByRole("button", { name: "Launch agent", exact: true }).click();
- await ui
- .getByRole("button", { name: "Review launch", exact: true })
- .click();
- const dialog = ui.getByRole("dialog", { name: "Launch agent" });
- const start = ui.getByRole("button", { name: "Start agent", exact: true });
- await start.dblclick();
- await expect(start).toBeDisabled();
- await expect
- .poll(() => calls.filter((call) => call.name === "start_run").length)
- .toBe(1);
- release();
- await expect(dialog.getByRole("alert")).toContainText(
- "Start response lost",
- );
- await expect(
- ui.getByRole("heading", { name: "Review agent launch" }),
- ).toBeVisible();
- await tabTo(page, start);
- await page.keyboard.press("Enter");
- await expect(dialog.getByRole("alert")).toContainText(
- /run cannot start from/,
- );
- expect(acceptedStarts).toHaveLength(1);
- const created = (await daemon("list_runs")).filter(
- (run) => !before.some((old) => old.id === run.id),
- );
- expect(created).toHaveLength(1);
- expect(created[0].id).toBe(acceptedStarts[0].id);
- expect(
- calls
- .filter((call) => call.name === "start_run")
- .map((call) => call.arguments),
- ).toEqual([{ id: created[0].id }, { id: created[0].id }]);
- await page.keyboard.press("Escape");
- await expect(
- ui.getByRole("button", { name: "Launch agent", exact: true }),
- ).toBeFocused();
- await tabTo(page, ui.getByRole("button", { name: "Agents", exact: true }));
- await page.keyboard.press("Enter");
- await tabTo(page, ui.getByRole("button", { name: /· waiting/ }));
- await page.keyboard.press("Enter");
- await expect(
- ui.getByText(created[0].worktree, { exact: true }),
- ).toBeVisible();
- await expect(
- ui.getByRole("button", { name: /Waiting for your answer/ }),
- ).toBeVisible();
- expect(calls.filter((call) => call.name === "prepare_launch")).toHaveLength(
- 1,
- );
- expect(forbidden).toEqual([]);
- } finally {
- release();
+ await showAgents(ui);
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await openItem(ui, /Fix reconnect regression/);
+ await expect(ui.getByLabel("Link target")).toHaveValue(
+ "owner/repo#draft-one",
+ );
+ await openItem(ui, /Second item/);
+ await expect(ui.getByLabel("Priority override")).toHaveValue("2");
+ await expect(ui.getByLabel("Link target")).toHaveValue(
+ "owner/repo#draft-two",
+ );
+ for (const priority of ["0", "1", "2", "3", ""]) {
+ await ui.getByLabel("Priority override").selectOption(priority);
+ await expect(ui.getByLabel("Priority override")).toBeEnabled();
}
+ expect(
+ calls
+ .filter(
+ (c) => c.name === "item_action" && c.arguments.action === "priority",
+ )
+ .map((c) => c.arguments),
+ ).toEqual(
+ [2, 0, 1, 2, 3, null].map((priority) => ({
+ id: second.item.id,
+ action: "priority",
+ priority,
+ })),
+ );
});
-test("dirty cleanup retains the daemon workspace and history until explicit Resume", async ({
+test("failed immediate priority saves restore the saved value with an inline error", async ({
page,
}) => {
- const { ui, calls, acceptedStarts, forbidden, daemon } =
- await assembledHost(page);
- await ui.getByRole("button", { name: /Assembled daemon fixture/ }).click();
- await ui.getByRole("button", { name: "Launch agent", exact: true }).click();
- await ui.getByRole("button", { name: "Review launch", exact: true }).click();
- await ui.getByRole("button", { name: "Start agent", exact: true }).click();
- await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
- await ui.getByRole("radio", { name: /Focused/ }).check();
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- await expect(ui.locator(".detail .status")).toHaveText("idle");
- const run = (await daemon("list_runs")).find(
- (entry) => entry.id === acceptedStarts[0].id,
- )!;
- expect(relative(tmpdir(), run.worktree)).toMatch(
- /^factory-browser-[^/]+\/worktrees\/[a-f0-9]+$/,
+ const snapshot = initial();
+ snapshot.inbox[0].user.override = 1;
+ const { ui } = await host(page, { snapshot, failPriority: true });
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByLabel("Priority override").selectOption("0");
+ await expect(ui.getByLabel("Priority override")).toHaveValue("1");
+ await expect(ui.locator(".detail").getByRole("alert")).toContainText(
+ "Priority save failed",
);
- const sentinel = join(run.worktree, "browser-dirty-fixture.txt");
- await writeFile(sentinel, "retain this browser fixture change\n");
- await ui.getByLabel("Message agent").fill("Retain this follow-up draft.");
- await ui
- .getByRole("button", { name: "Clean up worktree", exact: true })
- .click();
- await expect(ui.getByRole("alert")).toContainText(
- /modified or untracked files|worktree/,
+});
+
+test("empty filtered inbox offers Clear filters and hides zero attention", async ({
+ page,
+}) => {
+ const { ui } = await host(page);
+ await expect(ui.getByRole("button", { name: /^Messages\s*1$/ })).toHaveCount(
+ 0,
);
- await expect(ui.locator(".detail .status")).toHaveText("interrupted");
+ await ui.getByLabel("Search inbox").fill("no such item");
await expect(
- ui.getByRole("button", { name: "Resume agent", exact: true }),
+ ui.getByText("No matching items", { exact: true }),
).toBeVisible();
+ await ui.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(
- ui.getByRole("button", { name: "Send message", exact: true }),
- ).toBeDisabled();
- await expect(ui.getByLabel("Message agent")).toHaveValue(
- "Retain this follow-up draft.",
- );
- await expect(
- ui.getByText("Answer received. Task complete.", { exact: false }),
+ ui.getByRole("button", { name: /Fix reconnect regression/ }),
).toBeVisible();
- expect(await readFile(sentinel, "utf8")).toBe(
- "retain this browser fixture change\n",
+ await expect(ui.getByLabel("Search inbox")).toHaveValue("");
+});
+
+test("Open agent targets the newest retained run and keeps message drafts across tabs and runs", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [
+ retained(snapshot, "old", "idle", "2026-10-20T00:00:00Z"),
+ retained(snapshot, "latest"),
+ retained(snapshot, "draft", "prepared", "2026-10-22T00:00:00Z"),
+ retained(snapshot, "removed", "cleaned", "2026-10-23T00:00:00Z"),
+ ];
+ const { ui, calls } = await host(page, { snapshot });
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Inactive",
);
- const interrupted = (await daemon("list_runs")).find(
- (entry) => entry.id === run.id,
- )!;
- expect(interrupted.status).toBe("interrupted");
- expect(interrupted.worktree).toBe(run.worktree);
- expect(calls.filter((call) => call.name === "start_run")).toHaveLength(1);
- await ui.getByRole("button", { name: "Resume agent", exact: true }).click();
+ await openItem(ui, /Fix reconnect regression/);
await expect(
- ui.getByRole("button", { name: /Waiting for your answer/ }),
+ ui.getByRole("button", { name: "New run", exact: true }),
).toBeVisible();
- await expect(ui.locator(".detail .status")).toHaveText("waiting");
- const resumed = (await daemon("list_runs")).find(
- (entry) => entry.id === run.id,
- )!;
- expect(resumed.worktree).toBe(run.worktree);
- expect(resumed.threadId).toBe(run.threadId);
- expect(await readFile(sentinel, "utf8")).toBe(
- "retain this browser fixture change\n",
+ await ui.getByLabel("Message agent").fill("Draft for the newest run");
+ await ui.getByText("Details", { exact: true }).click();
+ await expect(ui.getByText("/local/latest", { exact: true })).toBeVisible();
+ await openItem(ui, /Fix reconnect regression/);
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Draft for the newest run",
);
- await expect(
- ui.getByText("Answer received. Task complete.", { exact: false }),
- ).toBeVisible();
+ await showAgents(ui);
+ await ui.locator(".work-history > button.item").first().click();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("");
+ await ui.getByLabel("Message agent").fill("Draft for the old run");
+ await showAgents(ui);
+ await ui.locator(".work-history > button.item").nth(1).click();
await expect(ui.getByLabel("Message agent")).toHaveValue(
- "Retain this follow-up draft.",
+ "Draft for the newest run",
);
expect(
- calls
- .filter((call) => call.name === "start_run")
- .map((call) => call.arguments),
- ).toEqual([{ id: run.id }, { id: run.id, resume: true }]);
- expect(forbidden).toEqual([]);
+ calls.filter((c) =>
+ ["prepare_launch", "start_run", "send_message"].includes(c.name),
+ ),
+ ).toHaveLength(0);
});
-test("MCP bridge drives the assembled daemon through launch, question, answer and follow-up", async ({
+test("default launch opens review directly and Draft launches reopens its immutable snapshot newest first", async ({
page,
}) => {
- const { ui, calls, forbidden } = await assembledHost(page);
- await ui.getByRole("button", { name: /Assembled daemon fixture/ }).click();
- expect(calls.map((call) => call.name)).not.toContain("start_run");
- await ui.getByRole("button", { name: "Launch agent", exact: true }).click();
- await ui
- .getByLabel("Task prompt")
- .fill("Inspect the local fixture and ask which scope to investigate.");
- await ui.getByRole("button", { name: "Review launch" }).click();
- await ui.getByRole("button", { name: "Start agent", exact: true }).click();
- await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
- await ui.getByRole("radio", { name: /Focused/ }).check();
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- await expect(ui.getByRole("dialog")).toHaveCount(0);
+ const snapshot = initial();
+ snapshot.profiles[0].mode = "plan";
+ snapshot.runs = [
+ retained(snapshot, "older-draft", "prepared", "2026-10-20T00:00:00Z"),
+ ];
+ const { ui, state, calls } = await host(page, { snapshot });
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
+ const dialog = ui.getByRole("dialog", { name: "Launch agent" });
await expect(
- ui.getByText("Answer received. Task complete.", { exact: false }),
- ).toBeVisible();
- await ui
- .getByLabel("Message agent")
- .fill("Follow-up on the isolated fixture.");
- await ui.getByRole("button", { name: "Send message" }).click();
+ dialog.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ await expect(dialog.getByText("plan", { exact: true })).toBeVisible();
+ await expect(dialog).toContainText("read-only · on-request");
+ await expect(dialog).toContainText("Ask before publishing");
+ await expect(dialog.getByLabel("Task prompt")).toHaveCount(0);
+ const prepared = structuredClone(state.runs[0]);
+ state.profiles[0].name = "Changed profile";
+ state.profiles[0].mode = "default";
+ state.profiles[0].sandbox = "workspace-write";
+ await dialog.getByRole("button", { name: "Close launch" }).click();
+ await showAgents(ui);
+ await expect(ui.getByText("Draft launches", { exact: true })).toBeVisible();
+ const drafts = ui.getByText("Draft launches", { exact: true }).locator("..");
+ await expect(drafts).not.toHaveAttribute("open", "");
+ await drafts.locator("summary").click();
+ await drafts.getByRole("button").first().click();
+ await expect(dialog).toContainText(prepared.profile.name);
+ await expect(dialog.getByText("plan", { exact: true })).toBeVisible();
+ await expect(dialog).toContainText("read-only · on-request");
+ await expect(dialog).toContainText(prepared.prompt);
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+ await dialog
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
await expect
- .poll(() => calls.filter((call) => call.name === "send_message").length)
- .toBe(1);
- expect(calls.filter((call) => call.name === "start_run")).toHaveLength(1);
- expect(forbidden).toEqual([]);
+ .poll(() => calls.find((c) => c.name === "start_run")?.arguments)
+ .toEqual({ id: prepared.id });
});
-function questionSnapshot(): Snapshot {
- const snap = initial();
- snap.runs = ["run-1", "run-2"].map((id, i) => ({
- id,
- itemId: snap.inbox[0].item.id,
- profile: { ...snap.profiles[0], name: `Agent ${i + 1}` },
- prompt: "Investigate",
- skills: [],
- worktree: `/local/${id}`,
- branch: id,
- base: "HEAD",
- threadId: id,
- turnId: `turn-${i + 1}`,
- status: "waiting",
- error: "",
- created: new Date().toISOString(),
- }));
- snap.attention = [
+test("global attention selects its owning run and returns to its conversation after answering", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ const second = structuredClone(snapshot.inbox[0]);
+ second.item.id = "owner/repo#2";
+ second.item.title = "Other task";
+ snapshot.inbox.push(second);
+ snapshot.runs = [
+ retained(snapshot, "attention-owner", "waiting"),
+ { ...retained(snapshot, "other"), itemId: second.item.id },
+ ];
+ snapshot.attention = [
{
- id: "question-1",
- runId: "run-1",
- origin: "rpc",
+ id: "global-question",
+ runId: "attention-owner",
method: "item/tool/requestUserInput",
blocking: true,
status: "pending",
@@ -1312,323 +1557,2592 @@ function questionSnapshot(): Snapshot {
{
id: "scope",
header: "Scope",
- question: "Which target?",
- isOther: true,
+ question: "Choose scope",
+ options: [{ label: "Focused", description: "One fix" }],
},
],
},
},
];
- return snap;
-}
+ const { ui, calls } = await host(page, { snapshot });
+ await openItem(ui, /Other task/);
+ await expect(
+ ui.getByRole("button", { name: "← Issues", exact: true }),
+ ).toBeVisible();
+ await openWaiting(ui);
+ const dialog = ui.getByRole("dialog");
+ await expect(dialog).toContainText(
+ "Fix reconnect regression · Investigation",
+ );
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await expect(dialog).toHaveCount(0);
+ await expect(ui.getByLabel("Message agent")).toBeVisible();
+ await expect(
+ ui.getByRole("button", { name: "← Messages", exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByRole("button", { name: "← Issues", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ ui.getByRole("heading", {
+ name: "Fix reconnect regression",
+ exact: true,
+ level: 1,
+ }),
+ ).toBeFocused();
+ await expect(ui.getByRole("button", { name: /^Messages\s*1$/ })).toHaveCount(
+ 0,
+ );
+ await ui.getByLabel("Message agent").fill("Continue the owning run");
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect
+ .poll(() => calls.find((c) => c.name === "send_message")?.arguments.id)
+ .toBe("attention-owner");
+});
-test("answer confirmation expires despite polling, stays with its run and never alters transcript text", async ({
+test("unverifiable Start disables retry and Edit until read_run locates accepted work", async ({
page,
}) => {
- await page.clock.install();
- const { ui, state, calls } = await host(page, {
- snapshot: questionSnapshot(),
- transcript: "UI_ANSWER_OK ✓ Answer sent is agent text",
- });
+ const options = { loseStartResponse: true, failReadRun: true };
+ const { ui, calls } = await host(page, options);
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
await ui
- .getByRole("button", { name: "Needs attention 1", exact: true })
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true })
.click();
- await ui.getByRole("textbox", { name: "Which target?" }).fill("One");
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- const confirmation = ui.locator(".answer-confirmation");
- await expect(confirmation).toHaveText("✓ Answer sent");
+ const dialog = ui.getByRole("dialog");
+ await expect(dialog.getByRole("alert")).toContainText(
+ "Start status is uncertain",
+ );
+ await expect(
+ dialog.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeDisabled();
+ await expect(
+ dialog.getByRole("button", { name: "Edit", exact: true }),
+ ).toBeDisabled();
+ options.failReadRun = false;
+ await dialog
+ .getByRole("button", { name: "Check start status", exact: true })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ await expect(ui.getByLabel("Message agent")).toBeVisible();
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(1);
+});
+
+test("repository form shows saving and errors beside retained inputs", async ({
+ page,
+}) => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const { ui } = await host(page, {
+ surface: "settings",
+ failSave: true,
+ beforeSave: () => gate,
+ });
+ await ui.getByLabel("GitHub repository").fill("owner/repo");
+ const form = ui
+ .getByRole("button", { name: "Save repository", exact: true })
+ .locator("xpath=ancestor::form");
+ try {
+ await form.getByRole("button").click();
+ await expect(form.getByRole("status")).toHaveText("Saving…");
+ await expect(form.getByRole("button")).toBeDisabled();
+ release();
+ await expect(form.getByRole("alert")).toContainText(
+ "Repository unavailable",
+ );
+ await expect(ui.getByLabel("GitHub repository")).toHaveValue("owner/repo");
+ await expect(form.getByRole("button")).toBeEnabled();
+ } finally {
+ release();
+ }
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`readable approval choices preserve payloads in a narrow ${theme} embed`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "approval-owner", "waiting")];
+ const decision = {
+ acceptWithExecpolicyAmendment: {
+ execpolicy_amendment: ["git", "status"],
+ },
+ };
+ const choices = [
+ { decision: "accept", label: "Allow once" },
+ { decision: "acceptForSession", label: "Allow for this session" },
+ { decision: "decline", label: "Deny" },
+ { decision: "cancel", label: "Cancel" },
+ { decision, label: "Allow and update command policy" },
+ ];
+ const { ui, state, calls } = await host(page, { snapshot, theme });
+ for (const [index, choice] of choices.entries()) {
+ state.runs[0].status = "waiting";
+ state.attention = [
+ {
+ id: `approval-${index}`,
+ runId: "approval-owner",
+ method: "item/commandExecution/requestApproval",
+ blocking: true,
+ status: "pending",
+ params: {
+ command: "git status",
+ reason: "Inspect working tree",
+ cwd: "/local/approval-owner",
+ availableDecisions: [choice.decision],
+ },
+ },
+ ];
+ state.cursor++;
+ await openWaiting(ui);
+ const dialog = ui.getByRole("dialog");
+ await expect(
+ dialog.getByText("git status", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ dialog.getByText("Inspect working tree", { exact: true }),
+ ).toBeVisible();
+ await expect(dialog.locator("details")).not.toHaveAttribute("open", "");
+ await expect(dialog.locator("pre")).toBeHidden();
+ expect(
+ await dialog.evaluate((el) => {
+ const r = el.getBoundingClientRect();
+ return r.left >= 0 && r.right <= window.innerWidth;
+ }),
+ ).toBe(true);
+ if (index === 0)
+ await page.screenshot({
+ path: test.info().outputPath(`approval-${theme}.png`),
+ });
+ expect(
+ await ui
+ .locator("body")
+ .evaluate((b) => b.scrollWidth <= b.clientWidth),
+ ).toBe(true);
+ await tabTo(
+ page,
+ dialog.getByRole("button", { name: choice.label, exact: true }),
+ );
+ await page.keyboard.press("Enter");
+ await expect(dialog).toHaveCount(0);
+ expect(
+ calls.filter((c) => c.name === "answer_request").at(-1)?.arguments,
+ ).toEqual({
+ id: `approval-${index}`,
+ answer: { decision: choice.decision },
+ });
+ }
+ state.runs[0].status = "waiting";
+ state.attention = [
+ {
+ id: "permissions",
+ runId: "approval-owner",
+ method: "item/permissions/requestApproval",
+ blocking: true,
+ status: "pending",
+ params: {
+ reason: "Read project files",
+ permissions: { fileSystem: { read: ["/local/project"], write: [] } },
+ },
+ },
+ ];
+ state.cursor++;
+ await openWaiting(ui);
+ await expect(
+ ui.getByRole("dialog").getByText("/local/project", { exact: true }),
+ ).toBeVisible();
+ await ui.getByRole("button", { name: "Allow once", exact: true }).click();
+ await expect(ui.getByRole("dialog")).toHaveCount(0);
+ expect(
+ calls.filter((c) => c.name === "answer_request").at(-1)?.arguments,
+ ).toEqual({
+ id: "permissions",
+ answer: {
+ permissions: { fileSystem: { read: ["/local/project"], write: [] } },
+ scope: "turn",
+ },
+ });
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
+ const launch = ui.getByRole("dialog", { name: "Launch agent" });
+ await expect(
+ launch.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ expect(
+ await launch.evaluate((el) => {
+ const r = el.getBoundingClientRect();
+ return r.left >= 0 && r.right <= window.innerWidth;
+ }),
+ ).toBe(true);
+ await page.screenshot({
+ path: test.info().outputPath(`launch-${theme}.png`),
+ });
+ await launch.getByRole("button", { name: "Edit", exact: true }).click();
+ await launch.getByText("Advanced", { exact: true }).click();
+ expect(
+ await launch.evaluate((el) => {
+ const r = el.getBoundingClientRect();
+ return r.left >= 0 && r.right <= window.innerWidth;
+ }),
+ ).toBe(true);
+ await page.keyboard.press("Escape");
+ await showSettings(page);
+ await ui.getByText("Priorities & notifications", { exact: true }).click();
+ expect(
+ await ui.locator("body").evaluate((b) => b.scrollWidth <= b.clientWidth),
+ ).toBe(true);
+ });
+}
+
+test("confirmed unaccepted Start allows an explicit retry of the same snapshot", async ({
+ page,
+}) => {
+ const options = { rejectStart: true };
+ const { ui, calls } = await host(page, options);
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
+ await ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ const dialog = ui.getByRole("dialog");
+ await expect(dialog.getByRole("alert")).toContainText("still prepared");
+ await expect(
+ dialog.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ options.rejectStart = false;
+ await dialog
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await expect(dialog).toHaveCount(0);
+ expect(
+ calls.filter((c) => c.name === "start_run").map((c) => c.arguments),
+ ).toEqual([{ id: "run-1" }, { id: "run-1" }]);
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+});
+
+test("collapsed settings editors preserve capabilities and show independent save results", async ({
+ page,
+}) => {
+ const { ui, calls } = await host(page, { surface: "settings" });
+ await expect(ui.getByLabel("GitHub repository")).toBeFocused();
+ for (const title of [
+ "Launch profiles",
+ "Managed skills",
+ "Connection diagnostics",
+ ])
+ await ui.getByText(title, { exact: true }).click();
+ const profileForm = ui
+ .getByRole("button", { name: "Save profile", exact: true })
+ .locator("xpath=ancestor::form");
+ await profileForm.getByLabel("Name", { exact: true }).fill("Focused review");
+ await profileForm
+ .getByRole("combobox", { name: /Agent mode/ })
+ .selectOption("plan");
+ await profileForm.getByRole("button").click();
+ await expect(profileForm.getByRole("status")).toHaveText("Saved.");
+ expect(calls.find((c) => c.name === "save_profile")?.arguments).toMatchObject(
+ {
+ name: "Focused review",
+ mode: "plan",
+ sandbox: "read-only",
+ publishing: "ask",
+ },
+ );
+ const skillForm = ui
+ .getByRole("button", { name: "Save skill", exact: true })
+ .locator("xpath=ancestor::form");
+ await skillForm
+ .getByLabel("Skill name", { exact: true })
+ .fill("focused-review");
+ await skillForm.getByRole("button").click();
+ await expect(skillForm.getByRole("status")).toHaveText("Saved.");
+ expect(calls.find((c) => c.name === "save_skill")?.arguments.name).toBe(
+ "focused-review",
+ );
+ const discovery = ui
+ .getByRole("button", { name: "Discover local skills", exact: true })
+ .locator("xpath=ancestor::form");
+ await discovery.getByRole("button").click();
+ await expect(discovery.getByRole("status")).toHaveText("Saved.");
+ await ui
+ .getByRole("button", { name: "Check connection", exact: true })
+ .click();
+ await expect(
+ ui.getByText("verified · fixture-check: Fixture connection healthy"),
+ ).toBeVisible();
+ await expect(profileForm.getByRole("status")).toHaveText("Saved.");
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`agent conversation shows human messages and keeps technical activity under Details in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "transcript", "waiting")];
+ const params = [
+ {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "user",
+ type: "userMessage",
+ content: [{ text: "Please investigate the reconnect failure." }],
+ },
+ },
+ },
+ {
+ method: "item/agentMessage/delta",
+ params: { itemId: "progress", delta: "Checking " },
+ },
+ {
+ method: "item/agentMessage/delta",
+ params: { itemId: "progress", delta: "the reconnect path." },
+ },
+ {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "progress",
+ type: "agentMessage",
+ phase: "commentary",
+ text: "Checking the reconnect path.",
+ },
+ },
+ },
+ {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "command",
+ type: "commandExecution",
+ command: "git status",
+ aggregatedOutput: "Command output detail",
+ },
+ },
+ },
+ {
+ method: "turn/plan/updated",
+ params: {
+ plan: [{ step: "Inspect reconnect", status: "in_progress" }],
+ },
+ },
+ { method: "turn/diff/updated", params: { diff: "Raw change detail" } },
+ {
+ method: "item/reasoning/summaryTextDelta",
+ params: { itemId: "reasoning", delta: "Internal reasoning summary" },
+ },
+ {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "files",
+ type: "fileChange",
+ changes: [{ path: "main.go", diff: "Raw file patch" }],
+ },
+ },
+ },
+ {
+ method: "provider/chunk",
+ params: { delta: "Unstructured output detail" },
+ },
+ {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "final",
+ type: "agentMessage",
+ phase: "final_answer",
+ text: "The retry condition is fixed and verified.",
+ },
+ },
+ },
+ {
+ method: "error",
+ params: {
+ error: {
+ message: "Visible failure",
+ code: "providerFailure",
+ diagnostic: "Diagnostic error detail",
+ },
+ },
+ },
+ ];
+ const transcript = params.map((data, i) => ({
+ seq: i + 1,
+ kind: "run.output",
+ entity: "transcript",
+ data,
+ at: "2026-10-21T00:00:00Z",
+ })) as JournalEvent[];
+ snapshot.attention = [
+ {
+ id: "visible-question",
+ runId: "transcript",
+ method: "item/tool/requestUserInput",
+ blocking: true,
+ status: "pending",
+ params: {
+ questions: [
+ {
+ id: "scope",
+ header: "Scope",
+ question: "Which scope should I use?",
+ options: [{ label: "Focused", description: "One fix" }],
+ },
+ ],
+ },
+ },
+ ];
+ const { ui, state, calls } = await host(page, {
+ snapshot,
+ transcript,
+ theme,
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const conversation = ui.locator(".transcript");
+ for (const message of [
+ "Please investigate the reconnect failure.",
+ "Checking the reconnect path.",
+ "The retry condition is fixed and verified.",
+ "Visible failure",
+ ])
+ await expect(
+ conversation.getByText(message, { exact: true }),
+ ).toBeVisible();
+ await expect(
+ conversation.getByText("Checking the reconnect path.", { exact: true }),
+ ).toHaveCount(1);
+ const details = ui.locator(".agent-details");
+ await expect(details).not.toHaveAttribute("open", "");
+ for (const message of [
+ "in_progress: Inspect reconnect",
+ "Raw change detail",
+ "Internal reasoning summary",
+ "main.go\nRaw file patch",
+ "Unstructured output detail",
+ "git status\nCommand output detail",
+ ]) {
+ await expect(
+ conversation.getByText(message, { exact: true }),
+ ).toHaveCount(0);
+ await expect(ui.getByText(message, { exact: true })).toBeHidden();
+ }
+ await expect(
+ ui.getByRole("button", { name: /Waiting for your answer/ }),
+ ).toBeVisible();
+ await page.screenshot({
+ path: test.info().outputPath(`concise-conversation-${theme}.png`),
+ });
+ await scanOwnedApp(page, `concise-conversation-${theme}`);
+ await details.locator("summary").focus();
+ await page.keyboard.press("Enter");
+ await expect(
+ ui.getByText("git status\nCommand output detail", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByText("Internal reasoning summary", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByText("in_progress: Inspect reconnect", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByText(/"diagnostic": "Diagnostic error detail"/),
+ ).toBeVisible();
+ await details.locator("summary").click();
+ transcript.push({
+ seq: 13,
+ kind: "run.output",
+ entity: "transcript",
+ data: {
+ method: "item/agentMessage/delta",
+ params: {
+ itemId: "next-progress",
+ delta: "Verification is still running.",
+ },
+ },
+ at: "2026-10-21T00:00:01Z",
+ });
+ transcript.push({
+ seq: 14,
+ kind: "run.output",
+ entity: "transcript",
+ data: {
+ method: "commandExecution/outputDelta",
+ params: { itemId: "next-command", delta: "New command noise" },
+ },
+ at: "2026-10-21T00:00:01Z",
+ });
+ state.cursor++;
+ await expect(
+ conversation.getByText("Verification is still running.", { exact: true }),
+ ).toBeVisible();
+ await expect(details).not.toHaveAttribute("open", "");
+ await expect(
+ ui.getByText("New command noise", { exact: true }),
+ ).toBeHidden();
+ await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
+ await expect(ui.getByRole("dialog")).toContainText(
+ "Which scope should I use?",
+ );
+ await page.keyboard.press("Escape");
+ expect(
+ calls.filter((c) =>
+ ["start_run", "send_message", "answer_request"].includes(c.name),
+ ),
+ ).toHaveLength(0);
+ });
+}
+
+test("technical-only activity leaves a concise waiting state in the conversation", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "technical-only", "running")];
+ const { ui } = await host(page, {
+ snapshot,
+ transcript: [
+ {
+ seq: 1,
+ kind: "run.output",
+ entity: "technical-only",
+ data: {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "cmd",
+ type: "commandExecution",
+ command: "go test ./...",
+ aggregatedOutput: "Raw test output",
+ },
+ },
+ },
+ at: "2026-10-21T00:00:00Z",
+ },
+ ],
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.locator(".transcript")).toHaveText(
+ "Waiting for an agent update…",
+ );
+ await expect(
+ ui.getByText("go test ./...\nRaw test output", { exact: true }),
+ ).toBeHidden();
+});
+
+for (const theme of ["light", "dark"] as const) {
+ for (const width of [1280, 390]) {
+ test(`single-page navigation preserves list context and drafts at ${width}px in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height: 820 });
+ const snapshot = initial();
+ snapshot.inbox = Array.from({ length: 40 }, (_, index) => {
+ const entry = structuredClone(snapshot.inbox[0]);
+ entry.item.id = `owner/repo#${index + 1}`;
+ entry.item.title = `Item ${index + 1}: investigation`;
+ entry.item.body = `Description for item ${index + 1}`;
+ return entry;
+ });
+ snapshot.inbox[0].item.title +=
+ ": investigate a lengthy failure description that wraps in a narrow inbox while keeping priority beside the first line";
+ snapshot.runs = [
+ { ...retained(snapshot, "page-run"), itemId: "owner/repo#30" },
+ ];
+ const { ui, calls } = await host(page, { snapshot, theme });
+ await expect(
+ ui.locator("header, footer, .brand, .mark, .split, .detail"),
+ ).toHaveCount(0);
+ await expect(
+ ui.getByRole("status", { name: "Connection status" }),
+ ).toHaveClass("sr-only");
+ await expect(ui.getByText("Sync details", { exact: true })).toHaveCount(
+ 0,
+ );
+ await ui.getByLabel("Search inbox").fill("Item");
+ await ui.getByLabel("Inbox filter").selectOption("all");
+ const row = ui.getByRole("button", { name: /Item 30: investigation/ });
+ const listWidth = await ui
+ .locator(".item-list")
+ .evaluate((el) => el.getBoundingClientRect().width);
+ const frameWidth = await ui
+ .locator("body")
+ .evaluate(() => window.innerWidth);
+ expect(listWidth).toBeGreaterThan(frameWidth * 0.9);
+ const firstItem = ui.locator(".inbox-row > .item").first();
+ await expect(firstItem.locator(".priority")).toHaveText("P1");
+ await expect(ui.locator(".item-meta .priority")).toHaveCount(0);
+ const badgeBox = await firstItem.locator(".priority").boundingBox();
+ const titleBox = await firstItem.locator(".item-title").boundingBox();
+ expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(titleBox!.x);
+ expect(Math.abs(badgeBox!.y - titleBox!.y)).toBeLessThan(6);
+ expect(
+ await ui
+ .locator("body")
+ .evaluate((b) => b.scrollWidth <= b.clientWidth),
+ ).toBe(true);
+ await page.screenshot({
+ path: test.info().outputPath(`inbox-top-${width}-${theme}.png`),
+ });
+ await row.scrollIntoViewIfNeeded();
+ await row.focus();
+ const scrollBefore = await ui
+ .locator("body")
+ .evaluate(() => window.scrollY);
+ expect(scrollBefore).toBeGreaterThan(0);
+ await page.keyboard.press("Enter");
+ await expect(
+ ui.getByRole("heading", { name: "Item 30: investigation", level: 1 }),
+ ).toBeVisible();
+ await expect(ui.locator(".item-list")).toHaveCount(0);
+ await expect(ui.getByLabel("Search inbox")).toHaveCount(0);
+ await expect(
+ ui.getByText("Description for item 30", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ ui.getByText("Item 29: investigation", { exact: true }),
+ ).toHaveCount(0);
+ expect(
+ await ui
+ .locator("body")
+ .evaluate((b) => b.scrollWidth <= b.clientWidth),
+ ).toBe(true);
+ await page.screenshot({
+ path: test.info().outputPath(`item-${width}-${theme}.png`),
+ });
+ await (await linkTarget(ui)).fill("owner/repo#retained-draft");
+ await expect(ui.getByLabel("Message agent")).toBeVisible();
+ await expect(ui.locator(".item-list")).toHaveCount(0);
+ await ui.getByLabel("Message agent").fill("Run-specific message draft");
+ await showItemDetails(ui);
+ await expect(ui.getByLabel("Link target")).toHaveValue(
+ "owner/repo#retained-draft",
+ );
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await expect(ui.locator(".detail")).toHaveCount(0);
+ await expect(ui.getByLabel("Search inbox")).toHaveValue("Item");
+ await expect(ui.getByLabel("Inbox filter")).toHaveValue("all");
+ await expect(row).toBeFocused();
+ expect(
+ await ui.locator("body").evaluate(() => window.scrollY),
+ ).toBeCloseTo(scrollBefore, 0);
+ await page.screenshot({
+ path: test.info().outputPath(`inbox-${width}-${theme}.png`),
+ });
+ await showAgents(ui);
+ const runRow = ui.locator(".work-history > .item").first();
+ await runRow.click();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Run-specific message draft",
+ );
+ await expect(ui.locator(".item-list")).toHaveCount(0);
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await expect(ui.getByLabel("Search inbox")).toHaveValue("Item");
+ await expect(ui.getByLabel("Message agent")).toHaveCount(0);
+ expect(
+ calls.filter((c) =>
+ [
+ "prepare_launch",
+ "start_run",
+ "send_message",
+ "set_relationship",
+ ].includes(c.name),
+ ),
+ ).toHaveLength(0);
+ });
+ }
+}
+
+test("page changes keep keyboard focus usable when an unread row leaves the list", async ({
+ page,
+}) => {
+ const { ui } = await host(page);
+ await ui.getByLabel("Inbox filter").selectOption("unread");
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await expect(
+ ui.getByText("No matching items", { exact: true }),
+ ).toBeVisible();
+ await expect(ui.locator(".detail")).toHaveCount(0);
+ await ui.getByRole("button", { name: "Clear filters", exact: true }).click();
+ await expect(
+ ui.getByRole("button", { name: /Fix reconnect regression/ }),
+ ).toBeVisible();
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`small inbox GitHub links open the original issue and PR without selecting either in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const snapshot = initial();
+ snapshot.inbox[0].item.id = "dkropachev/code-factory-pilot-20261019#1";
+ snapshot.inbox[0].item.url =
+ "https://github.com/dkropachev/code-factory-pilot-20261019/issues/1";
+ const pr = structuredClone(snapshot.inbox[0]);
+ pr.item.id = "dkropachev/code-factory-pilot-20261019#2";
+ pr.item.kind = "pr";
+ pr.item.title = "Original pull request";
+ pr.item.url =
+ "https://github.com/dkropachev/code-factory-pilot-20261019/pull/2";
+ snapshot.inbox.push(pr);
+ const { ui, state, calls, openedLinks } = await host(page, {
+ snapshot,
+ theme,
+ });
+ const issue = ui.getByRole("link", {
+ name: "Open issue dkropachev/code-factory-pilot-20261019#1 on GitHub",
+ exact: true,
+ });
+ const pull = ui.getByRole("link", {
+ name: "Open pull request dkropachev/code-factory-pilot-20261019#2 on GitHub",
+ exact: true,
+ });
+ await expect(issue).toHaveText("dkropachev/code-factory-pilot-20261019#1");
+
+ await expect(
+ ui.getByText("dkropachev/code-factory-pilot-20261019#1", { exact: true }),
+ ).toHaveCount(1);
+ await expect(ui.getByText("GitHub", { exact: true })).toHaveCount(0);
+ await expect(issue.locator("..")).toHaveClass("item-meta");
+ await expect(issue).toHaveAttribute("href", snapshot.inbox[0].item.url);
+
+ await expect(issue).toHaveAttribute("rel", "noopener noreferrer");
+ await expect(ui.locator("button a")).toHaveCount(0);
+ await issue.click();
+ await expect.poll(() => openedLinks.length).toBe(1);
+ await ui.getByRole("button", { name: "My PRs", exact: true }).click();
+ await expect(pull).toHaveText("dkropachev/code-factory-pilot-20261019#2");
+ await expect(pull).toHaveAttribute("href", pr.item.url);
+ await ui.getByRole("button", { name: /Original pull request/ }).focus();
+ await page.keyboard.press("Tab");
+ await expect(pull).toBeFocused();
+ await page.keyboard.press("Enter");
+ await expect.poll(() => openedLinks.length).toBe(2);
+ expect(openedLinks).toEqual([snapshot.inbox[0].item.url, pr.item.url]);
+ expect(state.inbox.every((e) => e.unread && e.user.new)).toBe(true);
+ expect(
+ calls.filter((c) =>
+ ["read_item", "item_action", "prepare_launch", "start_run"].includes(
+ c.name,
+ ),
+ ),
+ ).toHaveLength(0);
+ await expect(ui.locator(".detail")).toHaveCount(0);
+ await expect(ui.getByLabel("Search inbox")).toBeVisible();
+ expect(
+ await ui.locator("body").evaluate((b) => b.scrollWidth <= b.clientWidth),
+ ).toBe(true);
+ await page.screenshot({
+ path: test.info().outputPath(`github-links-${theme}.png`),
+ });
+ await scanOwnedApp(page, `github-links-${theme}`);
+ await ui.getByRole("button", { name: /Original pull request/ }).click();
+ await expect(
+ ui.getByRole("heading", { name: pr.item.title, level: 1 }),
+ ).toBeVisible();
+ await expect(ui.locator(".item-list")).toHaveCount(0);
+ });
+}
+for (const failure of ["deny", "rpc"] as const) {
+ test(`inbox GitHub link ${failure} failure stays in the list with visible feedback`, async ({
+ page,
+ }) => {
+ const { ui, calls, openedLinks } = await host(page, {
+ denyOpenLink: failure === "deny",
+ failOpenLink: failure === "rpc",
+ });
+ await ui
+ .getByRole("link", {
+ name: "Open issue owner/repo#1 on GitHub",
+ exact: true,
+ })
+ .click();
+ await expect(ui.getByRole("alert")).toContainText(
+ failure === "deny"
+ ? "The host could not open GitHub."
+ : "Host link unavailable",
+ );
+ expect(openedLinks).toHaveLength(1);
+ await expect(ui.getByLabel("Search inbox")).toBeVisible();
+ expect(
+ calls.filter((c) => c.name === "read_item" || c.name === "item_action"),
+ ).toHaveLength(0);
+ });
+}
+
+for (const theme of ["light", "dark"] as const) {
+ test(`settings has a dedicated ${theme} view without inbox or agent navigation`, async ({
+ page,
+ }) => {
+ const { ui, calls } = await host(page, { surface: "settings", theme });
+ await expect(
+ ui.getByRole("heading", { name: "Settings", exact: true }),
+ ).toBeVisible();
+ await expect(ui.getByRole("navigation")).toHaveCount(0);
+ await expect(
+ ui.getByRole("button", { name: "Issues", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ ui.getByRole("button", { name: "Agents", exact: true }),
+ ).toHaveCount(0);
+ await expect(ui.getByLabel("Search inbox")).toHaveCount(0);
+ await expect(ui.getByLabel("Message agent")).toHaveCount(0);
+ await ui.getByLabel("GitHub repository").fill("owner/repo");
+ await ui
+ .getByRole("button", { name: "Save repository", exact: true })
+ .click();
+ const form = ui
+ .getByRole("button", { name: "Save repository", exact: true })
+ .locator("xpath=ancestor::form");
+ await expect(form.getByRole("status")).toHaveText("Saved.");
+ await expect(
+ ui.getByRole("heading", { name: "Settings", exact: true }),
+ ).toBeVisible();
+ expect(
+ calls.filter((c) => ["prepare_launch", "start_run"].includes(c.name)),
+ ).toHaveLength(0);
+ });
+}
+test("inbox and agent views have no settings entry or configuration controls", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "no-settings")];
+ const { ui } = await host(page, { snapshot });
+ for (const tab of ["Issues", "My PRs", "Review PRs", "Messages"]) {
+ await ui.getByRole("button", { name: tab, exact: true }).click();
+ await expect(
+ ui.getByRole("button", { name: "Settings", exact: true }),
+ ).toHaveCount(0);
+ await expect(ui.getByLabel("GitHub repository")).toHaveCount(0);
+ }
+ await showAgents(ui);
+ await ui.locator(".work-history > .item").first().click();
+ await expect(ui.getByLabel("Message agent")).toBeVisible();
+ await expect(
+ ui.getByRole("button", { name: "Settings", exact: true }),
+ ).toHaveCount(0);
+});
+
+test("separate settings can save while the inbox view retains its item and agent drafts", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "settings-concurrent")];
+ const { ui, calls } = await host(page, { snapshot });
+ await openItem(ui, /Fix reconnect regression/);
+ await (await linkTarget(ui)).fill("owner/repo#kept");
+ await ui.getByLabel("Message agent").fill("Keep this agent draft");
+ await page.evaluate(() => {
+ const frame = document.createElement("iframe");
+ frame.title = "Code Factory settings";
+ frame.src = "/settings-ui";
+ frame.style.cssText = "width:100%;height:95vh;border:0";
+ document.body.appendChild(frame);
+ });
+ const settings = page.frameLocator('iframe[title="Code Factory settings"]');
+ await settings.getByLabel("GitHub repository").fill("owner/repo");
+ await settings
+ .getByRole("button", { name: "Save repository", exact: true })
+ .click();
+ await expect(
+ settings
+ .getByRole("button", { name: "Save repository", exact: true })
+ .locator("xpath=ancestor::form")
+ .getByRole("status"),
+ ).toHaveText("Saved.");
+ await expect(
+ settings.getByRole("heading", { name: "Settings", exact: true }),
+ ).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep this agent draft",
+ );
+ await expect(
+ ui.getByRole("button", { name: "Settings", exact: true }),
+ ).toHaveCount(0);
+ await showItemDetails(ui);
+ await expect(ui.getByLabel("Link target")).toHaveValue("owner/repo#kept");
+ expect(
+ calls.filter((c) =>
+ ["start_run", "send_message", "set_relationship"].includes(c.name),
+ ),
+ ).toHaveLength(0);
+});
+
+type ToolCall = { name: string; arguments: Record };
+type ToolResult = {
+ isError?: boolean;
+ content?: { type: string; text?: string }[];
+ structuredContent?: { data: unknown };
+};
+async function assembledHost(
+ page: Page,
+ options: {
+ loseFirstStartResponse?: boolean;
+ beforeFirstStartResponse?: () => Promise;
+ } = {},
+) {
+ const calls: ToolCall[] = [];
+ const acceptedStarts: Run[] = [];
+ async function request(p: ToolCall): Promise {
+ calls.push(p);
+ const response = await fetch(`${fixtureDaemonOrigin}/mcp`, {
+ method: "POST",
+ headers: {
+ Authorization:
+ "Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ "MCP-Protocol-Version": "2026-07-28",
+ "Mcp-Method": "tools/call",
+ "Mcp-Name": p.name,
+ },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ id: 1,
+ method: "tools/call",
+ params: {
+ ...p,
+ _meta: {
+ "io.modelcontextprotocol/protocolVersion": "2026-07-28",
+ "io.modelcontextprotocol/clientInfo": {
+ name: "browser-host",
+ version: "1",
+ },
+ "io.modelcontextprotocol/clientCapabilities": {},
+ },
+ },
+ }),
+ });
+ const result = (await response.json()) as {
+ result?: ToolResult;
+ error?: unknown;
+ };
+ if (!response.ok || result.error || !result.result)
+ throw new Error("Daemon MCP request failed");
+ return result.result;
+ }
+ await page.exposeFunction("daemonCall", async (p: ToolCall) => {
+ const result = await request(p);
+ if (p.name === "start_run" && !p.arguments.resume && !result.isError) {
+ acceptedStarts.push(result.structuredContent!.data as Run);
+ if (acceptedStarts.length === 1) {
+ await options.beforeFirstStartResponse?.();
+ if (options.loseFirstStartResponse)
+ throw new Error(
+ "Start response lost after the fixture daemon accepted it.",
+ );
+ }
+ }
+ return result;
+ });
+ await page.route("**/assembled-host", (route) =>
+ route.fulfill({
+ contentType: "text/html",
+ body: ``,
+ }),
+ );
+ const fixtureDaemonHost = new URL(fixtureDaemonOrigin).host;
+ const forbidden: string[] = [];
+ page.on("request", (r) => {
+ if (
+ r.frame().parentFrame() &&
+ (r.url().includes(fixtureDaemonHost) || /localhost/.test(r.url()))
+ )
+ forbidden.push(r.url());
+ });
+ await page.goto("/assembled-host");
+ const ui = page.frameLocator("iframe");
+ await expect(ui.getByLabel("Search inbox")).toBeVisible();
+ async function daemon(
+ name: string,
+ args: Record = {},
+ ): Promise {
+ const result = await request({ name, arguments: args });
+ if (result.isError)
+ throw new Error(
+ result.content?.map((item) => item.text ?? "").join("\n") ||
+ "Fixture tool failed",
+ );
+ return result.structuredContent!.data as T;
+ }
+ return { ui, calls, acceptedStarts, forbidden, daemon };
+}
+
+test("lost Start response locates the accepted daemon run without another Start", async ({
+ page,
+}) => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const { ui, calls, acceptedStarts, forbidden, daemon } = await assembledHost(
+ page,
+ {
+ loseFirstStartResponse: true,
+ beforeFirstStartResponse: () => gate,
+ },
+ );
+ const before = await daemon("list_runs");
+ try {
+ await ui.getByRole("button", { name: /Assembled daemon fixture/ }).click();
+ await showItemDetails(ui);
+ await ui.getByRole("button", { name: /^New run$/, exact: true }).click();
+ await expect(
+ ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ const dialog = ui.getByRole("dialog", { name: "Launch agent" });
+ const start = ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true });
+ await start.dblclick();
+ await expect(start).toBeDisabled();
+ await expect
+ .poll(() => calls.filter((call) => call.name === "start_run").length)
+ .toBe(1);
+ release();
+ await expect(dialog).toHaveCount(0);
+ expect(acceptedStarts).toHaveLength(1);
+ const created = (await daemon("list_runs")).filter(
+ (run) => !before.some((old) => old.id === run.id),
+ );
+ expect(created).toHaveLength(1);
+ expect(created[0].id).toBe(acceptedStarts[0].id);
+ expect(
+ calls
+ .filter((call) => call.name === "start_run")
+ .map((call) => call.arguments),
+ ).toEqual([{ id: created[0].id }]);
+ expect(
+ calls.some(
+ (call) =>
+ call.name === "read_run" && call.arguments.id === created[0].id,
+ ),
+ ).toBe(true);
+ await expect(
+ ui.getByRole("button", { name: /Waiting for your answer/ }),
+ ).toBeVisible();
+ expect(calls.filter((call) => call.name === "prepare_launch")).toHaveLength(
+ 1,
+ );
+ expect(forbidden).toEqual([]);
+ } finally {
+ release();
+ }
+});
+
+test("sending a message restores the dirty retained daemon session without a Resume action", async ({
+ page,
+}) => {
+ const { ui, calls, acceptedStarts, forbidden, daemon } =
+ await assembledHost(page);
+ await ui.getByRole("button", { name: /Assembled daemon fixture/ }).click();
+ await showItemDetails(ui);
+ await ui.getByRole("button", { name: /^New run$/, exact: true }).click();
+ await expect(
+ ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ await ui
+ .getByRole("dialog", { name: "Launch agent" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await expect
+ .poll(
+ async () =>
+ (await daemon("list_runs")).find(
+ (r) => r.id === acceptedStarts[0].id,
+ )?.status,
+ )
+ .toBe("idle");
+ const run = (await daemon("list_runs")).find(
+ (entry) => entry.id === acceptedStarts[0].id,
+ )!;
+ expect(relative(tmpdir(), run.worktree)).toMatch(
+ /^factory-browser-[^/]+\/worktrees\/[a-f0-9]+$/,
+ );
+ const sentinel = join(run.worktree, "browser-dirty-fixture.txt");
+ await writeFile(sentinel, "retain this browser fixture change\n");
+ await ui.getByLabel("Message agent").fill("Retain this follow-up draft.");
+ await ui.getByText("Details", { exact: true }).click();
+ await ui
+ .getByRole("button", { name: "Clean up worktree", exact: true })
+ .click();
+ await expect(ui.getByRole("alert")).toContainText(
+ /modified or untracked files|worktree/,
+ );
+ await expect
+ .poll(
+ async () =>
+ (await daemon("list_runs")).find((r) => r.id === run.id)?.status,
+ )
+ .toBe("interrupted");
+ await expect(
+ ui.getByRole("button", { name: "Resume agent", exact: true }),
+ ).toHaveCount(0);
+ await expect(
+ ui.getByRole("button", { name: "Send message", exact: true }),
+ ).toBeEnabled();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Retain this follow-up draft.",
+ );
+ await expect(
+ ui.getByText("Answer received. Task complete.", { exact: false }),
+ ).toBeVisible();
+ expect(await readFile(sentinel, "utf8")).toBe(
+ "retain this browser fixture change\n",
+ );
+ const interrupted = (await daemon("list_runs")).find(
+ (entry) => entry.id === run.id,
+ )!;
+ expect(interrupted.status).toBe("interrupted");
+ expect(interrupted.worktree).toBe(run.worktree);
+ expect(calls.filter((call) => call.name === "start_run")).toHaveLength(1);
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(
+ ui.getByRole("button", { name: /Waiting for your answer/ }),
+ ).toBeVisible();
+ await expect(ui.locator(".detail .status")).toHaveText("Waiting for you");
+ const resumed = (await daemon("list_runs")).find(
+ (entry) => entry.id === run.id,
+ )!;
+ expect(resumed.worktree).toBe(run.worktree);
+ expect(resumed.threadId).toBe(run.threadId);
+ expect(await readFile(sentinel, "utf8")).toBe(
+ "retain this browser fixture change\n",
+ );
+ await expect(
+ ui.getByText("Answer received. Task complete.", { exact: false }),
+ ).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("");
+ expect(
+ calls
+ .filter((call) => call.name === "send_message")
+ .map((call) => call.arguments),
+ ).toEqual([
+ { id: run.id, text: "Retain this follow-up draft.", expectedTurn: "" },
+ ]);
+ expect(
+ calls
+ .filter((call) => call.name === "start_run")
+ .map((call) => call.arguments),
+ ).toEqual([{ id: run.id }]);
+ expect(forbidden).toEqual([]);
+});
+
+test("MCP bridge drives the assembled daemon through launch, question, answer and follow-up", async ({
+ page,
+}) => {
+ const { ui, calls, forbidden } = await assembledHost(page);
+ await ui.getByRole("button", { name: /Fresh first message fixture/ }).click();
+ expect(calls.map((call) => call.name)).not.toContain("start_run");
+ await expect(ui.getByLabel("Message agent")).not.toHaveValue("");
+ await ui
+ .getByLabel("Message agent")
+ .fill("Inspect the local fixture and ask which scope to investigate.");
+ await ui
+ .getByRole("region", { name: "New agent conversation" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await expect(ui.getByRole("dialog")).toHaveCount(0);
+ await expect(
+ ui.getByText("Answer received. Task complete.", { exact: false }),
+ ).toBeVisible();
+ await ui
+ .getByLabel("Message agent")
+ .fill("Follow-up on the isolated fixture.");
+ await ui.getByRole("button", { name: "Send message" }).click();
+ await expect
+ .poll(() => calls.filter((call) => call.name === "send_message").length)
+ .toBe(1);
+ expect(calls.filter((call) => call.name === "start_run")).toHaveLength(1);
+ expect(forbidden).toEqual([]);
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`focused work queues use GitHub ownership review requests and pending priority in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const snapshot = initial();
+ const make = (
+ number: number,
+ kind: string,
+ priority: number | null,
+ options: Partial = {},
+ ) => {
+ const row = structuredClone(snapshot.inbox[0]);
+ row.item = {
+ ...row.item,
+ id: `owner/repo#${number}`,
+ number,
+ kind,
+ title: `Work ${number}`,
+ url: `https://github.com/owner/repo/${kind === "pr" ? "pull" : "issues"}/${number}`,
+ ...options,
+ };
+ row.priority = priority;
+ return row;
+ };
+ const linked = make(4, "issue", 0);
+ linked.hasPullRequest = true;
+ snapshot.inbox = [
+ snapshot.inbox[0],
+ make(2, "pr", 1),
+ make(3, "pr", 2, { author: "other", reviewRequests: ["alex"] }),
+ linked,
+ make(5, "issue", 0, { state: "closed" }),
+ make(6, "issue", null),
+ make(7, "issue", 0),
+ make(8, "pr", 0, { author: "other", reviewTeams: ["org/core"] }),
+ make(9, "pr", 1, { author: "other" }),
+ make(10, "pr", 0, { state: "closed" }),
+ ];
+ snapshot.viewer!.teams = ["org/core"];
+ const { ui, calls } = await host(page, { snapshot, theme });
+ const rows = ui.getByRole("region", { name: "Items" }).getByRole("button");
+ await expect(ui.getByLabel("Inbox filter")).toHaveValue("open");
+ await expect(rows).toHaveCount(4);
+ await ui.getByLabel("Inbox filter").selectOption("pending");
+ await expect(rows).toHaveText([
+ /Work 7/,
+ /Fix reconnect regression/,
+ /Work 6/,
+ ]);
+ await ui.getByLabel("Inbox filter").selectOption("open");
+ await expect(rows).toHaveCount(4);
+ await ui.getByLabel("Search inbox").fill("Work 4");
+ await ui.getByRole("button", { name: "My PRs", exact: true }).click();
+ await expect(rows).toHaveText([/Work 2/]);
+ await expect(ui.getByLabel("Inbox filter")).toHaveValue("open");
+ await expect(ui.getByLabel("Search inbox")).toHaveValue("");
+ await ui.getByRole("button", { name: "Review PRs", exact: true }).click();
+ await expect(rows).toHaveText([/Work 8/, /Work 3/]);
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await expect(ui.getByLabel("Search inbox")).toHaveValue("Work 4");
+ await expect(ui.getByLabel("Inbox filter")).toHaveValue("open");
+ await expect(rows).toHaveText([/Work 4/]);
+ await expect(ui.locator("body")).toHaveJSProperty(
+ "scrollWidth",
+ await ui.locator("body").evaluate((e) => e.clientWidth),
+ );
+ expect(
+ calls.some((call) =>
+ ["read_item", "start_run", "prepare_launch"].includes(call.name),
+ ),
+ ).toBe(false);
+ await page.screenshot({
+ path: test.info().outputPath(`work-queues-${theme}.png`),
+ });
+ await scanOwnedApp(page, `work-queues-${theme}`);
+ });
+}
+
+test("item opens a prefilled conversation and first-message Start stays explicit", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const { ui, calls, state } = await host(page);
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(
+ ui.getByRole("region", { name: "Agent", exact: true }),
+ ).toBeVisible();
+ await expect(ui.getByRole("region", { name: "Item summary" })).toContainText(
+ "Connections stop after a brief outage.",
+ );
+ await expect(
+ ui.getByRole("region", { name: "Item", exact: true }),
+ ).toHaveCount(0);
+ const chat = ui.getByRole("region", { name: "New agent conversation" });
+ await expect(chat.getByLabel("Message agent")).toHaveValue(
+ "Investigate Fix reconnect regression.\nConnections stop after a brief outage.",
+ );
+ await expect(ui.locator(".item-details")).not.toHaveAttribute("open", "");
+ await expect(
+ ui.getByRole("button", { name: "Open agent", exact: true }),
+ ).toHaveCount(0);
+ await page.screenshot({ path: test.info().outputPath("first-message.png") });
+ await scanOwnedApp(page, "first-message");
+ await showItemDetails(ui);
+ await ui.getByText("Work history", { exact: true }).click();
+ await expect(ui.getByText("No work history.", { exact: true })).toBeVisible();
+ expect(
+ calls.some((call) => ["prepare_launch", "start_run"].includes(call.name)),
+ ).toBe(false);
+ await chat
+ .getByLabel("Message agent")
+ .fill("Explain the retry failure and suggest a safe fix.");
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(chat.getByLabel("Message agent")).toHaveValue(
+ "Explain the retry failure and suggest a safe fix.",
+ );
+ await chat.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect(
+ ui.getByText("Inspecting reconnect logic", { exact: true }),
+ ).toBeVisible();
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.find((c) => c.name === "prepare_launch")!.arguments.prompt).toBe(
+ "Explain the retry failure and suggest a safe fix.",
+ );
+ expect(
+ calls.filter((c) => c.name === "start_run").map((c) => c.arguments.id),
+ ).toEqual([state.runs[0].id]);
+ await expect(ui.getByRole("dialog")).toHaveCount(0);
+});
+
+test("first-message launch uncertainty survives leaving and reopening its item", async ({
+ page,
+}) => {
+ const { ui, calls } = await host(page, {
+ rejectStart: true,
+ failReadRun: true,
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui
+ .getByRole("region", { name: "New agent conversation" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Start status is uncertain",
+ );
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(
+ ui.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeDisabled();
+ await ui
+ .getByRole("button", { name: "Check start status", exact: true })
+ .click();
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+});
+
+test("Messages lists waiting agents and answering removes only its owning request", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ const second = structuredClone(snapshot.inbox[0]);
+ second.item.id = "owner/repo#2";
+ second.item.title = "Second task";
+ snapshot.inbox.push(second);
+ snapshot.runs = [
+ retained(snapshot, "one", "waiting"),
+ { ...retained(snapshot, "two", "waiting"), itemId: second.item.id },
+ ];
+ snapshot.attention = snapshot.runs.map((run, i) => ({
+ id: `question-${i}`,
+ runId: run.id,
+ method: "item/tool/requestUserInput",
+ blocking: true,
+ status: "pending",
+ params: {
+ questions: [
+ {
+ id: "scope",
+ header: "Scope",
+ question: `Scope for task ${i}?`,
+ options: [{ label: "Focused", description: "Small fix" }],
+ },
+ ],
+ },
+ }));
+ const request = snapshot.attention[0];
+ snapshot.runs.push({
+ ...retained(snapshot, "disconnected", "interrupted"),
+ itemId: second.item.id,
+ });
+ snapshot.attention.push(
+ { ...structuredClone(request), id: "nonblocking", blocking: false },
+ { ...structuredClone(request), id: "answered", status: "answered" },
+ { ...structuredClone(request), id: "stale", status: "stale" },
+ { ...structuredClone(request), id: "orphaned", runId: "missing-run" },
+ {
+ ...structuredClone(request),
+ id: "disconnected-request",
+ runId: "disconnected",
+ },
+ {
+ ...structuredClone(request),
+ id: "approval",
+ method: "item/commandExecution/requestApproval",
+ params: {
+ command: "git status",
+ reason: "Inspect changes",
+ availableDecisions: ["decline"],
+ },
+ },
+ );
+ const { ui, calls } = await host(page, { snapshot });
+ await ui.getByRole("button", { name: /^Messages/ }).click();
+ const waiting = ui.getByRole("region", { name: "Waiting for you" });
+ await expect(waiting.getByRole("button")).toHaveCount(4);
+ await expect(
+ ui.getByRole("button", { name: /^Messages\s*4$/ }),
+ ).toBeVisible();
+ await expect(ui.getByText("All agents", { exact: true })).toHaveCount(0);
+ await expect(ui.getByText("Work history", { exact: true })).toHaveCount(0);
+ await waiting.getByRole("button", { name: /Second task/ }).click();
+ await expect(ui.getByRole("dialog")).toContainText(
+ "Second task · Investigation",
+ );
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await expect(
+ ui.getByRole("heading", { name: "Second task", exact: true }),
+ ).toBeVisible();
+ await ui.getByRole("button", { name: "← Messages", exact: true }).click();
+ await expect(waiting.getByRole("button")).toHaveCount(3);
+ await expect(waiting).toContainText("Fix reconnect regression");
+ expect(calls.find((c) => c.name === "answer_request")!.arguments.id).toBe(
+ "question-1",
+ );
+ await waiting.getByRole("button", { name: /Approval needed/ }).click();
+ await expect(ui.getByRole("dialog")).toContainText("git status");
+ await ui.getByRole("button", { name: "Deny", exact: true }).click();
+ await ui.getByRole("button", { name: "← Messages", exact: true }).click();
+ await expect(waiting.getByRole("button")).toHaveCount(2);
+ expect(
+ calls.filter((c) => c.name === "answer_request").at(-1)?.arguments,
+ ).toEqual({ id: "approval", answer: { decision: "decline" } });
+ await waiting.getByRole("button", { name: /Reply needed/ }).click();
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await ui.getByRole("button", { name: "← Messages", exact: true }).click();
+ await expect(waiting.getByRole("button")).toHaveCount(1);
+ await waiting.getByRole("button", { name: /agent continues/ }).click();
+ await ui.getByRole("radio", { name: /Focused/ }).check();
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await ui.getByRole("button", { name: "← Messages", exact: true }).click();
+ await expect(waiting.getByRole("button")).toHaveCount(0);
+ await expect(waiting).toContainText("No messages waiting for you.");
+ await expect(
+ ui.getByRole("button", { name: "Messages", exact: true }),
+ ).toBeVisible();
+});
+
+test("first-message launch reviews changed authority before starting its prepared snapshot", async ({
+ page,
+}) => {
+ const { ui, state, calls } = await host(page);
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const chat = ui.getByRole("region", { name: "New agent conversation" });
+ await expect(chat).toContainText("read-only");
+ state.profiles[0].sandbox = "workspace-write";
+ await chat.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect(chat.getByRole("alert")).toContainText(
+ "Launch settings changed",
+ );
+ await expect(chat).toContainText("workspace-write");
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+ await chat.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect(
+ ui.getByText("Inspecting reconnect logic", { exact: true }),
+ ).toBeVisible();
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(
+ calls.filter((c) => c.name === "start_run").map((c) => c.arguments.id),
+ ).toEqual([state.runs[0].id]);
+});
+
+test("first-message lost Start response locates accepted work without another launch", async ({
+ page,
+}) => {
+ const { ui, calls, state } = await host(page, { loseStartResponse: true });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui
+ .getByRole("region", { name: "New agent conversation" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await expect(
+ ui.getByText("Inspecting reconnect logic", { exact: true }),
+ ).toBeVisible();
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(1);
+ expect(calls.find((c) => c.name === "read_run")!.arguments.id).toBe(
+ state.runs[0].id,
+ );
+});
+
+test("a late first-message Start cannot replace another item's draft", async ({
+ page,
+}) => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => (release = resolve));
+ const snapshot = initial();
+ const second = structuredClone(snapshot.inbox[0]);
+ second.item.id = "owner/repo#2";
+ second.item.title = "Second task";
+ snapshot.inbox.push(second);
+ const { ui, calls } = await host(page, {
+ snapshot,
+ beforeStartResponse: () => {
+ // Keep snapshot reads behind the accepted Start response. Only accepting
+ // its waiting run can make the pending question count as a message.
+ snapshot.runs[0].status = "prepared";
+ return gate;
+ },
+ });
+ try {
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui
+ .getByRole("region", { name: "New agent conversation" })
+ .getByRole("button", { name: "Start agent", exact: true })
+ .click();
+ await expect
+ .poll(() => calls.filter((c) => c.name === "start_run").length)
+ .toBe(1);
+ await ui.getByRole("button", { name: "Issues", exact: true }).click();
+ await ui.getByRole("button", { name: /Second task/ }).click();
+ await ui
+ .getByLabel("Message agent")
+ .fill("Keep the second task's unsubmitted message.");
+ await expect(ui.getByRole("button", { name: "Messages", exact: true })).toBeVisible();
+ release();
+ // This badge is derived from acceptRun's returned run, not a host-call or
+ // response-sent signal. Wait for that React update before checking selection.
+ await expect(ui.getByRole("button", { name: "Messages 1", exact: true })).toBeVisible();
+ await expect(
+ ui.getByRole("heading", { name: "Second task", exact: true }),
+ ).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep the second task's unsubmitted message.",
+ );
+ await expect(
+ ui.getByRole("region", { name: "New agent conversation" }),
+ ).toBeVisible();
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(1);
+ } finally {
+ release();
+ }
+});
+
+test("first-message preparation errors retain the editable draft without starting", async ({
+ page,
+}) => {
+ const { ui, calls } = await host(page, { failPreparation: true });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const chat = ui.getByRole("region", { name: "New agent conversation" });
+ await chat
+ .getByLabel("Message agent")
+ .fill("Keep this first-message draft after failure.");
+ await chat.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect(chat.getByRole("alert")).toContainText(
+ "Fixture preparation denied",
+ );
+ await expect(chat.getByLabel("Message agent")).toHaveValue(
+ "Keep this first-message draft after failure.",
+ );
+ await expect(chat.getByLabel("Message agent")).toBeEditable();
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`own PR review rejection preserves the first message without starting in ${theme}`, async ({ page }) => {
+ const snapshot = initial();
+ snapshot.inbox[0].item.kind = "pr";
+ snapshot.inbox[0].queues = ["mine"];
+ snapshot.profiles[0].id = "review";
+ snapshot.profiles[0].name = "PR review";
+ const { ui, calls } = await host(page, {
+ snapshot,
+ theme,
+ toolError: { name: "prepare_launch", message: "you cannot review your own pull request (owner/repo#1)" },
+ });
+ await ui.getByRole("button", { name: "My PRs", exact: true }).click();
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const chat = ui.getByRole("region", { name: "New agent conversation" });
+ await chat.getByLabel("Agent pipeline").selectOption("review");
+ await chat.getByLabel("Message agent").fill("Review this pull request.");
+ await chat.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect(chat.getByRole("alert")).toContainText("you cannot review your own pull request");
+ await expect(chat.getByLabel("Message agent")).toHaveValue("Review this pull request.");
+ expect(calls.filter(c => c.name === "prepare_launch")).toHaveLength(1);
+ expect(calls.filter(c => c.name === "start_run")).toHaveLength(0);
+ expect(snapshot.runs).toHaveLength(0);
+ });
+}
+
+test("initial bridge failures retry a fresh handshake without reloading or losing state", async ({
+ page,
+}) => {
+ const { ui, calls } = await host(page, { initializeFailures: 3 });
+ await expect(
+ ui.getByRole("status", { name: "Connection status" }),
+ ).toHaveText("Connected");
+ expect(
+ await page.evaluate(
+ () =>
+ (window as unknown as { initializationCount: number })
+ .initializationCount,
+ ),
+ ).toBe(4);
+ expect(calls.filter((c) => c.name === "read_snapshot")).toHaveLength(1);
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+});
+
+test("transient polling outage recovers without a stale error banner or lost first message", async ({
+ page,
+}) => {
+ let disconnected = false;
+ const { ui, calls } = await host(page, {
+ transportFault: (name) => disconnected && name === "read_changes",
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui
+ .getByLabel("Message agent")
+ .fill("Keep this draft through reconnect.");
+ disconnected = true;
+ await expect(
+ ui.getByRole("status", { name: "Connection status" }),
+ ).toHaveText("Reconnecting…", { timeout: 7000 });
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep this draft through reconnect.",
+ );
+ disconnected = false;
+ await expect(
+ ui.getByRole("status", { name: "Connection status" }),
+ ).toHaveText("Connected", { timeout: 7000 });
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep this draft through reconnect.",
+ );
+ expect(
+ calls.some((c) =>
+ ["prepare_launch", "start_run", "send_message"].includes(c.name),
+ ),
+ ).toBe(false);
+});
+
+test("transient item read failure retries and marks only the successfully displayed revision read", async ({
+ page,
+}) => {
+ let itemUnavailable = true;
+ const { ui, calls } = await host(page, {
+ transportFault: (name) => itemUnavailable && name === "read_item",
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(
+ ui.getByRole("status").filter({ hasText: "Retrying item" }),
+ ).toBeVisible();
+ expect(calls.filter((c) => c.name === "item_action")).toHaveLength(0);
+ itemUnavailable = false;
+ await expect(
+ ui.getByRole("heading", { name: "Fix reconnect regression", exact: true }),
+ ).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toBeVisible();
+ expect(calls.filter((c) => c.name === "read_item")).toHaveLength(4);
+ await expect
+ .poll(
+ () =>
+ calls.filter(
+ (c) => c.name === "item_action" && c.arguments.action === "read",
+ ).length,
+ )
+ .toBe(1);
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+});
+
+test("transcript recovery keeps output and draft while removing only its own read error", async ({
+ page,
+}) => {
+ let transcriptUnavailable = false;
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "recover-output")];
+ const { ui } = await host(page, {
+ snapshot,
+ transportFault: (name) =>
+ transcriptUnavailable && name === "read_transcript",
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(
+ ui.getByText("Inspecting reconnect logic", { exact: true }),
+ ).toBeVisible();
+ await ui.getByLabel("Message agent").fill("Keep the follow-up draft.");
+ transcriptUnavailable = true;
await expect(
- ui.getByRole("heading", { name: "Agent 1", exact: true }),
+ ui.getByRole("status").filter({ hasText: "Retrying conversation" }),
+ ).toBeVisible({ timeout: 7000 });
+ await expect(
+ ui.getByText("Inspecting reconnect logic", { exact: true }),
).toBeVisible();
- await expect(ui.locator(".transcript")).toContainText(
- "UI_ANSWER_OK ✓ Answer sent is agent text",
+ transcriptUnavailable = false;
+ await expect(
+ ui.getByRole("status").filter({ hasText: "Retrying conversation" }),
+ ).toHaveCount(0, { timeout: 7000 });
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep the follow-up draft.",
);
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+});
+
+test("mutation connection failure is not replayed and later polling does not dismiss it", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "send-once")];
+ const { ui, calls } = await host(page, {
+ snapshot,
+ transportFault: (name) => name === "send_message",
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
await ui
- .getByRole("textbox", { name: "Message agent" })
- .fill("Keep my draft");
- await ui.getByRole("button").filter({ hasText: "Agent 2 · waiting" }).click();
- await expect(confirmation).toHaveCount(0);
- await ui.getByRole("button").filter({ hasText: "Agent 1 · idle" }).click();
- await expect(confirmation).toHaveText("✓ Answer sent");
- await expect(ui.getByRole("textbox", { name: "Message agent" })).toHaveValue(
- "Keep my draft",
+ .getByLabel("Message agent")
+ .fill("Send only once, retaining the uncertain draft.");
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Connection to Code Factory interrupted",
+ );
+ const polls = calls.filter((c) => c.name === "read_changes").length;
+ await expect
+ .poll(() => calls.filter((c) => c.name === "read_changes").length)
+ .toBeGreaterThan(polls + 1);
+ expect(calls.filter((c) => c.name === "send_message")).toHaveLength(1);
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Send only once, retaining the uncertain draft.",
+ );
+ await expect(ui.getByRole("alert")).toContainText(
+ "Connection to Code Factory interrupted",
+ );
+});
+
+test("read tool errors are not mistaken for retryable transport errors", async ({
+ page,
+}) => {
+ const { ui, calls } = await host(page, {
+ toolError: {
+ name: "read_item",
+ message: "Connection failed while validating this item.",
+ },
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Connection failed while validating this item",
+ );
+ await expect(
+ ui.getByRole("button", { name: "Retry loading item", exact: true }),
+ ).toBeVisible();
+ const polls = calls.filter((c) => c.name === "read_changes").length;
+ await expect
+ .poll(() => calls.filter((c) => c.name === "read_changes").length)
+ .toBeGreaterThan(polls + 1);
+ expect(calls.filter((c) => c.name === "read_item")).toHaveLength(1);
+});
+
+test("exact host-wrapped MCP connection error retries a read without exposing its framework text", async ({
+ page,
+}) => {
+ const failure = {
+ name: "unused",
+ message: "McpServerError: Connection failed.",
+ };
+ const { ui, calls } = await host(page, { toolError: failure });
+ await ui.locator("main").evaluate((root) => {
+ root.dataset.frameworkErrorSeen = "false";
+ new MutationObserver(() => {
+ if (
+ [...root.querySelectorAll('[role="alert"]')].some((e) =>
+ e.textContent?.includes("McpServerError"),
+ )
+ )
+ root.dataset.frameworkErrorSeen = "true";
+ }).observe(root, { subtree: true, childList: true, characterData: true });
+ });
+ failure.name = "read_changes";
+ await expect
+ .poll(() => calls.filter((c) => c.name === "read_changes").length)
+ .toBeGreaterThan(0);
+ failure.name = "unused";
+ await expect
+ .poll(() => calls.filter((c) => c.name === "read_changes").length)
+ .toBeGreaterThan(1);
+ await expect(
+ ui.getByRole("status", { name: "Connection status" }),
+ ).toHaveText("Connected");
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+ await expect(ui.locator("main")).toHaveAttribute(
+ "data-framework-error-seen",
+ "false",
+ );
+ expect(
+ calls.some((c) =>
+ ["start_run", "send_message", "answer_request", "save_settings"].includes(
+ c.name,
+ ),
+ ),
+ ).toBe(false);
+});
+
+test("exact host-wrapped connection failure never replays a mutation", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "wrapped-send")];
+ const { ui, calls } = await host(page, {
+ snapshot,
+ toolError: {
+ name: "send_message",
+ message: "McpServerError: Connection failed.",
+ },
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui.getByLabel("Message agent").fill("Retain this uncertain message.");
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Connection to Code Factory interrupted",
+ );
+ const polls = calls.filter((c) => c.name === "read_changes").length;
+ await expect
+ .poll(() => calls.filter((c) => c.name === "read_changes").length)
+ .toBeGreaterThan(polls + 1);
+ expect(calls.filter((c) => c.name === "send_message")).toHaveLength(1);
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Retain this uncertain message.",
);
- state.cursor++;
- await page.clock.runFor(4000);
- await expect(confirmation).toBeVisible();
- state.cursor++;
- await page.clock.runFor(1200);
- await expect(confirmation).toHaveCount(0);
- expect(calls.filter((c) => c.name === "answer_request")).toHaveLength(1);
- await page.reload();
- await ui.getByRole("button", { name: /^Agents/ }).click();
- await ui.getByRole("button").filter({ hasText: "Agent 1 · idle" }).click();
- await expect(confirmation).toHaveCount(0);
});
-for (const failure of ["failed", "uncertain"] as const) {
- test(`${failure} answers retain error feedback without confirmation or replay`, async ({
+for (const theme of ["light", "dark"] as const) {
+ test(`terminated work retains history and cleanup without allowing continuation in ${theme}`, async ({ page }) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "closed-source", "terminated")];
+ snapshot.runs[0].error = "PR is merged; work terminated. History and workspace are retained.";
+ const { ui, calls } = await host(page, { snapshot, theme });
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText("Stopped");
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("alert")).toContainText("PR is merged; work terminated");
+ await ui.getByLabel("Message agent").fill("Continue anyway");
+ await expect(ui.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
+ await ui.getByText("Details", { exact: true }).click();
+ await expect(ui.getByRole("button", { name: "Clean up worktree", exact: true })).toBeVisible();
+ await expect(ui.getByRole("button", { name: "Interrupt", exact: true })).toHaveCount(0);
+ expect(calls.filter(c => ["start_run", "send_message"].includes(c.name))).toHaveLength(0);
+ });
+ test(`inactive work status and sending a message restore the retained session in ${theme}`, async ({
page,
}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "wake-session", "interrupted")];
+ // A disconnected turn identity must not be sent as an active steering target.
+ snapshot.runs[0].turnId = "old-turn";
+ let release!: () => void;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
const { ui, calls } = await host(page, {
- snapshot: questionSnapshot(),
- answerFailure: failure,
+ snapshot,
+ theme,
+ beforeMessage: () => gate,
});
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Inactive",
+ );
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Inactive",
+ );
+ await expect(ui.getByRole("status", { name: "Run status" })).toHaveCount(0);
+ await expect(ui.getByRole("button", { name: /Resume agent/ })).toHaveCount(
+ 0,
+ );
await ui
- .getByRole("button", { name: "Needs attention 1", exact: true })
- .click();
- await ui.getByRole("textbox", { name: "Which target?" }).fill("One");
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- await expect(ui.getByRole("alert")).toContainText(
- failure === "failed"
- ? "Fixture answer denied"
- : "Answer delivery uncertain",
+ .getByLabel("Message agent")
+ .fill("Continue this issue with my clarification.");
+ const send = ui.getByRole("button", { name: "Send message", exact: true });
+ try {
+ await send.dblclick();
+ await expect(send).toBeDisabled();
+ await expect
+ .poll(() => calls.filter((c) => c.name === "send_message").length)
+ .toBe(1);
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Continue this issue with my clarification.",
+ );
+ release();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("");
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Active",
+ );
+ await page.screenshot({
+ path: test.info().outputPath(`message-wake-${theme}.png`),
+ });
+ expect(
+ calls.filter((c) => c.name === "send_message")[0].arguments,
+ ).toEqual({
+ id: "wake-session",
+ text: "Continue this issue with my clarification.",
+ expectedTurn: "",
+ });
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+ expect(
+ await ui
+ .locator("body")
+ .evaluate((el) => el.scrollWidth <= el.clientWidth),
+ ).toBe(true);
+ } finally {
+ release();
+ }
+ });
+}
+test("saved message clears its draft before agent delivery and shows uncertainty without replay", async ({ page }) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "queued-session", "interrupted")];
+ const transcript: JournalEvent[] = [];
+ const { ui, calls } = await host(page, { snapshot, transcript, queueMessages: true });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui.getByLabel("Message agent").fill("Save my follow-up before contacting the agent.");
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("");
+ await expect(ui.locator(".transcript")).toContainText("Message saved. Awaiting agent delivery.");
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText("Inactive");
+ transcript.push({ seq: snapshot.cursor + 1, kind: "run.output", entity: "queued-session", at: new Date().toISOString(), data: { method: "codefactory/messageDelivery", params: { itemId: "saved-message", state: "uncertain", error: "Agent delivery could not be confirmed. Inspect this session before sending again." } } });
+ await expect(ui.locator(".transcript")).toContainText("Agent delivery could not be confirmed.");
+ await expect(ui.locator(".transcript")).not.toContainText("Awaiting agent delivery");
+ await expect(ui.locator(".transcript article")).toHaveCount(1);
+ expect(calls.filter(c => c.name === "send_message")).toHaveLength(1);
+ expect(calls.filter(c => c.name === "start_run")).toHaveLength(0);
+});
+
+test("failed inactive message keeps its draft and never calls Resume or replays the send", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "failed-session", "failed")];
+ const { ui, calls } = await host(page, {
+ snapshot,
+ toolError: {
+ name: "send_message",
+ message: "Retained worktree is unavailable",
+ },
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await ui.getByLabel("Message agent").fill("Keep this clarification.");
+ await ui.getByRole("button", { name: "Send message", exact: true }).click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Retained worktree is unavailable",
+ );
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep this clarification.",
+ );
+ await ui.getByRole("button", { name: "← Issues", exact: true }).click();
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Keep this clarification.",
+ );
+ expect(calls.filter((c) => c.name === "send_message")).toHaveLength(1);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+});
+
+test("cleaned work keeps its Finished item status while offering an explicit fresh conversation", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "cleaned-history", "cleaned")];
+ const { ui, calls } = await host(page, { snapshot });
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Finished",
+ );
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("status", { name: "Work status" })).toHaveText(
+ "Finished",
+ );
+ await expect(
+ ui.getByRole("region", { name: "New agent conversation" }),
+ ).toBeVisible();
+ await expect(
+ ui.getByRole("button", { name: "Start agent", exact: true }),
+ ).toBeEnabled();
+ expect(
+ calls.filter((c) =>
+ ["start_run", "send_message", "prepare_launch"].includes(c.name),
+ ),
+ ).toHaveLength(0);
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`Working and linked PR monitoring follow server projections in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const state = initial();
+ state.inbox[0].queues = ["issues", "working"];
+ state.inbox[0].work = {
+ tracked: true,
+ complete: false,
+ status: "Waiting for you",
+ runIds: ["older", "newer"],
+ attentionCount: 1,
+ };
+ const pr = {
+ id: "owner/repo#9",
+ title: "Reconnect fix",
+ url: "https://github.com/owner/repo/pull/9",
+ state: "open",
+ draft: false,
+ head: "current",
+ reviewDecision: "approved",
+ ci: "pending",
+ checks: [
+ {
+ name: "build",
+ status: "in_progress",
+ conclusion: "",
+ url: "https://github.com/owner/repo/actions/runs/9",
+ },
+ ],
+ syncedAt: new Date().toISOString(),
+ implementation: true,
+ };
+ state.inbox[0].linkedPullRequests = [pr];
+ state.runs = [
+ retained(state, "older", "waiting"),
+ retained(state, "newer", "idle", "2026-10-22T00:00:00Z"),
+ ];
+ const other = structuredClone(state.inbox[0]);
+ other.item.id = "owner/repo#2";
+ other.item.title = "Not started";
+ other.queues = ["issues"];
+ other.work = {
+ tracked: false,
+ complete: false,
+ status: "Not started",
+ runIds: [],
+ attentionCount: 0,
+ };
+ other.linkedPullRequests = [];
+ state.inbox.push(other);
+ const { ui, calls, openedLinks } = await host(page, {
+ snapshot: state,
+ theme,
+ });
+ await expect(ui.getByLabel("Inbox filter")).toHaveValue("open");
+ await expect(
+ ui.getByRole("region", { name: "Items" }).getByRole("button"),
+ ).toHaveCount(2);
+ await ui.getByRole("button", { name: "Working", exact: true }).click();
+ await expect(
+ ui.getByRole("region", { name: "Items" }).getByRole("button"),
+ ).toHaveCount(1);
+ await expect(ui.getByLabel("Work status")).toHaveText("Waiting for you");
+ await expect(ui.getByLabel("Pull request status")).toContainText(
+ "open · approved · CI pending",
);
- await expect(ui.locator(".answer-confirmation")).toHaveCount(0);
- await page.waitForTimeout(2200);
- expect(calls.filter((c) => c.name === "answer_request")).toHaveLength(1);
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByLabel("Session status")).toContainText("idle");
+ await expect(ui.getByLabel("Work status")).toHaveText("Waiting for you");
+ await showItemDetails(ui);
+ await ui.getByText("Work history", { exact: true }).click();
+ await expect(
+ ui.locator(".work-history").getByRole("button", { name: /waiting$/ }),
+ ).toBeVisible();
+ await expect(
+ ui.locator(".work-history").getByRole("button", { name: /idle$/ }),
+ ).toBeVisible();
+ const linked = ui.getByRole("region", { name: "Linked pull requests" });
+ await expect(linked).toContainText("Reconnect fix");
+ await linked.getByRole("link", { name: "owner/repo#9" }).click();
+ await expect.poll(() => openedLinks).toEqual([pr.url]);
+ await linked.getByText("CI checks", { exact: true }).click();
+ await linked.getByRole("link", { name: "build", exact: true }).click();
+ await expect.poll(() => openedLinks).toEqual([pr.url, pr.checks[0].url]);
+ pr.state = "merged";
+ pr.ci = "success";
+ state.inbox[0].queues = ["issues"];
+ state.inbox[0].work!.complete = true;
+ state.cursor++;
+ await expect(linked).toContainText("merged · approved · CI success");
+ await ui.getByRole("button", { name: "← Working", exact: true }).click();
+ await expect(
+ ui.getByRole("button", { name: /Fix reconnect regression/ }),
+ ).toHaveCount(0);
+ expect(
+ calls.filter((c) => ["prepare_launch", "start_run"].includes(c.name)),
+ ).toHaveLength(0);
+ await scanOwnedApp(page, `working-monitoring-${theme}`);
});
}
-test("successful answer keeps confirmation when the following refresh fails", async ({
+test("first-message pipeline selection preserves an edited prompt and launches the selected preset", async ({
+ page,
+}) => {
+ const state = initial();
+ state.profiles.push({
+ ...state.profiles[0],
+ id: "review",
+ name: "PR review",
+ prompt: "Review {{title}}",
+ });
+ const { ui, calls } = await host(page, { snapshot: state });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByLabel("Agent pipeline")).toHaveValue("implement");
+ await ui
+ .getByLabel("Message agent")
+ .fill("Inspect the regression without publishing.");
+ await ui.getByLabel("Agent pipeline").selectOption("review");
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Inspect the regression without publishing.",
+ );
+ expect(calls.filter((c) => c.name === "prepare_launch")).toHaveLength(0);
+ await ui.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect
+ .poll(() => calls.filter((c) => c.name === "start_run").length)
+ .toBe(1);
+ expect(
+ calls.find((c) => c.name === "prepare_launch")!.arguments.profileId,
+ ).toBe("review");
+});
+
+test("text fallback opens the same session console and preserves its reply draft", async ({
+ page,
+}) => {
+ const state = initial();
+ const run = retained(state, "retained", "interrupted");
+ state.runs = [run];
+ state.attention = [
+ {
+ id: "text-question",
+ runId: run.id,
+ origin: "text",
+ method: "item/tool/requestUserInput",
+ blocking: true,
+ status: "pending",
+ params: {
+ questions: [
+ {
+ id: "reply",
+ header: "Reply",
+ question: "Which target should I use?",
+ isOther: true,
+ },
+ ],
+ },
+ },
+ ];
+ const transcript: JournalEvent[] = [
+ {
+ seq: 1,
+ kind: "run.output",
+ entity: run.id,
+ at: new Date().toISOString(),
+ data: {
+ method: "item/completed",
+ params: {
+ item: {
+ id: "cmd",
+ type: "commandExecution",
+ command: "git status",
+ aggregatedOutput: "fixture command output",
+ },
+ },
+ },
+ },
+ {
+ seq: 2,
+ kind: "run.output",
+ entity: run.id,
+ at: new Date().toISOString(),
+ data: {
+ method: "item/completed",
+ params: {
+ codeFactorySource: "question-recovery",
+ item: {
+ id: "automatic-input",
+ type: "userMessage",
+ content: [
+ {
+ type: "text",
+ text: "Internal question form repair instructions",
+ },
+ ],
+ },
+ },
+ },
+ },
+ ];
+ const { ui, calls } = await host(page, { snapshot: state, transcript });
+ await ui.getByRole("button", { name: /Messages/ }).click();
+ await ui
+ .getByRole("region", { name: "Waiting for you" })
+ .getByRole("button")
+ .click();
+ await ui
+ .getByRole("button", { name: "Open session console", exact: true })
+ .click();
+ await expect(ui.getByLabel("Session console")).toContainText(
+ "fixture command output",
+ );
+ await expect(ui.getByLabel("Session console")).toContainText(
+ "Question recovery",
+ );
+ await expect(ui.getByLabel("Session console")).toContainText(
+ "Internal question form repair instructions",
+ );
+ await ui.getByLabel("Message agent").fill("Console draft");
+ await ui
+ .getByRole("button", { name: "View conversation", exact: true })
+ .click();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("Console draft");
+ await expect(
+ ui.getByText("fixture command output", { exact: false }),
+ ).not.toBeVisible();
+ await expect(
+ ui.getByText("Internal question form repair instructions", { exact: true }),
+ ).not.toBeVisible();
+ await ui.getByRole("button", { name: /Waiting for your answer/ }).click();
+ await ui
+ .getByRole("dialog")
+ .getByLabel("Which target should I use?")
+ .fill("Target one");
+ await ui.getByRole("button", { name: "Answer", exact: true }).click();
+ await expect
+ .poll(() => calls.filter((c) => c.name === "answer_request").length)
+ .toBe(1);
+ expect(
+ calls.filter((c) => c.name === "answer_request").map((c) => c.arguments),
+ ).toEqual([
+ {
+ id: "text-question",
+ answer: { answers: { reply: { answers: ["Target one"] } } },
+ },
+ ]);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+});
+
+test("question recovery progress and keyboard takeover retain the session draft", async ({
page,
}) => {
- const { ui, calls } = await host(page, {
- snapshot: questionSnapshot(),
- failRefreshAfterAnswer: true,
+ const state = initial();
+ const run = retained(state, "repairing", "running");
+ run.turnId = "repair-turn";
+ state.runs = [run];
+ state.questionRecoveries = [
+ { id: "recovery", runId: run.id, stage: "requesting-form", attempts: 2 },
+ ];
+ const { ui, calls } = await host(page, { snapshot: state });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(
+ ui.getByRole("region", { name: "Question recovery" }),
+ ).toContainText("attempt 2 of 2");
+ await ui.getByLabel("Message agent").fill("Retain takeover context");
+ const takeover = ui.getByRole("button", {
+ name: "Take over session",
+ exact: true,
});
- await ui
- .getByRole("button", { name: "Needs attention 1", exact: true })
- .click();
- await ui.getByRole("textbox", { name: "Which target?" }).fill("One");
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- await expect(ui.locator(".answer-confirmation")).toHaveText("✓ Answer sent");
- await expect(ui.getByRole("alert")).toContainText(
- "Response sent, but refresh failed",
+ await takeover.focus();
+ await takeover.press("Enter");
+ await expect(ui.getByLabel("Session console")).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toHaveValue(
+ "Retain takeover context",
);
- await expect(ui.getByRole("dialog")).toHaveCount(0);
- expect(calls.filter((c) => c.name === "answer_request")).toHaveLength(1);
+ await expect
+ .poll(() =>
+ calls.filter((c) => c.name === "interrupt_run").map((c) => c.arguments),
+ )
+ .toEqual([{ id: run.id }]);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
});
-test("approval responses use Response sent and keep other run attention", async ({
+test("an uncertain textual reply can be dismissed without advancing its session", async ({
page,
}) => {
- const snap = questionSnapshot();
- snap.attention[0].method = "item/commandExecution/requestApproval";
- snap.attention[0].params = {
- command: "git status",
- availableDecisions: ["decline"],
- };
- snap.attention.push({
- ...snap.attention[0],
- id: "approval-2",
- runId: "run-2",
- });
- const { ui, state } = await host(page, { snapshot: snap });
+ const state = initial();
+ const run = retained(state, "retained", "idle");
+ state.runs = [run];
+ state.attention = [
+ {
+ id: "uncertain",
+ runId: run.id,
+ origin: "text",
+ method: "item/tool/requestUserInput",
+ blocking: true,
+ status: "pending",
+ params: {
+ questions: [
+ {
+ id: "reply",
+ header: "Reply",
+ question: "The agent's final reply",
+ isOther: true,
+ },
+ ],
+ },
+ },
+ ];
+ const { ui, calls } = await host(page, { snapshot: state });
+ await ui.getByRole("button", { name: /Messages/ }).click();
await ui
- .getByRole("button", { name: "Needs attention 2", exact: true })
+ .getByRole("region", { name: "Waiting for you" })
+ .getByRole("button")
.click();
- await ui.getByRole("button", { name: "decline", exact: true }).click();
- await expect(ui.locator(".answer-confirmation")).toHaveText(
- "✓ Response sent",
- );
- expect(state.attention.map((a) => a.id)).toEqual(["approval-2"]);
+ await ui
+ .getByRole("button", { name: "No reply needed", exact: true })
+ .click();
+ await expect(ui.getByRole("dialog")).toHaveCount(0);
+ expect(
+ calls.filter((c) => c.name === "dismiss_question").map((c) => c.arguments),
+ ).toEqual([{ id: "uncertain" }]);
+ expect(
+ calls.filter((c) =>
+ ["start_run", "send_message", "answer_request"].includes(c.name),
+ ),
+ ).toHaveLength(0);
+ expect(run.status).toBe("idle");
});
for (const theme of ["light", "dark"] as const) {
- test(`recovery icons, fallback controls and keyboard navigation at narrow width in ${theme}`, async ({
+ test(`admin system and repository review policies retain inheritance and extra prompts in ${theme}`, async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
- const snap = questionSnapshot();
- snap.questionRecoveries = [
- {
- id: "recovery-1",
- runId: "run-1",
- stage: "requesting-form",
- attempts: 2,
- },
- ];
- const { ui, state, calls } = await host(page, {
- snapshot: snap,
+ const snapshot = initial();
+ snapshot.profiles.push({
+ ...snapshot.profiles[0],
+ id: "team-review",
+ name: "Team review",
+ });
+ const { ui, calls, state } = await host(page, {
+ snapshot,
+ surface: "settings",
theme,
- recoveryInput: true,
});
- await ui.getByRole("button", { name: /^Agents/ }).click();
+ await ui.getByText("System defaults", { exact: true }).click();
+ await expect(
+ ui.getByLabel("System default for review requests"),
+ ).toHaveValue("false");
+ await ui
+ .getByLabel("System default for review requests")
+ .selectOption("true");
+ await ui.getByLabel("System review pipeline").selectOption("team-review");
+ await ui.getByLabel("Plugin update check interval").selectOption("60");
await ui
- .getByRole("button")
- .filter({ hasText: "Agent 1 · waiting" })
+ .getByRole("button", { name: "Save system defaults", exact: true })
.click();
- await expect(
- ui.getByText("Recovering question · attempt 2 of 2"),
- ).toBeVisible();
- await expect(
- ui
- .locator(".transcript article")
- .filter({ hasText: "Automatic question-only request" })
- .locator("small"),
- ).toHaveText("Question recovery");
- const recovery = ui.locator("div.notice");
- await expect(recovery.locator("svg")).toHaveAttribute(
- "aria-hidden",
- "true",
+ await expect.poll(() => state.config.autoReview).toBe(true);
+ await ui
+ .getByLabel("GitHub repository", { exact: true })
+ .fill("owner/repo");
+ await expect(ui.getByLabel("Repository review requests")).toHaveValue(
+ "inherit",
);
- await expect(recovery.locator("svg path")).toHaveCount(2);
- await expect(
- ui.getByRole("status", { name: "Run status" }).locator("svg circle"),
- ).toHaveCount(1);
- await expect(
- ui.getByRole("textbox", { name: "Message agent" }),
- ).toBeEnabled();
+ await ui.getByLabel("Repository review requests").selectOption("false");
await ui
- .getByRole("textbox", { name: "Message agent" })
- .fill("Retain recovery draft");
- await expect(
- ui.getByRole("button", { name: "Send message", exact: true }),
- ).toBeDisabled();
+ .getByLabel("Repository review pipeline")
+ .selectOption("team-review");
await ui
- .getByRole("button", { name: "Waiting for your answer — Answer" })
- .hover();
- await scanOwnedApp(page, `${theme}-recovery-narrow`);
- await ui.getByRole("button", { name: "Take over", exact: true }).focus();
- await page.keyboard.press("Enter");
- await expect(
- ui.getByRole("button", { name: "Take over", exact: true }),
- ).toHaveCount(0);
+ .getByLabel("Additional repository review prompt")
+ .fill("Review transaction boundaries and preserve compatibility.");
+ await ui
+ .getByRole("button", { name: "Save repository", exact: true })
+ .click();
+ await expect.poll(() => state.repositories[0]?.autoReview).toBe(false);
expect(
- calls.some(
- (c) => c.name === "interrupt_run" && c.arguments.id === "run-1",
- ),
- ).toBe(true);
- state.attention[0].origin = "text";
- state.cursor++;
- await page.waitForTimeout(1200);
- const opener = ui.getByRole("button", {
- name: "Waiting for your answer — Answer",
- });
- await opener.focus();
- await page.keyboard.press("Enter");
- const inspect = ui.getByRole("button", {
- name: "Inspect technical transcript",
+ calls.filter((c) => c.name === "save_repository").at(-1)?.arguments,
+ ).toMatchObject({
+ autoReview: false,
+ reviewProfileId: "team-review",
+ reviewPrompt: "Review transaction boundaries and preserve compatibility.",
});
- await tabTo(page, inspect);
- await page.keyboard.press("Enter");
+ await ui
+ .getByRole("button", { name: "Edit repository owner/repo", exact: true })
+ .click();
await expect(
- ui.getByRole("textbox", { name: "Message agent" }),
- ).toHaveValue("Retain recovery draft");
+ ui.getByLabel("Additional repository review prompt"),
+ ).toHaveValue("Review transaction boundaries and preserve compatibility.");
+ await ui.getByLabel("Repository review requests").selectOption("inherit");
+ await ui
+ .getByRole("button", { name: "Save repository", exact: true })
+ .click();
+ await expect.poll(() => state.repositories[0]?.autoReview).toBeNull();
+ await ui
+ .getByRole("button", { name: "Poll repositories now", exact: true })
+ .click();
+ await expect
+ .poll(() => calls.filter((c) => c.name === "sync_repositories").length)
+ .toBe(1);
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+ await scanOwnedApp(page, `admin-repository-${theme}`);
+ });
+ test(`plugin admin tracks installs, versions and explicit updates in ${theme}`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const { ui, calls } = await host(page, { surface: "settings", theme, memoizeReads: true });
+ await ui.getByText("Plugins & skills", { exact: true }).click();
+ const plugin = ui.locator(".plugin-row").filter({
+ has: ui.getByRole("heading", { name: "review-kit", exact: true }),
+ });
+ await expect(plugin).toContainText("Installed · enabled");
+ await expect(plugin).toContainText("Update available");
+ expect(calls.filter((c) => c.name === "manage_codex_plugins")).toHaveLength(
+ 0,
+ );
+ await ui
+ .getByRole("button", { name: "Check for plugin updates", exact: true })
+ .click();
await expect(
- ui.getByRole("heading", { name: "Technical transcript" }),
- ).toBeFocused();
- await opener.click();
- await scanOwnedApp(page, `${theme}-fallback-narrow`);
+ ui.getByRole("button", { name: "Update review-kit", exact: true }),
+ ).toBeEnabled();
+ expect(
+ calls
+ .filter((c) => c.name === "manage_codex_plugins")
+ .map((c) => c.arguments.action),
+ ).toEqual(["check-updates"]);
await ui
- .getByRole("button", { name: "No reply needed", exact: true })
+ .getByRole("button", { name: "Update review-kit", exact: true })
.focus();
await page.keyboard.press("Enter");
- await expect(ui.getByRole("dialog")).toHaveCount(0);
- expect(calls.filter((c) => c.name === "dismiss_question")).toHaveLength(1);
- expect(
- calls.filter(
- (c) => c.name === "answer_request" || c.name === "send_message",
- ),
- ).toHaveLength(0);
+ await expect(plugin).toContainText("Installed version: 2.0.0");
+ await expect(plugin).toContainText("Matches checked marketplace version");
+ await ui.getByLabel("Include available plugins").check();
+ await ui
+ .getByRole("button", { name: "Install extra-kit", exact: true })
+ .click();
+ const extra = ui.locator(".plugin-row").filter({
+ has: ui.getByRole("heading", { name: "extra-kit", exact: true }),
+ });
+ await expect(extra).toContainText("Installed · enabled");
+ await ui
+ .getByRole("button", { name: "Remove extra-kit", exact: true })
+ .click();
+ await expect(extra).toContainText("Not installed");
+ await ui
+ .getByLabel("Marketplace source", { exact: true })
+ .fill("owner/custom-plugins");
+ await ui.getByLabel("Marketplace Git ref (optional)").fill("stable");
+ await ui
+ .getByRole("button", { name: "Add marketplace", exact: true })
+ .click();
+ await expect
+ .poll(
+ () =>
+ calls.filter((c) => c.name === "manage_codex_plugins").at(-1)
+ ?.arguments,
+ )
+ .toMatchObject({
+ action: "add-marketplace",
+ target: "owner/custom-plugins",
+ ref: "stable",
+ });
+ await expect(
+ ui.getByRole("button", { name: "Discover pipeline skills", exact: true }),
+ ).toBeEnabled();
+ await scanOwnedApp(page, `admin-plugins-${theme}`);
expect(
await ui.locator("body").evaluate((e) => e.scrollWidth <= e.clientWidth),
).toBe(true);
+ const reads = calls.filter((c) => c.name === "read_plugin_admin");
+ expect(reads.length).toBeGreaterThan(2);
+ expect(reads.every((c) => typeof c.arguments.freshness === "string")).toBe(true);
+ expect(new Set(reads.map((c) => c.arguments.freshness)).size).toBe(reads.length);
});
}
-test("text fallback submits its answer through the owning request", async ({
+test("uncertain plugin mutations preserve drafts and never replay automatically", async ({
page,
}) => {
- const snap = questionSnapshot();
- snap.attention[0].origin = "text";
- const { ui, calls } = await host(page, { snapshot: snap });
+ const { ui, calls } = await host(page, {
+ surface: "settings",
+ toolError: {
+ name: "manage_codex_plugins",
+ message: "Delivery could not be confirmed",
+ },
+ });
+ await ui.getByText("Plugins & skills", { exact: true }).click();
await ui
- .getByRole("button", { name: "Needs attention 1", exact: true })
- .click();
+ .getByLabel("Plugin selector", { exact: true })
+ .fill("my-review@my-market");
await ui
- .getByRole("textbox", { name: "Which target?" })
- .fill("My fallback answer");
- await ui.getByRole("button", { name: "Answer", exact: true }).click();
- await expect(ui.locator(".answer-confirmation")).toHaveText("✓ Answer sent");
- expect(
- calls.filter((c) => c.name === "answer_request").map((c) => c.arguments),
- ).toEqual([
- {
- id: "question-1",
- answer: { answers: { scope: { answers: ["My fallback answer"] } } },
- },
- ]);
+ .getByRole("button", { name: "Install selected plugin", exact: true })
+ .click();
+ await expect(ui.getByRole("alert")).toContainText(
+ "Delivery could not be confirmed",
+ );
+ await expect(ui.getByLabel("Plugin selector", { exact: true })).toHaveValue(
+ "my-review@my-market",
+ );
+ const reads = calls.filter((c) => c.name === "read_plugin_admin").length;
+ await ui.getByLabel("Search Codex plugins").fill("review");
+ await expect.poll(() => calls.filter((c) => c.name === "read_plugin_admin").length).toBeGreaterThan(reads);
+ await expect(ui.getByRole("alert")).toContainText("Delivery could not be confirmed");
+ expect(calls.filter((c) => c.name === "manage_codex_plugins")).toHaveLength(
+ 1,
+ );
});
-test("clarification progress has no attention badge and stops when unresolved", async ({
- page,
-}) => {
- const snap = questionSnapshot();
- snap.attention = [];
- snap.runs = [snap.runs[0]];
- snap.runs[0].status = "idle";
- snap.runs[0].turnId = "";
- snap.questionRecoveries = [
- { id: "clarify-1", runId: "run-1", stage: "clarifying", attempts: 1 },
- ];
- const { ui, state } = await host(page, {
- snapshot: snap,
- recoveryInput: true,
- });
- await ui.getByRole("button", { name: /^Agents/ }).click();
- await ui.getByRole("button").filter({ hasText: "Agent 1 · idle" }).click();
- await expect(
- ui.getByText("Clarifying whether input is needed · attempt 1 of 2"),
- ).toBeVisible();
- await expect(
- ui.getByRole("button", { name: "Take over", exact: true }),
- ).toBeVisible();
- await expect(
- ui.getByRole("button", { name: /Waiting for your answer/ }),
- ).toHaveCount(0);
- await expect(
- ui.locator(".badge").filter({ hasText: "Needs attention" }),
- ).toHaveCount(0);
- await expect(ui.locator(".attention b")).toHaveText("0");
- await expect(
- ui.getByRole("button", { name: "Send message", exact: true }),
- ).toBeDisabled();
+test("clarification progress has no attention badge and stops when unresolved", async ({ page }) => {
+ const snapshot = initial();
+ const run = retained(snapshot, "clarifying", "idle");
+ run.turnId = "";
+ snapshot.runs = [run];
+ snapshot.questionRecoveries = [{ id: "clarify-1", runId: run.id, stage: "clarifying", attempts: 1 }];
+ const { ui, state, calls } = await host(page, { snapshot });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const progress = ui.getByRole("region", { name: "Question recovery" });
+ await expect(progress).toContainText("Clarifying whether input is needed · attempt 1 of 2");
+ await expect(ui.getByRole("button", { name: "Take over session", exact: true })).toBeVisible();
+ await expect(ui.getByRole("button", { name: "Messages", exact: true })).toBeVisible();
+ await expect(ui.getByRole("button", { name: /Waiting for your answer/ })).toHaveCount(0);
+ await expect(ui.locator(".badge").filter({ hasText: "Needs attention" })).toHaveCount(0);
+ // Filled draft proves that recovery, rather than empty-input validation, blocks Send.
+ await ui.getByLabel("Message agent").fill("Owner direction");
+ const send = ui.getByRole("button", { name: "Send message", exact: true });
+ await expect(send).toBeDisabled();
state.questionRecoveries![0].stage = "unresolved";
state.questionRecoveries![0].attempts = 2;
state.cursor++;
- await expect(ui.locator(".recovery-progress")).toHaveCount(0);
- await expect(
- ui.getByRole("button", { name: "Take over", exact: true }),
- ).toHaveCount(0);
- await expect(ui.locator(".attention b")).toHaveText("0");
- await ui
- .getByRole("textbox", { name: "Message agent" })
- .fill("Owner direction");
- await expect(
- ui.getByRole("button", { name: "Send message", exact: true }),
- ).toBeEnabled();
+ await expect(progress).toHaveCount(0);
+ await expect(ui.getByRole("button", { name: "Take over session", exact: true })).toHaveCount(0);
+ await expect(ui.getByRole("button", { name: "Messages", exact: true })).toBeVisible();
+ await expect(ui.getByLabel("Message agent")).toHaveValue("Owner direction");
+ await expect(send).toBeEnabled();
+ expect(calls.filter((c) => c.name === "send_message" || c.name === "start_run")).toHaveLength(0);
});
-test("nonblocking recovered text question keeps its continuing label", async ({
- page,
-}) => {
- const snap = questionSnapshot();
- snap.attention = [snap.attention[0]];
- snap.attention[0].origin = "text";
- snap.attention[0].blocking = false;
- const { ui } = await host(page, { snapshot: snap });
- await ui.getByRole("button", { name: /^Agents/ }).click();
- await ui.getByRole("button").filter({ hasText: "Agent 1 · waiting" }).click();
- await expect(
- ui.getByRole("button", { name: "Question · agent continues — Answer" }),
- ).toBeVisible();
- await expect(
- ui.getByRole("button", { name: /Waiting for your answer/ }),
- ).toHaveCount(0);
+test("nonblocking recovered text question keeps its continuing label", async ({ page }) => {
+ const snapshot = initial();
+ const run = retained(snapshot, "nonblocking", "idle");
+ snapshot.runs = [run];
+ snapshot.attention = [{ id: "nonblocking-text", runId: run.id, origin: "text", method: "item/tool/requestUserInput", blocking: false, status: "pending", params: { questions: [{id: "reply", question: "Which target?", isOther: true}] } }];
+ const { ui } = await host(page, { snapshot });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("button", { name: "Question · agent continues — Answer" })).toBeVisible();
+ await expect(ui.getByRole("button", { name: /Waiting for your answer/ })).toHaveCount(0);
});
+for (const theme of ["light", "dark"] as const) {
+ test(`reported issues navigation remains keyboard reachable at narrow width in ${theme}`, async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ const { ui, calls } = await host(page, { theme });
+ const reports = ui.getByRole("button", { name: "Reported issues", exact: true });
+ await tabTo(page, reports);
+ await page.keyboard.press("Enter");
+ await expect(ui.getByRole("heading", { name: "Reported issues", exact: true })).toBeVisible();
+ await expect(reports).toHaveAttribute("aria-current", "page");
+ expect(await ui.locator("body").evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
+ expect(calls.some(c => ["start_run", "refresh_issue_reports"].includes(c.name))).toBe(false);
+ await scanOwnedApp(page, `reports-navigation-${theme}`);
+ });
+}
+
function reportFixture(id = "report-1"): IssueReport {
const at = "2026-10-25T12:00:00Z";
const fresh = { lastSuccess: "", lastAttempt: "", retryAt: "", error: "" };
@@ -1847,7 +4361,7 @@ test("reported issues displays independent closure, prerelease delivery and expl
calls.find((c) => c.name === "resume_issue_report")?.arguments,
).toMatchObject({ id: "report-1", reconciled: true });
await ui.getByRole("button", { name: "Open GitHub issue #17" }).click();
- await expect.poll(() => openLinks).toEqual([{ url: report.issue.url }]);
+ await expect.poll(() => openLinks).toEqual([report.issue.url]);
});
test("reported issues makes detail progress while journal updates continue", async ({
@@ -2243,3 +4757,412 @@ test("reported issues paginates and filters processing component and version", a
ui.getByRole("button", { name: "Load more reports" }),
).toBeEnabled();
});
+test("plugin status polling clears recovered read failures while retaining drafts", async ({ page }) => {
+ let unavailable = true;
+ const { ui, calls } = await host(page, {
+ surface: "settings",
+ transportFault: (name) => unavailable && name === "read_plugin_admin",
+ });
+ await ui.getByText("Plugins & skills", { exact: true }).click();
+ await ui.getByLabel("Plugin selector", { exact: true }).fill("retained@draft");
+ const readError = ui.getByRole("alert").filter({ hasText: "Could not read plugin status" });
+ await expect(readError).toBeVisible();
+ unavailable = false;
+ await expect(readError).toHaveCount(0, { timeout: 15000 });
+ await expect(ui.getByRole("heading", { name: "review-kit", exact: true })).toBeVisible();
+ await expect(ui.getByLabel("Plugin selector", { exact: true })).toHaveValue("retained@draft");
+ expect(calls.filter((c) => c.name === "manage_codex_plugins")).toHaveLength(0);
+});
+
+test("system defaults display and retain a supported nonpreset plugin interval", async ({ page }) => {
+ const snapshot = initial();
+ snapshot.config.pluginCheckMinutes = 30;
+ const { ui, calls, state } = await host(page, { snapshot, surface: "settings" });
+ await ui.getByText("System defaults", { exact: true }).click();
+ const interval = ui.getByLabel("Plugin update check interval");
+ await expect(interval).toHaveValue("30");
+ await expect(interval.locator("option:checked")).toHaveText("Every 30 minutes");
+ await ui.getByLabel("Request budget per hour", { exact: true }).fill("120");
+ await ui.getByRole("button", { name: "Save system defaults", exact: true }).click();
+ await expect.poll(() => state.config.githubRequestsPerHour).toBe(120);
+ expect(calls.find((c) => c.name === "save_settings")?.arguments.pluginCheckMinutes).toBe(30);
+ expect(state.config.pluginCheckMinutes).toBe(30);
+});
+
+test("repository review pipeline overrides system defaults and plugin skills remain selectable", async ({
+ page,
+}) => {
+ const snapshot = initial();
+ snapshot.inbox[0].item.kind = "pr";
+ snapshot.inbox[0].item.reviewRequests = ["alex"];
+ snapshot.profiles.push(
+ { ...snapshot.profiles[0], id: "system-review", name: "System reviewer" },
+ { ...snapshot.profiles[0], id: "repo-review", name: "Repository reviewer" },
+ );
+ snapshot.config.reviewProfileId = "system-review";
+ snapshot.repositories = [
+ {
+ id: "owner/repo",
+ checkout: "",
+ base: "",
+ lastSync: new Date().toISOString(),
+ error: "",
+ reviewProfileId: "repo-review",
+ autoReview: false,
+ },
+ ];
+ snapshot.skills = [
+ {
+ id: "plugin-skill",
+ name: "Plugin review policy",
+ path: "/plugins/review/SKILL.md",
+ content: "Fixture skill",
+ managed: false,
+ },
+ {
+ id: "missing-skill",
+ name: "Missing plugin skill",
+ path: "/missing/SKILL.md",
+ content: "",
+ managed: false,
+ disabled: true,
+ },
+ ];
+ const { ui, calls } = await host(page, { snapshot });
+ await ui.getByRole("button", { name: "Review PRs", exact: true }).click();
+ await ui.getByRole("button", { name: /P1 Fix reconnect regression/ }).click();
+ await expect(ui.getByLabel("Agent pipeline", { exact: true })).toHaveValue(
+ "repo-review",
+ );
+ expect(calls.filter((c) => c.name === "start_run")).toHaveLength(0);
+ await showSettings(page);
+ await ui.getByText("Launch profiles", { exact: true }).click();
+ await ui
+ .getByLabel("Edit profile", { exact: true })
+ .selectOption("repo-review");
+ await ui
+ .getByRole("checkbox", { name: "Plugin review policy", exact: true })
+ .check();
+ await expect(
+ ui.getByRole("checkbox", { name: /Missing plugin skill/ }),
+ ).toBeDisabled();
+ await ui.getByRole("button", { name: "Save profile", exact: true }).click();
+ await expect
+ .poll(
+ () =>
+ calls.filter((c) => c.name === "save_profile").at(-1)?.arguments.skills,
+ )
+ .toEqual(["plugin-skill"]);
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`review queues from multiple repositories load full details from compact snapshots in ${theme}`, async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ const snapshot = initial();
+ snapshot.inbox = ["owner/first", "owner/second"].map((repository, index) => {
+ const entry = structuredClone(snapshot.inbox[0]);
+ entry.item = {
+ ...entry.item,
+ id: `${repository}#${index + 1}`,
+ repository,
+ number: index + 1,
+ kind: "pr",
+ title: `Review from repository ${index + 1}`,
+ author: "other",
+ head: `head-${index + 1}`,
+ reviewRequests: ["alex"],
+ activity: [],
+ };
+ entry.queues = ["review"];
+ return entry;
+ });
+ const { ui, calls } = await host(page, {
+ snapshot,
+ theme,
+ readItem: async (detail) => ({
+ ...detail,
+ item: {
+ ...detail.item,
+ activity: [{ id: "comment:1", kind: "comment", author: "reviewer", body: "Full history is available on demand.", url: "" }],
+ checks: { result: "Full checks retained" },
+ pr: { id:detail.item.id,title:detail.item.title,url:detail.item.url,state:"open",draft:false,head:detail.item.head,reviewDecision:"none",ci:"success",syncedAt:new Date().toISOString(),checks:[{name:"Full current-head check",status:"completed",conclusion:"success",url:""}] },
+ },
+ }),
+ });
+ await ui.getByRole("button", { name: "Review PRs", exact: true }).click();
+ await expect(ui.getByRole("region", { name: "Items" }).getByRole("button")).toHaveCount(2);
+ expect(calls.filter((c) => c.name === "read_item")).toHaveLength(0);
+ await ui.getByRole("button", { name: /Review from repository 2/ }).click();
+ await expect(ui.getByRole("heading", { name: "Review from repository 2" })).toBeVisible();
+ await ui.getByRole("region",{name:"PR monitoring",exact:true}).getByText("CI checks",{exact:true}).click();
+ await expect(ui.getByText(/Full current-head check/)).toBeVisible();
+ await ui.getByText("Item details", { exact: true }).click();
+ await ui.getByText("Activity", { exact: true }).click();
+ await expect(ui.getByText("Full history is available on demand.", { exact: true })).toBeVisible();
+ await ui.getByText("Checks", { exact: true }).click();
+ await expect(ui.getByText(/Full checks retained/)).toBeVisible();
+ expect(calls.filter((c) => ["prepare_launch", "start_run"].includes(c.name))).toHaveLength(0);
+ expect(calls.filter((c) => c.name === "read_item").every((c) => c.arguments.id === "owner/second#2")).toBe(true);
+ });
+}
+
+for (const theme of ["light", "dark"] as const) {
+ test(`GitHub usage settings show cached quota and save budget without polling in ${theme}`, async ({page})=>{
+ await page.setViewportSize({width:390,height:820});
+ const snapshot=initial();snapshot.githubUsage={requestsLastHour:12,budget:600,reserve:100,nextRequest:"",restRemaining:1200,restReset:"",graphqlRemaining:2400,graphqlReset:""};
+ const {ui,calls}=await host(page,{snapshot,theme,surface:"settings"});
+ await ui.getByText("System defaults",{exact:true}).click();
+ const usage=ui.getByRole("group",{name:"GitHub API usage"});
+ await expect(usage).toContainText("12 / 600 requests");
+ await expect(usage).toContainText("REST remaining: 1200");
+ await usage.getByLabel("Request budget per hour",{exact:true}).fill("120");
+ await usage.getByLabel("Keep GitHub requests in reserve",{exact:true}).fill("200");
+ await usage.getByLabel("Review notification poll interval",{exact:true}).selectOption("300");
+ await ui.getByRole("button",{name:"Save system defaults",exact:true}).click();
+ await expect.poll(()=>calls.filter(c=>c.name==="save_settings").length).toBe(1);
+ expect(calls.find(c=>c.name==="save_settings")!.arguments).toMatchObject({githubRequestsPerHour:120,githubReserve:200,githubPollSeconds:300});
+ expect(calls.some(c=>["sync_repositories","read_item","start_run","prepare_launch"].includes(c.name))).toBe(false);
+ await expect(ui.locator("body")).toHaveJSProperty("scrollWidth",await ui.locator("body").evaluate(e=>e.clientWidth));
+ await scanOwnedApp(page,`github-budget-${theme}`);
+ });
+}
+
+test("a deferred detail refresh retains saved information and unread state until a successful retry",async({page})=>{
+ let deferred=true;
+ const {ui,calls}=await host(page,{readItem:async detail=>({...detail,refreshError:deferred?"Could not refresh details; showing saved information. GitHub request reserve reached.":""})});
+ await ui.getByRole("button",{name:/Fix reconnect regression/}).click();
+ await expect(ui.getByRole("alert")).toContainText("showing saved information");
+ await expect(ui.getByRole("heading",{name:"Fix reconnect regression",exact:true})).toBeVisible();
+ expect(calls.filter(c=>c.name==="item_action"&&c.arguments.action==="read")).toHaveLength(0);
+ deferred=false;await ui.getByRole("button",{name:"Retry detail refresh",exact:true}).click();
+ await expect(ui.getByRole("alert")).toHaveCount(0);
+ await expect.poll(()=>calls.filter(c=>c.name==="item_action"&&c.arguments.action==="read").length).toBe(1);
+ expect(calls.some(c=>["prepare_launch","start_run"].includes(c.name))).toBe(false);
+});
+
+test("cooldown without an error keeps a retry action and saved details unread until fresh", async ({ page }) => {
+ let coolingDown = true;
+ const retryAt = "2100-06-01T12:00:00Z";
+ const { ui, calls, state } = await host(page, {
+ readItem: async detail => ({
+ ...detail,
+ observation: {
+ observedAt: "2026-10-24T01:00:00Z",
+ refresh: coolingDown ? "cooldown" : "fresh",
+ ...(coolingDown ? { retryAt } : {}),
+ },
+ }),
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("heading", { name: "Fix reconnect regression", exact: true })).toBeVisible();
+ const deferred = ui.getByRole("status").filter({ hasText: "Refresh deferred; showing saved information." });
+ await expect(deferred).toContainText("Retry after");
+ await expect(deferred).toContainText("2100");
+ const draft = ui.getByRole("textbox", { name: "Message agent", exact: true });
+ await draft.fill("Keep my retry draft.");
+ const retry = ui.getByRole("button", { name: "Retry detail refresh", exact: true });
+ await retry.click();
+ await expect.poll(() => calls.filter(c => c.name === "read_item").length).toBe(2);
+ await expect(deferred).toBeVisible();
+ expect(state.inbox[0].unread).toBe(true);
+ expect(calls.filter(c => c.name === "item_action" && c.arguments.action === "read")).toHaveLength(0);
+ await expect(draft).toHaveValue("Keep my retry draft.");
+ coolingDown = false;
+ await retry.click();
+ await expect(deferred).toHaveCount(0);
+ await expect(retry).toHaveCount(0);
+ await expect.poll(() => calls.filter(c => c.name === "item_action" && c.arguments.action === "read").length).toBe(1);
+ expect(state.inbox[0].unread).toBe(false);
+ await expect(draft).toHaveValue("Keep my retry draft.");
+ expect(calls.filter(c => ["prepare_launch", "prepare_pr_review", "start_run"].includes(c.name))).toHaveLength(0);
+});
+
+test("linked PR changes refresh full selected details without consuming another read or launch",async({page})=>{
+ const snapshot=initial();
+ snapshot.inbox[0].linkedPullRequests=[{id:"owner/repo#2",title:"Linked implementation",url:"https://github.com/owner/repo/pull/2",state:"open",draft:false,head:"abc",reviewDecision:"approved",ci:"success",syncedAt:new Date().toISOString(),checks:[{name:"Initial CI",status:"completed",conclusion:"success",url:""}]}];
+ const {ui,calls,state}=await host(page,{snapshot,readItem:async detail=>({...detail,linkedPullRequests:structuredClone(snapshot.inbox[0].linkedPullRequests)})});
+ await ui.getByRole("button",{name:/Fix reconnect regression/}).click();
+ const linked=ui.getByRole("region",{name:"Linked pull requests",exact:true});
+ await expect(linked).toContainText("success");
+ snapshot.inbox[0].linkedPullRequests![0].ci="failure";
+ snapshot.inbox[0].linkedPullRequests![0].checks=[{name:"Latest CI",status:"completed",conclusion:"failure",url:""}];
+ state.cursor++;
+ await expect(linked).toContainText("failure");
+ await linked.getByText("CI checks",{exact:true}).click();
+ await expect(linked.getByText(/Latest CI/)).toBeVisible();
+ expect(calls.filter(c=>c.name==="item_action"&&c.arguments.action==="read")).toHaveLength(1);
+ expect(calls.some(c=>["prepare_launch","start_run"].includes(c.name))).toBe(false);
+});
+
+test("Markdown issue headings do not replace the useful conversation summary", async ({ page }) => {
+ const snapshot = initial();
+ const paragraph = "The PR capacity helper must clamp exhausted capacity to zero.";
+ snapshot.inbox[0].item.body = `## Issue\n\n### Context\n\n${paragraph}\n\n## Scope\n\nNo persistence changes.`;
+ const { ui, calls } = await host(page, { snapshot });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const summary = ui.getByRole("region", { name: "Item summary", exact: true });
+ await expect(summary).toHaveText(paragraph);
+ await showItemDetails(ui);
+ await ui.getByText("Full description", { exact: true }).click();
+ await expect(ui.getByText("Full description", { exact: true }).locator("..")).toContainText("## Issue");
+ await expect(ui.getByText("Full description", { exact: true }).locator("..")).toContainText("No persistence changes.");
+ expect(calls.some((c) => ["prepare_launch", "start_run"].includes(c.name))).toBe(false);
+});
+
+
+test("fresh read markers observe external changes after a host memoizes an empty journal page", async ({ page }) => {
+ const { ui, calls, state } = await host(page, { memoizeReads: true });
+ await expect.poll(() => calls.filter(c => c.name === "read_changes").length).toBeGreaterThan(0);
+ state.inbox[0].item.title = "Externally updated pilot review";
+ state.cursor++;
+ await expect(ui.getByRole("button", { name: /Externally updated pilot review/ })).toBeVisible();
+ state.inbox[0].item.title = "Second external review update";
+ state.cursor++;
+ await expect(ui.getByRole("button", { name: /Second external review update/ })).toBeVisible();
+ const reads = calls.filter(c => ["read_changes", "read_snapshot"].includes(c.name));
+ expect(new Set(reads.map(c => c.arguments.freshness)).size).toBe(reads.length);
+ expect(reads.every(c => typeof c.arguments.freshness === "string" && c.arguments.freshness.length > 0)).toBe(true);
+ expect(calls.some(c => ["prepare_launch", "start_run", "send_message"].includes(c.name))).toBe(false);
+});
+
+
+test("explicit full-access launch shows never approvals and authorized publishing", async ({ page }) => {
+ const snapshot = initial();
+ snapshot.profiles[0].sandbox = "danger-full-access";
+ snapshot.profiles[0].approval = "never";
+ snapshot.profiles[0].publishing = "authorized";
+ const { ui, calls, state } = await host(page, { snapshot });
+ await openItem(ui, /Fix reconnect regression/);
+ await ui.getByRole("button", { name: "New run", exact: true }).click();
+ const dialog = ui.getByRole("dialog", { name: "Launch agent" });
+ await expect(dialog).toContainText("danger-full-access · never");
+ await expect(dialog).toContainText("Authorized");
+ expect(calls.filter(c => c.name === "start_run")).toHaveLength(0);
+ await dialog.getByRole("button", { name: "Start agent", exact: true }).click();
+ await expect.poll(() => calls.filter(c => c.name === "start_run").length).toBe(1);
+ expect(state.runs[0].profile).toMatchObject({ sandbox: "danger-full-access", approval: "never", publishing: "authorized" });
+ await showSettings(page);
+ await expect(ui.getByText(/Automatic reviews use the selected pipeline.s sandbox and publishing/)).toBeVisible();
+});
+
+for (const theme of ["light", "dark"] as const) {
+ test(`posted reviews show verified verdicts findings and refresh after completion in ${theme}`, async ({page}) => {
+ await page.setViewportSize({width:390,height:820});
+ const snapshot=initial();const entry=snapshot.inbox[0];
+ Object.assign(entry.item,{kind:"pr",head:"current-head",author:"someone",reviewRequests:["alex"]});entry.queues=["review"];
+ let response: PRReviews={itemId:entry.item.id,reviews:[],syncedAt:"0001-01-01T00:00:00Z",lastAttempt:"0001-01-01T00:00:00Z",error:"GitHub temporarily unavailable"};
+ const {ui,calls,state,openedLinks}=await host(page,{snapshot,theme,readReviews:async()=>structuredClone(response)});
+ await ui.getByRole("button",{name:"Review PRs",exact:true}).click();
+ await ui.getByRole("button",{name:/Fix reconnect regression/}).click();
+ const reviews=ui.getByRole("region",{name:"Posted reviews",exact:true});
+ await expect(reviews.getByText("Publication has not been verified.",{exact:true})).toBeVisible();
+ await expect(reviews.getByText("GitHub temporarily unavailable",{exact:true})).toBeVisible();
+ await expect(reviews.getByText(/No posted reviews found/)).toHaveCount(0);
+ response={itemId:entry.item.id,syncedAt:new Date().toISOString(),lastAttempt:new Date().toISOString(),reviews:[
+ {id:"9",url:"https://github.com/owner/repo/pull/1#pullrequestreview-9",author:"alex",state:"COMMENTED",head:"current-head",submittedAt:"2026-10-24T03:00:00Z",body:"Concrete verified review",comments:[{id:"10",url:"https://github.com/owner/repo/pull/1#discussion_r10",author:"alex",path:"lib/reactor.py",line:79,body:"Actionable P2 failure"}]},
+ {id:"8",url:"https://github.com/owner/repo/pull/1#pullrequestreview-8",author:"other",state:"APPROVED",head:"earlier-head",submittedAt:"2026-10-23T03:00:00Z",body:"Earlier approval",comments:[]}
+ ]};
+ state.reviewVersions={[entry.item.id]:response.lastAttempt};state.cursor++;
+ await expect(reviews.getByText("Concrete verified review",{exact:true})).toBeVisible();
+ await expect(reviews.getByText("Actionable P2 failure",{exact:true})).toBeVisible();
+ await expect(reviews).toContainText("COMMENTED");await expect(reviews).toContainText("APPROVED");
+ await expect(reviews).toContainText("Current head");await expect(reviews).toContainText("Earlier head");
+ await reviews.getByRole("link",{name:"Review by alex",exact:true}).click();
+ await reviews.getByRole("link",{name:"lib/reactor.py:79",exact:true}).click();
+ await expect.poll(()=>openedLinks).toEqual([response.reviews[0].url,response.reviews[0].comments[0].url]);
+ response={...response,error:"Could not refresh posted reviews; showing saved information."};
+ await reviews.getByRole("button",{name:"Refresh reviews",exact:true}).click();
+ await expect(reviews.getByRole("alert")).toContainText("showing saved information");
+ await expect(reviews.getByText("Concrete verified review",{exact:true})).toBeVisible();
+ expect(calls.filter(c=>["start_run","prepare_launch","prepare_pr_review"].includes(c.name))).toHaveLength(0);
+ expect(calls.filter(c=>c.name==="read_pr_reviews").every(c=>c.arguments.id===entry.item.id)).toBe(true);
+ await scanOwnedApp(page,`${theme}-posted-reviews`);
+ });
+}
+
+for (const theme of ["light", "dark"] as const) {
+ test(`saved details and receipts render during background refresh in ${theme}`, async ({page}) => {
+ const snapshot=initial();const entry=snapshot.inbox[0];
+ Object.assign(entry.item,{kind:"pr",head:"current-head",author:"someone",reviewRequests:["alex"]});entry.queues=["review"];
+ let pending=true;
+ const {ui,calls}=await host(page,{snapshot,theme,
+ readItem:async(detail)=>({...detail,observation:{observedAt:"2026-10-24T01:00:00Z",refresh:pending?"pending":"fresh"}}),
+ readReviews:async()=>({itemId:entry.item.id,refresh:pending?"pending":"fresh",syncedAt:"2026-10-24T01:00:00Z",lastAttempt:"2026-10-24T01:00:00Z",reviews:[{id:"saved",url:"https://github.com/owner/repo/pull/1#pullrequestreview-1",author:"alex",state:"COMMENTED",head:"current-head",submittedAt:"2026-10-24T01:00:00Z",body:"Saved receipt available immediately",comments:[]}]})
+ });
+ await ui.getByRole("button",{name:"Review PRs",exact:true}).click();
+ await ui.getByRole("button",{name:/Fix reconnect regression/}).click();
+ await expect(ui.getByText("Refreshing saved details…",{exact:true})).toBeVisible();
+ await expect(ui.getByText("Refreshing saved reviews…",{exact:true})).toBeVisible();
+ await expect(ui.getByText("Saved receipt available immediately",{exact:true})).toBeVisible();
+ expect(calls.filter(c=>c.name==="item_action"&&c.arguments.action==="read")).toHaveLength(0);
+ pending=false;
+ await expect(ui.getByText("Refreshing saved details…",{exact:true})).toHaveCount(0);
+ await expect(ui.getByText("Refreshing saved reviews…",{exact:true})).toHaveCount(0);
+ await expect.poll(()=>calls.filter(c=>c.name==="item_action"&&c.arguments.action==="read").length).toBe(1);
+ expect(calls.filter(c=>["start_run","prepare_launch","prepare_pr_review"].includes(c.name))).toHaveLength(0);
+ expect(new Set(calls.filter(c=>c.name==="read_pr_reviews").map(c=>c.arguments.freshness)).size).toBeGreaterThan(1);
+ await scanOwnedApp(page,`${theme}-saved-observations`);
+ });
+}
+
+for (const refresh of ["paused", "cooldown", "unavailable"] as const) {
+ test(`saved unread details remain unread when refresh is ${refresh} without an error`, async ({ page }) => {
+ const { ui, calls, state } = await host(page, {
+ readItem: async (detail) => ({ ...detail, observation: { observedAt: "2026-10-24T01:00:00Z", refresh } }),
+ });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ await expect(ui.getByRole("heading", { name: "Fix reconnect regression", exact: true })).toBeVisible();
+ await ui.getByLabel("Agent pipeline", { exact: true }).selectOption("implement");
+ await ui.getByRole("textbox", { name: "Message agent", exact: true }).fill("Keep the saved conversation draft.");
+ expect(state.inbox[0].unread).toBe(true);
+ expect(calls.filter((c) => c.name === "item_action" && c.arguments.action === "read")).toHaveLength(0);
+ expect(calls.filter((c) => ["prepare_launch", "start_run"].includes(c.name))).toHaveLength(0);
+ });
+}
+
+test("chunked owner and agent completions remain normal conversation across journal pages", async ({ page }) => {
+ const snapshot = initial();
+ snapshot.runs = [retained(snapshot, "chunked-conversation")];
+ const transcript: JournalEvent[] = [];
+ const event = (method: string, params: Record) => transcript.push({
+ seq: transcript.length + 1, kind: "run.output", entity: "chunked-conversation",
+ at: "2026-10-24T01:00:00Z", data: { method, params },
+ });
+ for (let n = 0; n < 99; n++) event("ignored", {});
+ const chunk = (params: Record) => {
+ const bytes = Buffer.from(JSON.stringify(params));
+ for (let start = 0; start < bytes.length;) {
+ let end = Math.min(start + (32 << 10), bytes.length);
+ while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
+ event("item/completed/chunk", { delta: bytes.subarray(start, end).toString() });
+ start = end;
+ }
+ };
+ const owner = `Owner message ${'é {quoted "value"} \\ '.repeat(2200)}owner end`;
+ const agent = `Agent reply ${'🧭 [nested] "reply" \\ '.repeat(2200)}agent end`;
+ chunk({ item: { id: "owner-large", type: "userMessage", content: [{ text: owner }] } });
+ event("item/agentMessage/delta", { itemId: "agent-large", delta: "Temporary streamed prefix" });
+ chunk({ item: { id: "agent-large", type: "agentMessage", text: agent } });
+ chunk({ item: { id: "owner-next", type: "userMessage", content: [{ text: "Adjacent completed owner message" }] } });
+ event("item/completed/chunk", { delta: '{"item":{"type":"agentMessage","text":"partial secret' });
+ event("item/completed", { item: { id: "command", type: "commandExecution", command: "fixture command", aggregatedOutput: "Technical output only" } });
+ event("item/completed/chunk", { delta: "malformed provider JSON" });
+ const { ui, calls } = await host(page, { snapshot, transcript });
+ await ui.getByRole("button", { name: /Fix reconnect regression/ }).click();
+ const conversation = ui.locator('[aria-label="Conversation"]');
+ await expect(conversation.getByText(owner, { exact: true })).toBeVisible();
+ await expect(conversation.getByText(agent, { exact: true })).toBeVisible();
+ await expect(conversation.getByText("Adjacent completed owner message", { exact: true })).toBeVisible();
+ await expect(conversation.locator("article").filter({ has: ui.getByText(owner, { exact: true }) }).locator("small")).toHaveText("You");
+ await expect(conversation.locator("article").filter({ has: ui.getByText(agent, { exact: true }) }).locator("small")).toHaveText("Agent");
+ await expect(conversation.getByText("Temporary streamed prefix", { exact: true })).toHaveCount(0);
+ await expect(conversation).not.toContainText("partial secret");
+ await expect(conversation).not.toContainText("Technical output only");
+ await expect(conversation).not.toContainText("malformed provider JSON");
+ const details = ui.locator("details").filter({ has: ui.locator("summary").getByText("Details", { exact: true }) });
+ await expect(details).not.toHaveAttribute("open");
+ await details.locator("summary").click();
+ await expect(ui.getByText(/partial secret/)).toBeVisible();
+ await expect(ui.getByText("malformed provider JSON", { exact: true })).toBeVisible();
+ expect(calls.filter((c) => c.name === "read_transcript").some((c) => c.arguments.after === 100)).toBe(true);
+});