fix(webui): preserve desktop restart and replay state

This commit is contained in:
chengyongru
2026-06-06 00:19:31 +08:00
committed by Xubin Ren
parent aee793ee19
commit dae86edba4
7 changed files with 239 additions and 93 deletions
+35 -2
View File
@@ -14,6 +14,7 @@ type WsMessageFrame = {
event?: unknown; event?: unknown;
kind?: unknown; kind?: unknown;
source?: NotificationSource; source?: NotificationSource;
stream_id?: unknown;
text?: unknown; text?: unknown;
}; };
@@ -25,15 +26,17 @@ const MAX_NOTIFICATION_BODY_LENGTH = 180;
const MAX_NOTIFICATION_TITLE_LENGTH = 80; const MAX_NOTIFICATION_TITLE_LENGTH = 80;
let unreadNotificationCount = 0; let unreadNotificationCount = 0;
const streamTextBuffers = new Map<string, string>();
export function handleDesktopNotificationFrame( export function handleDesktopNotificationFrame(
data: string, data: string,
options: DesktopNotifierOptions, options: DesktopNotifierOptions,
): void { ): void {
const frame = parseWsMessageFrame(data); const frame = parseWsMessageFrame(data);
if (!frame || !isAssistantNotificationFrame(frame)) return; const notificationFrame = frame ? notificationFrameFromWsFrame(frame) : null;
if (!notificationFrame) return;
if (!shouldNotify(options.getWindow())) return; if (!shouldNotify(options.getWindow())) return;
showDesktopNotification(frame, options); showDesktopNotification(notificationFrame, options);
} }
export function clearDesktopNotificationBadge(): void { export function clearDesktopNotificationBadge(): void {
@@ -67,6 +70,36 @@ function isAssistantNotificationFrame(frame: WsMessageFrame): frame is WsMessage
); );
} }
function notificationFrameFromWsFrame(frame: WsMessageFrame): WsMessageFrame & {
chat_id: string;
text: string;
} | null {
if (isAssistantNotificationFrame(frame)) return frame;
if (frame.event === "delta") {
if (typeof frame.chat_id === "string" && typeof frame.text === "string") {
const key = streamNotificationKey(frame);
streamTextBuffers.set(key, `${streamTextBuffers.get(key) ?? ""}${frame.text}`);
}
return null;
}
if (frame.event === "stream_end" && typeof frame.chat_id === "string") {
const key = streamNotificationKey(frame);
const text = typeof frame.text === "string"
? frame.text
: streamTextBuffers.get(key) ?? "";
streamTextBuffers.delete(key);
return text.trim().length > 0
? { ...frame, chat_id: frame.chat_id, text }
: null;
}
return null;
}
function streamNotificationKey(frame: WsMessageFrame): string {
const streamId = typeof frame.stream_id === "string" ? frame.stream_id : "";
return `${frame.chat_id ?? ""}\u0000${streamId}`;
}
function shouldNotify(win: BrowserWindow | null): boolean { function shouldNotify(win: BrowserWindow | null): boolean {
if (!Notification.isSupported()) return false; if (!Notification.isSupported()) return false;
if (!win || win.isDestroyed()) return false; if (!win || win.isDestroyed()) return false;
+16 -10
View File
@@ -860,12 +860,13 @@ class WebSocketChannel(BaseChannel):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id) self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else: else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id) self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
return
if msg.metadata.get("_goal_state_sync"): if msg.metadata.get("_goal_state_sync"):
if conns:
blob = msg.metadata.get("goal_state") blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False}) await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return return
if msg.metadata.get("_goal_status"): if msg.metadata.get("_goal_status"):
if conns:
status = msg.metadata.get("goal_status") status = msg.metadata.get("goal_status")
if status in ("running", "idle"): if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at")) started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
@@ -889,6 +890,7 @@ class WebSocketChannel(BaseChannel):
) )
return return
if msg.metadata.get("_session_updated"): if msg.metadata.get("_session_updated"):
if conns:
scope = msg.metadata.get("_session_update_scope") scope = msg.metadata.get("_session_update_scope")
await self.send_session_updated( await self.send_session_updated(
msg.chat_id, msg.chat_id,
@@ -946,6 +948,8 @@ class WebSocketChannel(BaseChannel):
transcript_overrides={"text": text}, transcript_overrides={"text": text},
) )
raw = json.dumps(payload, ensure_ascii=False) raw = json.dumps(payload, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" ") await self._safe_send_to(connection, raw, label=" ")
@@ -961,7 +965,7 @@ class WebSocketChannel(BaseChannel):
until the matching ``reasoning_end`` arrives. until the matching ``reasoning_end`` arrives.
""" """
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
if not conns or not delta: if not delta:
return return
meta = metadata or {} meta = metadata or {}
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -979,6 +983,8 @@ class WebSocketChannel(BaseChannel):
phase="reasoning", phase="reasoning",
) )
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning ") await self._safe_send_to(connection, raw, label=" reasoning ")
@@ -989,8 +995,6 @@ class WebSocketChannel(BaseChannel):
) -> None: ) -> None:
"""Close the current reasoning stream segment for in-place renderers.""" """Close the current reasoning stream segment for in-place renderers."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
if not conns:
return
meta = metadata or {} meta = metadata or {}
body: dict[str, Any] = { body: dict[str, Any] = {
"event": "reasoning_end", "event": "reasoning_end",
@@ -1006,6 +1010,8 @@ class WebSocketChannel(BaseChannel):
phase="reasoning", phase="reasoning",
) )
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning_end ") await self._safe_send_to(connection, raw, label=" reasoning_end ")
@@ -1016,8 +1022,6 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
if not conns:
return
payload: dict[str, Any] = { payload: dict[str, Any] = {
"event": "file_edit", "event": "file_edit",
"chat_id": chat_id, "chat_id": chat_id,
@@ -1030,6 +1034,8 @@ class WebSocketChannel(BaseChannel):
phase="activity", phase="activity",
) )
raw = json.dumps(payload, ensure_ascii=False) raw = json.dumps(payload, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" file_edit ") await self._safe_send_to(connection, raw, label=" file_edit ")
@@ -1040,8 +1046,6 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
if not conns:
return
meta = metadata or {} meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or "")) stream_key = (chat_id, str(meta.get("_stream_id") or ""))
if meta.get("_stream_end"): if meta.get("_stream_end"):
@@ -1069,6 +1073,8 @@ class WebSocketChannel(BaseChannel):
phase="answer", phase="answer",
) )
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ") await self._safe_send_to(connection, raw, label=" stream ")
@@ -1082,8 +1088,6 @@ class WebSocketChannel(BaseChannel):
) -> None: ) -> None:
"""Signal that the agent has fully finished processing the current turn.""" """Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id} body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
if latency_ms is not None: if latency_ms is not None:
body["latency_ms"] = int(latency_ms) body["latency_ms"] = int(latency_ms)
@@ -1096,6 +1100,8 @@ class WebSocketChannel(BaseChannel):
phase="complete", phase="complete",
) )
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns:
return
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ") await self._safe_send_to(connection, raw, label=" turn_end ")
+2
View File
@@ -90,6 +90,8 @@ def _clean_preview_path(raw_path: str | None) -> str:
if value.startswith("file://"): if value.startswith("file://"):
parsed = urlparse(value) parsed = urlparse(value)
value = unquote(parsed.path) value = unquote(parsed.path)
if re.match(r"^/[A-Za-z]:[\\/]", value):
value = value[1:]
else: else:
value = unquote(value) value = unquote(value)
value = value.split("?", 1)[0].split("#", 1)[0].strip() value = value.split("?", 1)[0].split("#", 1)[0].strip()
+40
View File
@@ -1166,6 +1166,37 @@ async def test_send_reasoning_without_subscribers_is_noop() -> None:
assert channel._subs == {} assert channel._subs == {}
@pytest.mark.asyncio
async def test_stream_transcript_persists_without_subscribers() -> None:
from nanobot.webui.transcript import build_webui_thread_response, read_transcript_lines
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
gateway=_basic_handler(bus),
)
await channel.send_delta("chat-1", "hello", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", " world", {"_stream_delta": True, "_stream_id": "s1"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "s1"})
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 42},
))
assert channel._subs == {}
lines = read_transcript_lines("websocket:chat-1")
assert [line["event"] for line in lines] == ["delta", "delta", "stream_end", "turn_end"]
body = build_webui_thread_response("websocket:chat-1")
assert body is not None
assert body["messages"][-1]["role"] == "assistant"
assert body["messages"][-1]["content"] == "hello world"
assert body["messages"][-1]["latencyMs"] == 42
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_turn_end_emits_turn_end_event() -> None: async def test_send_turn_end_emits_turn_end_event() -> None:
bus = MagicMock() bus = MagicMock()
@@ -2600,6 +2631,15 @@ def test_handle_file_preview_returns_workspace_file(tmp_path) -> None:
assert body["truncated"] is False assert body["truncated"] is False
def test_file_preview_normalizes_windows_file_url() -> None:
from nanobot.webui.file_preview import _clean_preview_path
assert _clean_preview_path("file:///C:/Users/me/project/app.py") == (
"C:/Users/me/project/app.py"
)
assert _clean_preview_path("file:///tmp/project/app.py") == "/tmp/project/app.py"
def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None: def test_handle_file_preview_rejects_paths_outside_workspace(tmp_path) -> None:
from urllib.parse import quote from urllib.parse import quote
+50 -56
View File
@@ -37,6 +37,7 @@ import { Input } from "@/components/ui/input";
import { fetchSettings, fetchWorkspaces } from "@/lib/api"; import { fetchSettings, fetchWorkspaces } from "@/lib/api";
import { import {
createRuntimeHost, createRuntimeHost,
getHostApi,
toRuntimeSurface, toRuntimeSurface,
} from "@/lib/runtime"; } from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace"; import { projectNameFromPath } from "@/lib/workspace";
@@ -341,6 +342,36 @@ export default function App() {
const [state, setState] = useState<BootState>({ status: "loading" }); const [state, setState] = useState<BootState>({ status: "loading" });
const bootstrapSecretRef = useRef(""); const bootstrapSecretRef = useRef("");
const refreshReadyClient = useCallback(
async (client: NanobotClient, fallbackSurface: RuntimeSurface) => {
const boot = await fetchBootstrap("", bootstrapSecretRef.current);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
return { token: boot.token, url };
},
[],
);
const bootstrapWithSecret = useCallback( const bootstrapWithSecret = useCallback(
(secret: string) => { (secret: string) => {
let cancelled = false; let cancelled = false;
@@ -358,37 +389,8 @@ export default function App() {
socketFactory: runtimeHost.socketFactory, socketFactory: runtimeHost.socketFactory,
onReauth: async () => { onReauth: async () => {
try { try {
const refreshed = await fetchBootstrap("", bootstrapSecretRef.current); const refreshed = await refreshReadyClient(client, runtimeSurface);
const refreshedUrl = deriveWsUrl( return refreshed.url;
refreshed.ws_path,
refreshed.token,
refreshed.ws_url,
);
const refreshedSurface = refreshed.runtime_surface
? toRuntimeSurface(refreshed.runtime_surface)
: runtimeSurface;
const refreshedHost = createRuntimeHost(
refreshedSurface,
refreshed.runtime_capabilities,
);
const tokenExpiresAt = bootstrapTokenExpiresAt(refreshed.expires_in);
if (refreshedHost.socketFactory) {
client.updateUrl(refreshedUrl, refreshedHost.socketFactory);
} else {
client.updateUrl(refreshedUrl);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: refreshed.token,
tokenExpiresAt,
modelName: refreshed.model_name ?? current.modelName,
runtimeSurface: refreshedSurface,
}
: current,
);
return refreshedUrl;
} catch { } catch {
return null; return null;
} }
@@ -418,7 +420,7 @@ export default function App() {
cancelled = true; cancelled = true;
}; };
}, },
[], [refreshReadyClient],
); );
useEffect(() => { useEffect(() => {
@@ -426,29 +428,7 @@ export default function App() {
const client = state.client; const client = state.client;
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
try { try {
const boot = await fetchBootstrap("", bootstrapSecretRef.current); await refreshReadyClient(client, state.runtimeSurface);
const url = deriveWsUrl(boot.ws_path, boot.token, boot.ws_url);
const runtimeSurface = boot.runtime_surface
? toRuntimeSurface(boot.runtime_surface)
: state.runtimeSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in);
if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory);
} else {
client.updateUrl(url);
}
setState((current) =>
current.status === "ready" && current.client === client
? {
...current,
token: boot.token,
tokenExpiresAt,
modelName: boot.model_name ?? current.modelName,
runtimeSurface,
}
: current,
);
} catch (e) { } catch (e) {
const msg = (e as Error).message; const msg = (e as Error).message;
if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) { if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) {
@@ -457,7 +437,7 @@ export default function App() {
} }
}, tokenRefreshDelayMs(state.tokenExpiresAt)); }, tokenRefreshDelayMs(state.tokenExpiresAt));
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [state]); }, [refreshReadyClient, state]);
useEffect(() => { useEffect(() => {
const saved = loadSavedSecret(); const saved = loadSavedSecret();
@@ -515,6 +495,16 @@ export default function App() {
setState({ status: "auth" }); setState({ status: "auth" });
}; };
const handleNativeEngineRestart = async (): Promise<string> => {
const hostApi = getHostApi();
if (!hostApi?.restartEngine) {
throw new Error("native engine restart is unavailable");
}
await hostApi.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token;
};
return ( return (
<ClientProvider <ClientProvider
client={state.client} client={state.client}
@@ -525,6 +515,7 @@ export default function App() {
runtimeSurface={state.runtimeSurface} runtimeSurface={state.runtimeSurface}
onModelNameChange={handleModelNameChange} onModelNameChange={handleModelNameChange}
onLogout={handleLogout} onLogout={handleLogout}
onNativeEngineRestart={handleNativeEngineRestart}
/> />
</ClientProvider> </ClientProvider>
); );
@@ -534,10 +525,12 @@ function Shell({
runtimeSurface, runtimeSurface,
onModelNameChange, onModelNameChange,
onLogout, onLogout,
onNativeEngineRestart,
}: { }: {
runtimeSurface: RuntimeSurface; runtimeSurface: RuntimeSurface;
onModelNameChange: (modelName: string | null) => void; onModelNameChange: (modelName: string | null) => void;
onLogout: () => void; onLogout: () => void;
onNativeEngineRestart: () => Promise<string>;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { client, token } = useClient(); const { client, token } = useClient();
@@ -1519,6 +1512,7 @@ function Shell({
onSectionChange={onSettingsSectionChange} onSectionChange={onSettingsSectionChange}
onLogout={onLogout} onLogout={onLogout}
onRestart={onRestart} onRestart={onRestart}
onNativeEngineRestart={onNativeEngineRestart}
isRestarting={isRestarting} isRestarting={isRestarting}
hostChromeInset={showHostChrome} hostChromeInset={showHostChrome}
/> />
+19 -10
View File
@@ -287,6 +287,7 @@ interface SettingsViewProps {
onSectionChange?: (section: SettingsSectionKey) => void; onSectionChange?: (section: SettingsSectionKey) => void;
onLogout?: () => void; onLogout?: () => void;
onRestart?: () => void; onRestart?: () => void;
onNativeEngineRestart?: () => Promise<string>;
isRestarting?: boolean; isRestarting?: boolean;
hostChromeInset?: boolean; hostChromeInset?: boolean;
} }
@@ -458,6 +459,7 @@ export function SettingsView({
onSectionChange, onSectionChange,
onLogout, onLogout,
onRestart, onRestart,
onNativeEngineRestart,
isRestarting = false, isRestarting = false,
hostChromeInset = false, hostChromeInset = false,
}: SettingsViewProps) { }: SettingsViewProps) {
@@ -744,12 +746,15 @@ export function SettingsView({
const restartViaSettingsSurface = useCallback(async () => { const restartViaSettingsSurface = useCallback(async () => {
const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native"; const isNativeHost = (settings?.surface ?? settings?.runtime_surface) === "native";
const hostApi = getHostApi(); if (
if (isNativeHost && settings?.runtime_capabilities?.can_restart_engine && hostApi) { isNativeHost &&
settings?.runtime_capabilities?.can_restart_engine &&
onNativeEngineRestart
) {
setHostEngineApplying(true); setHostEngineApplying(true);
try { try {
await hostApi.restartEngine(); const nextToken = await onNativeEngineRestart();
const payload = await fetchSettings(token); const payload = await fetchSettings(nextToken);
applyPayload(payload); applyPayload(payload);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS); setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null); setError(null);
@@ -761,21 +766,25 @@ export function SettingsView({
return; return;
} }
onRestart?.(); onRestart?.();
}, [applyPayload, onRestart, settings, token]); }, [applyPayload, onNativeEngineRestart, onRestart, settings]);
const maybeRestartHostEngine = useCallback( const maybeRestartHostEngine = useCallback(
async (payload: RestartAwarePayload) => { async (payload: RestartAwarePayload) => {
const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface; const surface = payload.surface ?? payload.runtime_surface ?? settings?.surface ?? settings?.runtime_surface;
const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities; const capabilities = payload.runtime_capabilities ?? settings?.runtime_capabilities;
const isNativeHost = surface === "native"; const isNativeHost = surface === "native";
const hostApi = getHostApi(); if (
if (!payload.requires_restart || !isNativeHost || !capabilities?.can_restart_engine || !hostApi) { !payload.requires_restart ||
!isNativeHost ||
!capabilities?.can_restart_engine ||
!onNativeEngineRestart
) {
return; return;
} }
setHostEngineApplying(true); setHostEngineApplying(true);
try { try {
await hostApi.restartEngine(); const nextToken = await onNativeEngineRestart();
const refreshed = await fetchSettings(token); const refreshed = await fetchSettings(nextToken);
applyPayload(refreshed); applyPayload(refreshed);
setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS); setPendingRestartSections(EMPTY_PENDING_RESTART_SECTIONS);
setError(null); setError(null);
@@ -785,7 +794,7 @@ export function SettingsView({
setHostEngineApplying(false); setHostEngineApplying(false);
} }
}, },
[applyPayload, settings, token], [applyPayload, onNativeEngineRestart, settings],
); );
const saveModelSettings = async () => { const saveModelSettings = async () => {
+62
View File
@@ -120,6 +120,7 @@ function renderSettingsView(
options: { options: {
initialSection?: "overview" | "apps" | "advanced" | "models"; initialSection?: "overview" | "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
onNativeEngineRestart?: () => Promise<string>;
} = {}, } = {},
) { ) {
render( render(
@@ -131,6 +132,7 @@ function renderSettingsView(
onBackToChat={() => {}} onBackToChat={() => {}}
onModelNameChange={() => {}} onModelNameChange={() => {}}
onSettingsChange={options.onSettingsChange} onSettingsChange={options.onSettingsChange}
onNativeEngineRestart={options.onNativeEngineRestart}
/> />
</ClientProvider>, </ClientProvider>,
); );
@@ -766,4 +768,64 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Web safety")).not.toBeInTheDocument(); expect(screen.queryByText("Web safety")).not.toBeInTheDocument();
expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument(); expect(screen.getByText("Allow Full Access shell commands to reach services on this Mac.")).toBeInTheDocument();
}); });
it("refreshes settings with a fresh token after native engine restart", async () => {
const payload = {
...settingsPayload(),
surface: "native" as const,
runtime_surface: "native" as const,
runtime_capabilities: {
can_restart_engine: true,
can_pick_folder: true,
can_open_logs: true,
can_export_diagnostics: true,
},
};
const restartedPayload = {
...payload,
advanced: { ...payload.advanced, webui_allow_local_service_access: false },
requires_restart: true,
restart_required_sections: ["runtime"],
};
const refreshedPayload = {
...restartedPayload,
requires_restart: false,
restart_required_sections: [],
};
const restartEngine = vi.fn(async () => "fresh-token");
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization;
if (url === "/api/settings" && auth === "Bearer fresh-token") {
return jsonResponse(refreshedPayload);
}
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") return jsonResponse({ apps: [], installed_count: 0 });
if (url === "/api/settings/mcp-presets") return jsonResponse({ presets: [], installed_count: 0 });
if (url === "/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=default") {
return jsonResponse(restartedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({
initialSection: "advanced",
onNativeEngineRestart: restartEngine,
});
expect(await screen.findByText("App safety")).toBeInTheDocument();
fireEvent.click(screen.getByRole("switch", { name: "Local services" }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(restartEngine).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings",
expect.objectContaining({
headers: { Authorization: "Bearer fresh-token" },
}),
),
);
});
}); });