@saadnvd1/agent-os 0.3.723 → 0.3.728

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 (116) hide show
  1. package/README.md +1 -0
  2. package/app/api/loops/route.ts +17 -0
  3. package/app/api/tasks/[id]/loop/cycles/[n]/log/route.ts +29 -0
  4. package/app/api/tasks/[id]/loop/draft-criteria/route.ts +17 -0
  5. package/app/api/tasks/[id]/loop/next/route.ts +25 -0
  6. package/app/api/tasks/[id]/loop/route.ts +72 -0
  7. package/app/api/tasks/[id]/loop/run/route.ts +12 -0
  8. package/app/api/tasks/[id]/loop/stop/route.ts +12 -0
  9. package/app/api/tasks/[id]/mode/route.ts +29 -0
  10. package/app/api/tasks/[id]/report/draft/route.ts +54 -0
  11. package/app/api/tasks/[id]/report/evidence/[evId]/route.ts +18 -0
  12. package/app/api/tasks/[id]/report/route.ts +39 -0
  13. package/app/api/workspaces/[id]/mode/route.ts +23 -0
  14. package/app/api/workspaces/[id]/reports/route.ts +17 -0
  15. package/app/page.tsx +8 -0
  16. package/components/Analyst/PairChip.tsx +131 -0
  17. package/components/Analyst/PairSwitch.tsx +76 -0
  18. package/components/Analyst/ReviewCard.tsx +272 -0
  19. package/components/Analyst/SignalChips.tsx +39 -0
  20. package/components/Analyst/StartAnalystSheet.tsx +109 -0
  21. package/components/Analyst/cardState.test.ts +109 -0
  22. package/components/Analyst/cardState.ts +95 -0
  23. package/components/Analyst/pair.ts +81 -0
  24. package/components/Chat/Code.tsx +3 -0
  25. package/components/Chat/Messages.tsx +6 -1
  26. package/components/Chat/itemContext.ts +14 -0
  27. package/components/Draft/DraftChips.tsx +10 -0
  28. package/components/Draft/DraftPanel.tsx +7 -0
  29. package/components/Pane/DesktopTabBar.tsx +6 -0
  30. package/components/Pane/MobileTabBar.tsx +17 -0
  31. package/components/Pane/MobileViewPicker.tsx +4 -0
  32. package/components/Pane/PaneMenu.test.ts +21 -0
  33. package/components/Pane/PaneMenu.tsx +16 -0
  34. package/components/Pane/PaneViewToggle.tsx +12 -0
  35. package/components/Pane/index.tsx +19 -0
  36. package/components/Report/DraftCard.tsx +89 -0
  37. package/components/Report/EstimateBar.tsx +51 -0
  38. package/components/Report/EvidenceSheet.tsx +98 -0
  39. package/components/Report/ModeChooser.tsx +43 -0
  40. package/components/Report/ReportView.tsx +289 -0
  41. package/components/SessionList/SessionRowMenu.tsx +6 -0
  42. package/components/Sessions/RolePill.tsx +7 -0
  43. package/components/Tasks/Loop/LoopButton.tsx +74 -0
  44. package/components/Tasks/Loop/LoopSetup.tsx +146 -0
  45. package/components/Tasks/Loop/LoopView.tsx +350 -0
  46. package/components/Tasks/Loop/labels.ts +17 -0
  47. package/components/Tasks/TaskRow.tsx +2 -0
  48. package/components/Workspaces/WorkspaceModeForm.tsx +22 -0
  49. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  50. package/components/ui/segmented.tsx +55 -0
  51. package/components/ui/status-pill.tsx +3 -0
  52. package/contexts/PaneContext.tsx +21 -1
  53. package/data/loops/index.ts +3 -0
  54. package/data/loops/keys.ts +4 -0
  55. package/data/loops/mutations.ts +68 -0
  56. package/data/loops/queries.ts +40 -0
  57. package/data/push/topics.ts +6 -0
  58. package/data/reports/index.ts +115 -0
  59. package/lib/analyst/forward.ts +1 -1
  60. package/lib/db/changes.ts +7 -0
  61. package/lib/db/migrations/20261021T1600_task_loops.ts +81 -0
  62. package/lib/db/migrations/20261021T1800_health_reports.ts +38 -0
  63. package/lib/done/index.ts +2 -0
  64. package/lib/drafts.ts +2 -0
  65. package/lib/loops/analyse.ts +46 -0
  66. package/lib/loops/analyst-prompt.ts +203 -0
  67. package/lib/loops/checkCriteria.ts +114 -0
  68. package/lib/loops/compactEvidence.ts +140 -0
  69. package/lib/loops/controls.ts +317 -0
  70. package/lib/loops/decideStop.ts +113 -0
  71. package/lib/loops/deps.ts +128 -0
  72. package/lib/loops/diffMap.ts +39 -0
  73. package/lib/loops/draftCriteria.ts +71 -0
  74. package/lib/loops/gatherEvidence.ts +265 -0
  75. package/lib/loops/hooks.ts +40 -0
  76. package/lib/loops/index.ts +7 -0
  77. package/lib/loops/route.ts +83 -0
  78. package/lib/loops/runCycle.ts +323 -0
  79. package/lib/loops/screenInstruction.ts +59 -0
  80. package/lib/loops/sendInstruction.ts +83 -0
  81. package/lib/loops/snapshot.ts +38 -0
  82. package/lib/loops/stopLoop.ts +86 -0
  83. package/lib/loops/store.ts +481 -0
  84. package/lib/loops/summary.ts +27 -0
  85. package/lib/loops/tickLoops.ts +141 -0
  86. package/lib/loops/types.ts +223 -0
  87. package/lib/loops/world.test-helpers.ts +223 -0
  88. package/lib/models/plan-step.ts +4 -1
  89. package/lib/orchestrator/digest.ts +2 -1
  90. package/lib/orchestrator/loop-guard.ts +5 -2
  91. package/lib/orchestrator/pr-wake.ts +4 -0
  92. package/lib/orchestrator/watcher.ts +8 -1
  93. package/lib/panes.ts +52 -0
  94. package/lib/plans/acceptance.ts +23 -0
  95. package/lib/reports/drafts.ts +87 -0
  96. package/lib/reports/end-hook.ts +17 -0
  97. package/lib/reports/estimate.ts +107 -0
  98. package/lib/reports/evidence/build.ts +193 -0
  99. package/lib/reports/evidence/gather.ts +229 -0
  100. package/lib/reports/fixtures.ts +17 -0
  101. package/lib/reports/generate.ts +159 -0
  102. package/lib/reports/hooks.ts +30 -0
  103. package/lib/reports/mode.ts +65 -0
  104. package/lib/reports/model.ts +115 -0
  105. package/lib/reports/read.ts +26 -0
  106. package/lib/reports/store.ts +180 -0
  107. package/lib/reports/template.ts +117 -0
  108. package/lib/reports/types.ts +135 -0
  109. package/lib/reports/validate.ts +142 -0
  110. package/lib/sidebar/row-menu.ts +9 -0
  111. package/lib/tasks/contract/routes.ts +5 -0
  112. package/lib/tasks/contract/types.ts +3 -0
  113. package/lib/tasks/finish.ts +2 -0
  114. package/lib/tasks/move-carry.ts +1 -0
  115. package/package.json +2 -2
  116. package/stores/analystUi.ts +19 -0
@@ -0,0 +1,272 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { AlertTriangle, Check, Loader2, RotateCcw, Send } from "lucide-react";
5
+ import { toast } from "sonner";
6
+ import { Button } from "@/components/ui/button";
7
+ import { StatusPill } from "@/components/ui/status-pill";
8
+ import { Textarea } from "@/components/ui/textarea";
9
+ import { useBookmarkChat } from "@/components/Bookmarks/context";
10
+ import { useChatItem } from "@/components/Chat/itemContext";
11
+ import {
12
+ useAskAgain,
13
+ useDismissRecommendation,
14
+ useSendRecommendation,
15
+ useSessionAnalyst,
16
+ } from "@/data/analyst";
17
+ import { parseReviewJson } from "@/lib/analyst/parseReview";
18
+ import type { Issue, Recommendation } from "@/lib/analyst/types";
19
+ import { cn } from "@/lib/utils";
20
+ import { cardView } from "./cardState";
21
+ import { SignalChips } from "./SignalChips";
22
+
23
+ const SEVERITY_DOT: Record<Issue["severity"], string> = {
24
+ high: "bg-red-500",
25
+ medium: "bg-amber-500",
26
+ low: "bg-muted-foreground/50",
27
+ };
28
+
29
+ const fail = (e: Error) => toast.error(e.message);
30
+
31
+ /**
32
+ * An analyst's `agentos-review` block, drawn as a card: its summary, the
33
+ * issues it flagged and the prompt it recommends, with Send to Coding Agent,
34
+ * Edit and Dismiss while the stored card is open.
35
+ */
36
+ export function ReviewCard({ code }: { code: string }) {
37
+ const item = useChatItem();
38
+ const sessionId = useBookmarkChat()?.sessionId;
39
+ const { data: link } = useSessionAnalyst(sessionId);
40
+ const parsed = parseReviewJson(code);
41
+ const rec =
42
+ (item &&
43
+ link &&
44
+ link.analyst.id === sessionId &&
45
+ link.recommendations.find((r) => r.analystItemId === item.itemId)) ||
46
+ null;
47
+
48
+ if (!parsed.ok)
49
+ return (
50
+ <div className="bg-foreground/[0.04] text-muted-foreground not-prose my-3 space-y-2 rounded-xl px-3 py-2.5 text-xs">
51
+ <div className="flex items-center gap-2">
52
+ {item?.streaming ? (
53
+ <>
54
+ <Loader2 className="h-3.5 w-3.5 animate-spin" /> Writing the
55
+ review…
56
+ </>
57
+ ) : (
58
+ <>
59
+ <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" />
60
+ {parsed.error}
61
+ </>
62
+ )}
63
+ </div>
64
+ {rec && link && !item?.streaming && (
65
+ <Actions
66
+ linkId={link.id}
67
+ coderName={link.coder.name}
68
+ rec={rec}
69
+ prompt={null}
70
+ view={cardView(rec, null, link.coderLastTurn)}
71
+ />
72
+ )}
73
+ </div>
74
+ );
75
+
76
+ const review = parsed.review;
77
+ const view = cardView(
78
+ rec,
79
+ rec?.verdict ?? review.verdict,
80
+ link?.coderLastTurn ?? null
81
+ );
82
+
83
+ return (
84
+ <div className="bg-card hairline not-prose my-3 space-y-3 rounded-xl p-3 text-sm">
85
+ <div className="flex items-center gap-2">
86
+ <StatusPill tone={view.tone}>{view.label}</StatusPill>
87
+ <span className="text-muted-foreground text-xs">Analyst review</span>
88
+ </div>
89
+ <p className="leading-relaxed">{review.summary}</p>
90
+ {rec?.signals && <SignalChips signals={rec.signals} />}
91
+ {review.issues.length > 0 && (
92
+ <ul className="space-y-2">
93
+ {review.issues.map((issue, i) => (
94
+ <li key={i} className="flex gap-2">
95
+ <span
96
+ aria-label={`${issue.severity} severity`}
97
+ className={cn(
98
+ "mt-1.5 h-2 w-2 shrink-0 rounded-full",
99
+ SEVERITY_DOT[issue.severity]
100
+ )}
101
+ />
102
+ <div className="min-w-0">
103
+ <div className="font-medium">{issue.title}</div>
104
+ {issue.where && (
105
+ <div className="text-muted-foreground truncate font-mono text-[11px]">
106
+ {issue.where}
107
+ </div>
108
+ )}
109
+ {issue.detail && (
110
+ <div className="text-muted-foreground text-xs leading-relaxed">
111
+ {issue.detail}
112
+ </div>
113
+ )}
114
+ </div>
115
+ </li>
116
+ ))}
117
+ </ul>
118
+ )}
119
+ {view.notes.map((note) => (
120
+ <div
121
+ key={note}
122
+ className="flex items-start gap-2 rounded-lg bg-amber-500/10 px-2.5 py-2 text-xs text-amber-700 dark:text-amber-400"
123
+ >
124
+ <AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
125
+ {note}
126
+ </div>
127
+ ))}
128
+ {rec && link ? (
129
+ <Actions
130
+ linkId={link.id}
131
+ coderName={link.coder.name}
132
+ rec={rec}
133
+ prompt={review.prompt ?? null}
134
+ view={view}
135
+ />
136
+ ) : (
137
+ review.prompt && <PromptBox text={review.prompt} />
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ function PromptBox({ text, muted }: { text: string; muted?: boolean }) {
144
+ return (
145
+ <div
146
+ className={cn(
147
+ "bg-foreground/[0.04] max-h-60 overflow-y-auto rounded-lg px-3 py-2 text-[13px] leading-relaxed whitespace-pre-wrap",
148
+ muted && "text-muted-foreground"
149
+ )}
150
+ >
151
+ {text}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ function Actions({
157
+ linkId,
158
+ coderName,
159
+ rec,
160
+ prompt,
161
+ view,
162
+ }: {
163
+ linkId: string;
164
+ coderName: string;
165
+ rec: Recommendation;
166
+ prompt: string | null;
167
+ view: ReturnType<typeof cardView>;
168
+ }) {
169
+ const [draft, setDraft] = useState<string | null>(null);
170
+ const send = useSendRecommendation(linkId);
171
+ const dismiss = useDismissRecommendation(linkId);
172
+ const again = useAskAgain(linkId);
173
+ const busy = send.isPending || dismiss.isPending || again.isPending;
174
+
175
+ if (rec.state === "sent")
176
+ return (
177
+ <div className="space-y-1.5">
178
+ <div className="text-muted-foreground flex items-center gap-1.5 text-xs">
179
+ <Check className="h-3.5 w-3.5 text-green-500" /> Sent to {coderName}
180
+ </div>
181
+ {rec.sentText && <PromptBox text={rec.sentText} muted />}
182
+ </div>
183
+ );
184
+
185
+ if (draft !== null)
186
+ return (
187
+ <div className="space-y-2">
188
+ <Textarea
189
+ autoFocus
190
+ value={draft}
191
+ onChange={(e) => setDraft(e.target.value)}
192
+ aria-label="Prompt for the coding agent"
193
+ className="min-h-[160px]"
194
+ />
195
+ <div className="flex flex-wrap gap-2">
196
+ <Button
197
+ size="sm"
198
+ className="h-11 md:h-8"
199
+ disabled={busy || !draft.trim()}
200
+ onClick={() =>
201
+ send.mutate(
202
+ { recId: rec.id, text: draft.trim() },
203
+ { onError: fail, onSuccess: () => setDraft(null) }
204
+ )
205
+ }
206
+ >
207
+ <Send /> Send to Coding Agent
208
+ </Button>
209
+ <Button
210
+ size="sm"
211
+ variant="ghost"
212
+ className="h-11 md:h-8"
213
+ onClick={() => setDraft(null)}
214
+ >
215
+ Cancel
216
+ </Button>
217
+ </div>
218
+ </div>
219
+ );
220
+
221
+ return (
222
+ <div className="space-y-2">
223
+ {prompt && <PromptBox text={prompt} muted={rec.state !== "open"} />}
224
+ <div className="flex flex-wrap gap-2">
225
+ {view.canSend && (
226
+ <Button
227
+ size="sm"
228
+ className="h-11 md:h-8"
229
+ disabled={busy}
230
+ onClick={() => send.mutate({ recId: rec.id }, { onError: fail })}
231
+ >
232
+ {send.isPending ? <Loader2 className="animate-spin" /> : <Send />}
233
+ Send to Coding Agent
234
+ </Button>
235
+ )}
236
+ {view.canWrite && (
237
+ <Button
238
+ size="sm"
239
+ variant="outline"
240
+ className="h-11 md:h-8"
241
+ disabled={busy}
242
+ onClick={() => setDraft(prompt ?? "")}
243
+ >
244
+ {view.canSend ? "Edit" : "Ask coding agent anyway"}
245
+ </Button>
246
+ )}
247
+ {view.canAskAgain && (
248
+ <Button
249
+ size="sm"
250
+ variant="outline"
251
+ className="h-11 md:h-8"
252
+ disabled={busy}
253
+ onClick={() => again.mutate(rec.id, { onError: fail })}
254
+ >
255
+ <RotateCcw /> Ask again
256
+ </Button>
257
+ )}
258
+ {view.canDismiss && (
259
+ <Button
260
+ size="sm"
261
+ variant="ghost"
262
+ className="h-11 md:h-8"
263
+ disabled={busy}
264
+ onClick={() => dismiss.mutate(rec.id, { onError: fail })}
265
+ >
266
+ Dismiss
267
+ </Button>
268
+ )}
269
+ </div>
270
+ </div>
271
+ );
272
+ }
@@ -0,0 +1,39 @@
1
+ import type { DigestSignals } from "@/lib/analyst/types";
2
+ import { cn } from "@/lib/utils";
3
+
4
+ const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
5
+
6
+ const shortCommand = (c: string) => (c.length > 40 ? `${c.slice(0, 39)}…` : c);
7
+
8
+ /** What the reviewed turns did, read without a model: the facts under the card. */
9
+ export function SignalChips({ signals: s }: { signals: DigestSignals }) {
10
+ const chips: { text: string; tone?: "good" | "bad" }[] = [];
11
+ chips.push({ text: plural(s.toolCalls, "tool call") });
12
+ if (s.filesChanged.length)
13
+ chips.push({ text: plural(s.filesChanged.length, "file") + " changed" });
14
+ for (const c of s.checks)
15
+ chips.push({
16
+ text: `${shortCommand(c.command)} ${c.passed ? "passed" : "failed"}`,
17
+ tone: c.passed ? "good" : "bad",
18
+ });
19
+ if (s.failedTools)
20
+ chips.push({ text: plural(s.failedTools, "failed tool"), tone: "bad" });
21
+ if (s.errors) chips.push({ text: plural(s.errors, "error"), tone: "bad" });
22
+ return (
23
+ <div className="flex flex-wrap gap-1.5">
24
+ {chips.map((c) => (
25
+ <span
26
+ key={c.text}
27
+ className={cn(
28
+ "bg-foreground/[0.05] text-muted-foreground max-w-full truncate rounded-md px-1.5 py-0.5 font-mono text-[11px]",
29
+ c.tone === "good" &&
30
+ "bg-green-500/10 text-green-700 dark:text-green-400",
31
+ c.tone === "bad" && "bg-red-500/10 text-red-700 dark:text-red-400"
32
+ )}
33
+ >
34
+ {c.text}
35
+ </span>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { Loader2 } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import { Switch } from "@/components/ui/switch";
15
+ import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
16
+ import { analystUi, analystUiActions } from "@/stores/analystUi";
17
+ import { useStartAnalystBeside } from "./pair";
18
+
19
+ // Pairs an analyst with a chat: it reviews each turn the coding agent
20
+ // finishes and recommends the next prompt, which the user sends or not.
21
+ export function StartAnalystSheet() {
22
+ const ui = useSnapshot(analystUi);
23
+ return (
24
+ <Dialog open={ui.open} onOpenChange={(o) => !o && analystUiActions.close()}>
25
+ {ui.open && ui.sessionId && (
26
+ <StartAnalystForm
27
+ key={ui.sessionId}
28
+ sessionId={ui.sessionId}
29
+ name={ui.name}
30
+ paneId={ui.paneId}
31
+ />
32
+ )}
33
+ </Dialog>
34
+ );
35
+ }
36
+
37
+ function StartAnalystForm({
38
+ sessionId,
39
+ name,
40
+ paneId,
41
+ }: {
42
+ sessionId: string;
43
+ name: string;
44
+ paneId: string | null;
45
+ }) {
46
+ const [model, setModel] = useState<string | null>(null);
47
+ const [reviewLast, setReviewLast] = useState(false);
48
+ const start = useStartAnalystBeside();
49
+
50
+ return (
51
+ <DialogContent className="sm:max-w-[460px]">
52
+ <DialogHeader>
53
+ <DialogTitle>Add an analyst</DialogTitle>
54
+ <DialogDescription>
55
+ A second chat that reviews each turn of{" "}
56
+ <span className="text-foreground font-medium">{name}</span>, explains
57
+ it in plain English, flags issues and drafts the next prompt. Nothing
58
+ reaches the coding agent until you press Send.
59
+ </DialogDescription>
60
+ </DialogHeader>
61
+ <div className="space-y-1.5">
62
+ <label
63
+ htmlFor="analyst-model"
64
+ className="text-muted-foreground text-xs font-medium uppercase"
65
+ >
66
+ Analyst model
67
+ </label>
68
+ <RoleModelSelect
69
+ id="analyst-model"
70
+ value={model}
71
+ inheritLabel="The analyst role's model"
72
+ claudeOnly
73
+ onChange={setModel}
74
+ />
75
+ </div>
76
+ <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
77
+ <span>Review the last turn now</span>
78
+ <Switch checked={reviewLast} onCheckedChange={setReviewLast} />
79
+ </label>
80
+ <div className="flex justify-end gap-2">
81
+ <Button
82
+ variant="ghost"
83
+ className="h-11 md:h-9"
84
+ onClick={() => analystUiActions.close()}
85
+ >
86
+ Cancel
87
+ </Button>
88
+ <Button
89
+ className="h-11 md:h-9"
90
+ disabled={start.isPending}
91
+ onClick={async () => {
92
+ const link = await start.run(
93
+ {
94
+ coderSessionId: sessionId,
95
+ analystModel: model ?? undefined,
96
+ reviewLast,
97
+ },
98
+ paneId ?? undefined
99
+ );
100
+ if (link) analystUiActions.close();
101
+ }}
102
+ >
103
+ {start.isPending && <Loader2 className="animate-spin" />}
104
+ Add analyst
105
+ </Button>
106
+ </div>
107
+ </DialogContent>
108
+ );
109
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { DigestSignals, Recommendation } from "@/lib/analyst/types";
3
+ import { cardView } from "./cardState";
4
+
5
+ const signals = (over: Partial<DigestSignals> = {}): DigestSignals => ({
6
+ turns: 1,
7
+ toolCalls: 3,
8
+ failedTools: 0,
9
+ checks: [],
10
+ filesChanged: [],
11
+ durationMs: 1000,
12
+ costUsd: 0,
13
+ interrupted: false,
14
+ errors: 0,
15
+ noToolTurns: 0,
16
+ readWeb: false,
17
+ ...over,
18
+ });
19
+
20
+ const rec = (over: Partial<Recommendation> = {}): Recommendation => ({
21
+ id: "r1",
22
+ linkId: "l1",
23
+ analystItemId: "a1",
24
+ coderThrough: "t1",
25
+ verdict: "continue",
26
+ summary: "Did the thing",
27
+ issues: [],
28
+ signals: signals(),
29
+ prompt: "Run the tests",
30
+ state: "open",
31
+ error: null,
32
+ sentText: null,
33
+ sentItemId: null,
34
+ edited: false,
35
+ createdAt: 1,
36
+ decidedAt: null,
37
+ ...over,
38
+ });
39
+
40
+ describe("cardView", () => {
41
+ it("offers Send, Edit and Dismiss on an open card with a prompt", () => {
42
+ const v = cardView(rec(), "continue", "t1");
43
+ expect(v).toMatchObject({
44
+ label: "Next prompt",
45
+ canSend: true,
46
+ canWrite: true,
47
+ canDismiss: true,
48
+ canAskAgain: false,
49
+ notes: [],
50
+ });
51
+ });
52
+
53
+ it("is read-only without a stored card", () => {
54
+ const v = cardView(null, "continue", "t1");
55
+ expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
56
+ });
57
+
58
+ it("has no Send on a blocked or needs-you card, but lets the user write one", () => {
59
+ for (const verdict of ["blocked", "needs_you"] as const) {
60
+ const v = cardView(rec({ verdict }), verdict, "t1");
61
+ expect(v.canSend).toBe(false);
62
+ expect(v.canWrite).toBe(true);
63
+ }
64
+ });
65
+
66
+ it("warns when the coding agent has moved on", () => {
67
+ const v = cardView(rec(), "continue", "t2");
68
+ expect(v.canSend).toBe(true);
69
+ expect(v.notes).toContain(
70
+ "The coding agent has moved on since this review"
71
+ );
72
+ });
73
+
74
+ it("flags a turn with no tool calls and web reads", () => {
75
+ const v = cardView(
76
+ rec({ signals: signals({ noToolTurns: 1, readWeb: true }) }),
77
+ "continue",
78
+ "t1"
79
+ );
80
+ expect(v.notes).toEqual([
81
+ "The coding agent's turn ended without a single tool call",
82
+ "It read web content: check the prompt before sending",
83
+ ]);
84
+ });
85
+
86
+ it("offers Ask again on an invalid card, with its error", () => {
87
+ const v = cardView(
88
+ rec({ state: "invalid", error: "No block", verdict: null }),
89
+ null,
90
+ "t1"
91
+ );
92
+ expect(v).toMatchObject({
93
+ canAskAgain: true,
94
+ canSend: false,
95
+ notes: ["No block"],
96
+ });
97
+ });
98
+
99
+ it("is settled once sent, dismissed or superseded", () => {
100
+ for (const state of ["sent", "dismissed", "superseded"] as const) {
101
+ const v = cardView(rec({ state }), "continue", "t2");
102
+ expect(v.canSend || v.canWrite || v.canDismiss).toBe(false);
103
+ expect(v.notes).toEqual([]);
104
+ }
105
+ expect(
106
+ cardView(rec({ state: "sent", edited: true }), "continue", "t1").label
107
+ ).toBe("Sent, edited");
108
+ });
109
+ });
@@ -0,0 +1,95 @@
1
+ import type { Recommendation, Verdict } from "@/lib/analyst/types";
2
+
3
+ export type CardTone = "primary" | "green" | "amber" | "red" | "muted";
4
+
5
+ export interface CardView {
6
+ label: string;
7
+ tone: CardTone;
8
+ // Send the prompt as written (or edited).
9
+ canSend: boolean;
10
+ // A blocked or needs-you card has no Send; the user can still write one.
11
+ canWrite: boolean;
12
+ canDismiss: boolean;
13
+ canAskAgain: boolean;
14
+ // Why to look twice before sending, or what happened to it.
15
+ notes: string[];
16
+ }
17
+
18
+ const VERDICT_LABEL: Record<Verdict, [string, CardTone]> = {
19
+ continue: ["Next prompt", "primary"],
20
+ done: ["Looks done", "green"],
21
+ blocked: ["Blocked", "red"],
22
+ needs_you: ["Needs you", "amber"],
23
+ };
24
+
25
+ const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
26
+
27
+ /**
28
+ * What a review card shows and offers. `rec` is the stored card for the
29
+ * block, or null when there is none (an old review, an ended pairing): then
30
+ * it's read-only.
31
+ */
32
+ export function cardView(
33
+ rec: Recommendation | null,
34
+ verdict: Verdict | null,
35
+ coderLastTurn: string | null
36
+ ): CardView {
37
+ const [vLabel, vTone] = verdict
38
+ ? VERDICT_LABEL[verdict]
39
+ : (["Review", "muted"] as const);
40
+ const view: CardView = {
41
+ label: vLabel,
42
+ tone: vTone,
43
+ canSend: false,
44
+ canWrite: false,
45
+ canDismiss: false,
46
+ canAskAgain: false,
47
+ notes: [],
48
+ };
49
+ if (!rec) return view;
50
+
51
+ const s = rec.signals;
52
+ if (s?.noToolTurns && s.noToolTurns === s.turns)
53
+ view.notes.push(
54
+ s.turns === 1
55
+ ? "The coding agent's turn ended without a single tool call"
56
+ : `None of the ${plural(s.turns, "turn")} made a tool call`
57
+ );
58
+ if (s?.interrupted) view.notes.push("The coding agent was interrupted");
59
+
60
+ switch (rec.state) {
61
+ case "sent":
62
+ return {
63
+ ...view,
64
+ label: rec.edited ? "Sent, edited" : "Sent",
65
+ tone: "muted",
66
+ notes: [],
67
+ };
68
+ case "dismissed":
69
+ return { ...view, label: "Dismissed", tone: "muted", notes: [] };
70
+ case "superseded":
71
+ return { ...view, label: "Superseded", tone: "muted", notes: [] };
72
+ case "invalid":
73
+ return {
74
+ ...view,
75
+ label: "Couldn't read",
76
+ tone: "red",
77
+ canAskAgain: true,
78
+ canDismiss: true,
79
+ notes: [rec.error || "The review block was invalid"],
80
+ };
81
+ }
82
+
83
+ // Open.
84
+ if (rec.coderThrough && coderLastTurn && rec.coderThrough !== coderLastTurn)
85
+ view.notes.push("The coding agent has moved on since this review");
86
+ if (s?.readWeb)
87
+ view.notes.push("It read web content: check the prompt before sending");
88
+ const sendable = verdict === "continue" || verdict === "done";
89
+ return {
90
+ ...view,
91
+ canSend: sendable && !!rec.prompt,
92
+ canWrite: true,
93
+ canDismiss: true,
94
+ };
95
+ }