@saadnvd1/agent-os 0.3.553 → 0.3.556

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/app/api/sessions/[id]/done/route.test.ts +70 -0
  2. package/app/api/sessions/[id]/done/route.ts +32 -6
  3. package/app/api/updates/check/route.ts +17 -0
  4. package/app/api/updates/route.test.ts +205 -0
  5. package/app/api/updates/route.ts +11 -0
  6. package/app/api/updates/run/route.ts +50 -0
  7. package/app/api/updates/settings/route.ts +25 -0
  8. package/components/Chat/ChatPanel.tsx +18 -5
  9. package/components/Chat/Messages.test.ts +44 -0
  10. package/components/Chat/Messages.tsx +29 -7
  11. package/components/Chat/composer/ComposerBody.tsx +5 -2
  12. package/components/Chat/composer/hook.test.ts +24 -0
  13. package/components/Chat/composer/insert.ts +18 -0
  14. package/components/SessionList/useDoneAction.test.ts +149 -0
  15. package/components/SessionList/useDoneAction.ts +17 -12
  16. package/components/Settings/SettingsDialog.test.ts +3 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/Settings/commands.ts +9 -0
  19. package/components/Settings/entry-points.test.ts +1 -0
  20. package/components/Updates/UpdateActions.tsx +93 -0
  21. package/components/Updates/UpdateBanner.test.ts +198 -0
  22. package/components/Updates/UpdateBanner.tsx +212 -0
  23. package/components/Updates/UpdatesSection.tsx +150 -0
  24. package/components/Updates/index.ts +2 -0
  25. package/components/views/DesktopView.tsx +2 -0
  26. package/components/views/MobileView.tsx +2 -0
  27. package/data/done/index.ts +33 -7
  28. package/data/updates/index.ts +63 -0
  29. package/hooks/useDismissedUpdate.ts +56 -0
  30. package/hooks/useReloadWhenUpdated.ts +19 -0
  31. package/lib/chat/quote.ts +5 -0
  32. package/lib/done/deadline.ts +45 -0
  33. package/lib/done/index.ts +4 -1
  34. package/lib/settings-url.ts +1 -0
  35. package/lib/updates/banner.ts +114 -0
  36. package/lib/updates/check.ts +240 -0
  37. package/lib/updates/run.ts +452 -0
  38. package/lib/updates/settings.ts +38 -0
  39. package/lib/updates/version.ts +30 -0
  40. package/lib/updates/view.ts +80 -0
  41. package/package.json +1 -1
  42. package/scripts/redeploy +4 -0
  43. package/scripts/update-run.mjs +82 -0
  44. package/scripts/update-run.test.ts +64 -0
  45. package/server.ts +16 -0
@@ -0,0 +1,18 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import type { markdownParagraphs } from "@/lib/chat/quote";
3
+
4
+ // Puts paragraphs in at the caret, or with `append` after everything already
5
+ // written (on new lines), leaving the caret after them.
6
+ export function insertMarkdown(
7
+ editor: Editor,
8
+ content: ReturnType<typeof markdownParagraphs>,
9
+ append = false
10
+ ) {
11
+ if (append && !editor.isEmpty) {
12
+ editor
13
+ .chain()
14
+ .insertContentAt(editor.state.doc.content.size, content)
15
+ .focus("end")
16
+ .run();
17
+ } else editor.chain().focus().insertContent(content).run();
18
+ }
@@ -0,0 +1,149 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useEffect } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Session } from "@/lib/db/types";
7
+
8
+ const toast = vi.hoisted(() =>
9
+ Object.assign(vi.fn(), {
10
+ loading: vi.fn(() => "t1"),
11
+ success: vi.fn(),
12
+ error: vi.fn(),
13
+ })
14
+ );
15
+ vi.mock("sonner", () => ({ toast }));
16
+
17
+ const { useDoneAction } = await import("./useDoneAction");
18
+
19
+ (
20
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
21
+ ).IS_REACT_ACT_ENVIRONMENT = true;
22
+
23
+ let root: Root;
24
+ let host: HTMLElement;
25
+ const hook: { current: (s: Session) => void } = { current: () => {} };
26
+ const confirmSpy = vi.fn(() => false);
27
+ const fetchSpy = vi.fn();
28
+
29
+ function Probe() {
30
+ const markDone = useDoneAction();
31
+ useEffect(() => {
32
+ hook.current = markDone;
33
+ });
34
+ return null;
35
+ }
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ vi.stubGlobal("confirm", confirmSpy);
40
+ vi.stubGlobal("fetch", fetchSpy);
41
+ host = document.createElement("div");
42
+ document.body.append(host);
43
+ root = createRoot(host);
44
+ act(() =>
45
+ root.render(
46
+ createElement(
47
+ QueryClientProvider,
48
+ { client: new QueryClient() },
49
+ createElement(Probe)
50
+ )
51
+ )
52
+ );
53
+ });
54
+ afterEach(() => {
55
+ act(() => root.unmount());
56
+ host.remove();
57
+ vi.unstubAllGlobals();
58
+ });
59
+
60
+ const quick = {
61
+ id: "q1",
62
+ name: "Fix pair page menu label",
63
+ quick_of: "orch",
64
+ task_status: "running",
65
+ pr_status: "open",
66
+ pr_number: 281,
67
+ } as Session;
68
+
69
+ const respond = (status: number, body: object) =>
70
+ fetchSpy.mockResolvedValue({
71
+ ok: status < 400,
72
+ status,
73
+ json: async () => body,
74
+ });
75
+ // Done on an open PR asks first, in a toast; Merge is its action.
76
+ const confirmMerge = () => {
77
+ const [, opts] = toast.mock.calls.at(-1) as unknown as [
78
+ string,
79
+ { action: { onClick: () => void } },
80
+ ];
81
+ act(() => opts.action.onClick());
82
+ };
83
+ const settle = () => act(() => new Promise((r) => setTimeout(r, 0)));
84
+
85
+ describe("Done from a row's menu", () => {
86
+ it("asks in the app, not with confirm(), and does nothing until Merge is chosen", async () => {
87
+ respond(200, {
88
+ outcome: {
89
+ id: "q1",
90
+ name: quick.name,
91
+ merged: "Merged.",
92
+ worktree: { action: "none" },
93
+ text: "Merged. Agent stopped, no worktree.",
94
+ },
95
+ });
96
+ act(() => hook.current(quick));
97
+ expect(fetchSpy).not.toHaveBeenCalled();
98
+ confirmMerge();
99
+ await settle();
100
+ expect(confirmSpy).not.toHaveBeenCalled();
101
+ expect(fetchSpy).toHaveBeenCalledWith(
102
+ "/api/sessions/q1/done",
103
+ expect.objectContaining({ method: "POST" })
104
+ );
105
+ expect(toast.success).toHaveBeenCalledWith(
106
+ "Merged. Agent stopped, no worktree.",
107
+ expect.objectContaining({ id: "t1" })
108
+ );
109
+ });
110
+
111
+ it("shows a refusal as a toast naming the gate, for a quick change and a task", async () => {
112
+ respond(409, {
113
+ error: "Not done, nothing merged.\n- ci failed: CI failed",
114
+ });
115
+ act(() => hook.current(quick));
116
+ confirmMerge();
117
+ await settle();
118
+ act(() => hook.current({ ...quick, id: "t2", quick_of: null } as Session));
119
+ confirmMerge();
120
+ await settle();
121
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
122
+ expect(toast.error).toHaveBeenCalledTimes(2);
123
+ expect(toast.error).toHaveBeenCalledWith(
124
+ expect.stringContaining("ci failed"),
125
+ expect.objectContaining({ id: "t1" })
126
+ );
127
+ });
128
+
129
+ it("ends the Merging toast with an error when the server never answers", async () => {
130
+ vi.useFakeTimers();
131
+ fetchSpy.mockImplementation(
132
+ (_url: string, init: { signal?: AbortSignal }) =>
133
+ new Promise((_, reject) =>
134
+ init.signal?.addEventListener("abort", () =>
135
+ reject(init.signal?.reason)
136
+ )
137
+ )
138
+ );
139
+ act(() => hook.current(quick));
140
+ confirmMerge();
141
+ expect(toast.error).not.toHaveBeenCalled();
142
+ await act(() => vi.advanceTimersByTimeAsync(181_000));
143
+ vi.useRealTimers();
144
+ expect(toast.error).toHaveBeenCalledWith(
145
+ expect.any(String),
146
+ expect.objectContaining({ id: "t1" })
147
+ );
148
+ });
149
+ });
@@ -7,21 +7,12 @@ import {
7
7
  } from "@/data/done";
8
8
  import { useSettleSession } from "@/data/sessions/shelf";
9
9
 
10
- // Done from a session's menu: an open PR merges through the gates, so that
11
- // one asks first; the rest can be undone from the toast.
10
+ // Done from a session's menu: an open PR merges through the gates, after an
11
+ // in-app confirmation; a failing gate comes back as an error toast naming it.
12
12
  export function useDoneAction() {
13
13
  const done = useDoneSession();
14
14
  const settle = useSettleSession();
15
- return (session: Session) => {
16
- const merges =
17
- session.task_status === "running" && session.pr_status === "open";
18
- if (
19
- merges &&
20
- !confirm(
21
- `Merge ${session.name}'s PR through the gates and finish it? A failing gate refuses and nothing merges.`
22
- )
23
- )
24
- return;
15
+ const run = (session: Session, merges: boolean) => {
25
16
  const id = toast.loading(
26
17
  merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
27
18
  );
@@ -45,6 +36,20 @@ export function useDoneAction() {
45
36
  onError: (e) => toast.error(e.message, { id, duration: 12000 }),
46
37
  });
47
38
  };
39
+ return (session: Session) => {
40
+ const merges =
41
+ session.task_status === "running" && session.pr_status === "open";
42
+ if (!merges) return run(session, false);
43
+ // Merging is the one Done that can't be undone: asked in the app, not
44
+ // with confirm(), which a webview drops.
45
+ toast(`Merge ${session.name}'s PR and finish it?`, {
46
+ description:
47
+ "It merges only through the orchestrator's gates; a failing gate refuses and nothing merges.",
48
+ duration: 15000,
49
+ action: { label: "Merge", onClick: () => run(session, true) },
50
+ cancel: { label: "Cancel", onClick: () => {} },
51
+ });
52
+ };
48
53
  }
49
54
 
50
55
  // Archive from a session's menu: puts it away; Unarchive brings it back.
@@ -44,6 +44,9 @@ vi.mock("@/components/Schedules", () => ({
44
44
  vi.mock("@/components/Audits/AuditsPanel", () => ({
45
45
  AuditsPanel: stub("audits"),
46
46
  }));
47
+ vi.mock("@/components/Updates/UpdatesSection", () => ({
48
+ UpdatesSection: stub("updates"),
49
+ }));
47
50
  vi.mock("./NotificationsSection", () => ({
48
51
  NotificationsSection: stub("notifications"),
49
52
  }));
@@ -14,6 +14,7 @@ import {
14
14
  LayoutGrid,
15
15
  ListChecks,
16
16
  ShieldOff,
17
+ RefreshCw,
17
18
  Smartphone,
18
19
  X,
19
20
  type LucideIcon,
@@ -31,6 +32,7 @@ import { DevicesPanel } from "@/components/Devices";
31
32
  import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
32
33
  import { ModelsSection } from "@/components/Models/ModelsSection";
33
34
  import { SchedulesPanel } from "@/components/Schedules";
35
+ import { UpdatesSection } from "@/components/Updates/UpdatesSection";
34
36
  import { WorkspaceSettings } from "@/components/Workspaces";
35
37
  import { useSettingsUrl } from "@/hooks/useSettingsUrl";
36
38
  import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
@@ -99,6 +101,11 @@ export const SECTION_INFO: Record<
99
101
  hint: "What this machine has, agents, first-run setup",
100
102
  icon: ListChecks,
101
103
  },
104
+ updates: {
105
+ label: "Updates",
106
+ hint: "Version, check for updates, the update banner",
107
+ icon: RefreshCw,
108
+ },
102
109
  };
103
110
 
104
111
  function SectionBody({
@@ -129,6 +136,8 @@ function SectionBody({
129
136
  return <AuditsPanel />;
130
137
  case "setup":
131
138
  return <SetupSection />;
139
+ case "updates":
140
+ return <UpdatesSection />;
132
141
  }
133
142
  }
134
143
 
@@ -5,6 +5,7 @@ import {
5
5
  Cpu,
6
6
  GitMerge,
7
7
  LayoutGrid,
8
+ RefreshCw,
8
9
  Settings,
9
10
  Smartphone,
10
11
  } from "lucide-react";
@@ -102,5 +103,13 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
102
103
  icon: ClipboardList,
103
104
  run: () => settingsUiActions.open("audits"),
104
105
  },
106
+ {
107
+ id: "app.updates",
108
+ title: "Check for updates",
109
+ group,
110
+ keywords: ["update", "upgrade", "version", "new version", "banner"],
111
+ icon: RefreshCw,
112
+ run: () => settingsUiActions.open("updates"),
113
+ },
105
114
  ];
106
115
  }
@@ -27,6 +27,7 @@ describe("⌘K entry points", () => {
27
27
  ["app.phone-notifications", "notifications"],
28
28
  ["app.schedules", "schedules"],
29
29
  ["app.audits", "audits"],
30
+ ["app.updates", "updates"],
30
31
  ] as const)("%s opens Settings at %s", (id, section) => {
31
32
  run(id);
32
33
  expect(settingsUi.open).toBe(true);
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import { useCancelUpdate, useRunUpdate } from "@/data/updates";
15
+ import { workingTotal, type UpdateView } from "@/lib/updates/banner";
16
+
17
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
18
+
19
+ const toastError = (e: unknown) =>
20
+ toast.error(e instanceof Error ? e.message : String(e));
21
+
22
+ /** What a restart cuts, in words. */
23
+ export function workingWarning(w: UpdateView["working"]): string {
24
+ const parts: string[] = [];
25
+ if (w.sessions) parts.push(`${plural(w.sessions, "session")} working`);
26
+ if (w.reviews) parts.push(`${plural(w.reviews, "review")} running`);
27
+ if (w.setups) parts.push(`${plural(w.setups, "task setup")} running`);
28
+ return parts.join(", ");
29
+ }
30
+
31
+ /**
32
+ * Update now, or Cancel for an update waiting for quiet. When something is
33
+ * working, Update now asks first and offers to wait.
34
+ */
35
+ export function UpdateActions({ view }: { view: UpdateView }) {
36
+ const run = useRunUpdate();
37
+ const cancel = useCancelUpdate();
38
+ const [confirm, setConfirm] = useState(false);
39
+ const busy = workingTotal(view.working) > 0;
40
+
41
+ if (view.pending)
42
+ return (
43
+ <Button
44
+ size="sm"
45
+ className="min-h-11 md:min-h-0"
46
+ variant="outline"
47
+ disabled={cancel.isPending}
48
+ onClick={() => cancel.mutate(undefined, { onError: toastError })}
49
+ >
50
+ Cancel
51
+ </Button>
52
+ );
53
+ if (!view.canUpdate.ok) return null;
54
+
55
+ const start = (when: "now" | "idle") => {
56
+ setConfirm(false);
57
+ run.mutate(when, { onError: toastError });
58
+ };
59
+
60
+ return (
61
+ <>
62
+ <Button
63
+ size="sm"
64
+ className="min-h-11 md:min-h-0"
65
+ disabled={run.isPending}
66
+ onClick={() => (busy ? setConfirm(true) : start("now"))}
67
+ >
68
+ Update now
69
+ </Button>
70
+ <Dialog open={confirm} onOpenChange={setConfirm}>
71
+ <DialogContent className="max-w-md">
72
+ <DialogHeader>
73
+ <DialogTitle>Update while work is running?</DialogTitle>
74
+ <DialogDescription>
75
+ {workingWarning(view.working)}. Terminals and chats keep running
76
+ through the restart; reviews and task setups in progress start
77
+ over when AgentOS is back.
78
+ </DialogDescription>
79
+ </DialogHeader>
80
+ <DialogFooter className="gap-2 max-sm:flex-col-reverse">
81
+ <Button variant="ghost" onClick={() => setConfirm(false)}>
82
+ Cancel
83
+ </Button>
84
+ <Button variant="outline" onClick={() => start("now")}>
85
+ Update now
86
+ </Button>
87
+ <Button onClick={() => start("idle")}>Update when idle</Button>
88
+ </DialogFooter>
89
+ </DialogContent>
90
+ </Dialog>
91
+ </>
92
+ );
93
+ }
@@ -0,0 +1,198 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { UpdateView } from "@/lib/updates/banner";
6
+
7
+ // Radix dispatches its own events; they must be jsdom's, not Node's.
8
+ for (const key of [
9
+ "Event",
10
+ "CustomEvent",
11
+ "FocusEvent",
12
+ "PopStateEvent",
13
+ "NodeFilter",
14
+ "HTMLButtonElement",
15
+ ])
16
+ Object.defineProperty(globalThis, key, {
17
+ value: (window as unknown as Record<string, unknown>)[key],
18
+ configurable: true,
19
+ writable: true,
20
+ });
21
+
22
+ let view: UpdateView | undefined;
23
+ const runMutate = vi.fn();
24
+ const cancelMutate = vi.fn();
25
+ vi.mock("@/data/updates", () => ({
26
+ useUpdates: () => ({ data: view }),
27
+ useRunUpdate: () => ({ mutate: runMutate, isPending: false }),
28
+ useCancelUpdate: () => ({ mutate: cancelMutate, isPending: false }),
29
+ }));
30
+ vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
31
+
32
+ const { UpdateBanner } = await import("./UpdateBanner");
33
+
34
+ (
35
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
36
+ ).IS_REACT_ACT_ENVIRONMENT = true;
37
+
38
+ const make = (over: Partial<UpdateView> = {}): UpdateView => ({
39
+ current: { version: "0.3.1", sha: "a" },
40
+ latest: {
41
+ version: "0.3.5",
42
+ sha: "b",
43
+ notes: ["Faster boot (#9)"],
44
+ url: "https://example.com/compare",
45
+ },
46
+ available: true,
47
+ behind: 4,
48
+ status: "ok",
49
+ checkedAt: 1,
50
+ checkError: null,
51
+ bannerEnabled: true,
52
+ bannerLocked: false,
53
+ canUpdate: { ok: true },
54
+ run: null,
55
+ pending: null,
56
+ working: { sessions: 0, reviews: 0, setups: 0 },
57
+ ...over,
58
+ });
59
+
60
+ let root: Root;
61
+ let host: HTMLElement;
62
+ const render = () => act(async () => root.render(createElement(UpdateBanner)));
63
+ const text = () => document.body.textContent ?? "";
64
+ const button = (label: string) =>
65
+ [...document.querySelectorAll<HTMLButtonElement>("button")].find(
66
+ (b) =>
67
+ b.textContent?.trim() === label || b.getAttribute("aria-label") === label
68
+ );
69
+
70
+ beforeEach(() => {
71
+ localStorage.clear();
72
+ runMutate.mockReset();
73
+ cancelMutate.mockReset();
74
+ view = make();
75
+ host = document.createElement("div");
76
+ document.body.appendChild(host);
77
+ root = createRoot(host);
78
+ });
79
+ afterEach(async () => {
80
+ await act(async () => root.unmount());
81
+ host.remove();
82
+ });
83
+
84
+ describe("UpdateBanner", () => {
85
+ it("says which version is available, with a link to the notes", async () => {
86
+ await render();
87
+ expect(text()).toContain("AgentOS 0.3.5 is available");
88
+ expect(
89
+ document.querySelector<HTMLAnchorElement>("a")?.getAttribute("href")
90
+ ).toBe("https://example.com/compare");
91
+ await act(async () => button("What's new")!.click());
92
+ expect(text()).toContain("Faster boot (#9)");
93
+ });
94
+
95
+ it("Dismiss hides it for that version and survives a reload", async () => {
96
+ await render();
97
+ await act(async () => button("Dismiss until the next version")!.click());
98
+ expect(text()).not.toContain("is available");
99
+ expect(localStorage.getItem("agentos.update.dismissed")).toBe("0.3.5");
100
+
101
+ // A new page load reads the same dismissal.
102
+ await act(async () => root.unmount());
103
+ root = createRoot(host);
104
+ await render();
105
+ expect(text()).not.toContain("is available");
106
+ });
107
+
108
+ it("shows again for a newer version", async () => {
109
+ localStorage.setItem("agentos.update.dismissed", "0.3.5");
110
+ view = make({
111
+ latest: { version: "0.3.6", sha: "c", notes: [], url: null },
112
+ });
113
+ await render();
114
+ expect(text()).toContain("AgentOS 0.3.6 is available");
115
+ });
116
+
117
+ it("is absent when the banner is switched off or nothing is newer", async () => {
118
+ view = make({ bannerEnabled: false });
119
+ await render();
120
+ expect(text()).toBe("");
121
+ view = make({ available: false });
122
+ await render();
123
+ expect(text()).toBe("");
124
+ });
125
+
126
+ it("offers no Update button to a caller who can't update, and says why", async () => {
127
+ view = make({
128
+ canUpdate: {
129
+ ok: false,
130
+ code: "forbidden",
131
+ reason: "Ask a device that can approve.",
132
+ },
133
+ });
134
+ await render();
135
+ expect(button("Update now")).toBeUndefined();
136
+ expect(text()).toContain("Ask a device that can approve.");
137
+ });
138
+
139
+ it("Update now starts at once when nothing is working", async () => {
140
+ await render();
141
+ await act(async () => button("Update now")!.click());
142
+ expect(runMutate).toHaveBeenCalledWith("now", expect.anything());
143
+ });
144
+
145
+ it("warns when work is running, and can wait for idle", async () => {
146
+ view = make({ working: { sessions: 3, reviews: 0, setups: 0 } });
147
+ await render();
148
+ await act(async () => button("Update now")!.click());
149
+ expect(runMutate).not.toHaveBeenCalled();
150
+ expect(text()).toContain("3 sessions working");
151
+ await act(async () => button("Update when idle")!.click());
152
+ expect(runMutate).toHaveBeenCalledWith("idle", expect.anything());
153
+ });
154
+
155
+ it("shows a pending update with Cancel", async () => {
156
+ view = make({ pending: { requestedAt: 1 } });
157
+ await render();
158
+ expect(text()).toContain("will update when nothing is working");
159
+ await act(async () => button("Cancel")!.click());
160
+ expect(cancelMutate).toHaveBeenCalled();
161
+ });
162
+
163
+ it("shows progress while updating, with no way to dismiss it", async () => {
164
+ view = make({
165
+ run: {
166
+ id: "r",
167
+ phase: "building",
168
+ from: "a",
169
+ fromVersion: "0.3.1",
170
+ toVersion: "0.3.5",
171
+ startedAt: Date.now(),
172
+ },
173
+ });
174
+ await render();
175
+ expect(text()).toContain("Building… step 3 of 4");
176
+ expect(button("Dismiss")).toBeUndefined();
177
+ });
178
+
179
+ it("shows a failure with its error until dismissed", async () => {
180
+ view = make({
181
+ available: false,
182
+ run: {
183
+ id: "r9",
184
+ phase: "failed",
185
+ from: "a",
186
+ fromVersion: "0.3.1",
187
+ toVersion: "0.3.5",
188
+ startedAt: 1,
189
+ error: "build exploded",
190
+ },
191
+ });
192
+ await render();
193
+ expect(text()).toContain("build exploded");
194
+ await act(async () => button("Dismiss")!.click());
195
+ expect(text()).toBe("");
196
+ expect(localStorage.getItem("agentos.update.dismissedRun")).toBe("r9");
197
+ });
198
+ });