@stigmer/react 3.12.7 → 3.12.9

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 (196) hide show
  1. package/README.md +1 -3
  2. package/composer/ComposerToolbar.d.ts +1 -19
  3. package/composer/ComposerToolbar.d.ts.map +1 -1
  4. package/composer/ComposerToolbar.js +2 -7
  5. package/composer/ComposerToolbar.js.map +1 -1
  6. package/composer/SessionComposer.d.ts +0 -10
  7. package/composer/SessionComposer.d.ts.map +1 -1
  8. package/composer/SessionComposer.js +2 -2
  9. package/composer/SessionComposer.js.map +1 -1
  10. package/composer/index.d.ts +0 -1
  11. package/composer/index.d.ts.map +1 -1
  12. package/composer/index.js.map +1 -1
  13. package/conversation/ConversationTimelineView.d.ts +15 -1
  14. package/conversation/ConversationTimelineView.d.ts.map +1 -1
  15. package/conversation/ConversationTimelineView.js +3 -2
  16. package/conversation/ConversationTimelineView.js.map +1 -1
  17. package/conversation/ConversationsWorkbench.d.ts +14 -1
  18. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  19. package/conversation/ConversationsWorkbench.js +11 -2
  20. package/conversation/ConversationsWorkbench.js.map +1 -1
  21. package/execution/MessageThread.d.ts +33 -2
  22. package/execution/MessageThread.d.ts.map +1 -1
  23. package/execution/MessageThread.js +44 -6
  24. package/execution/MessageThread.js.map +1 -1
  25. package/execution/RecalledMemoriesCard.d.ts +47 -0
  26. package/execution/RecalledMemoriesCard.d.ts.map +1 -0
  27. package/execution/RecalledMemoriesCard.js +58 -0
  28. package/execution/RecalledMemoriesCard.js.map +1 -0
  29. package/execution/ResultView.d.ts.map +1 -1
  30. package/execution/ResultView.js +6 -0
  31. package/execution/ResultView.js.map +1 -1
  32. package/execution/ToolCallItem.d.ts.map +1 -1
  33. package/execution/ToolCallItem.js +13 -2
  34. package/execution/ToolCallItem.js.map +1 -1
  35. package/execution/index.d.ts +2 -0
  36. package/execution/index.d.ts.map +1 -1
  37. package/execution/index.js +1 -0
  38. package/execution/index.js.map +1 -1
  39. package/execution/tool-categories.d.ts +1 -1
  40. package/execution/tool-categories.d.ts.map +1 -1
  41. package/execution/tool-categories.js +6 -0
  42. package/execution/tool-categories.js.map +1 -1
  43. package/identity-account/AccountPreferencesPanel.d.ts +2 -1
  44. package/identity-account/AccountPreferencesPanel.d.ts.map +1 -1
  45. package/identity-account/AccountPreferencesPanel.js +41 -3
  46. package/identity-account/AccountPreferencesPanel.js.map +1 -1
  47. package/identity-account/useAccountExecutionDefaults.d.ts +7 -0
  48. package/identity-account/useAccountExecutionDefaults.d.ts.map +1 -1
  49. package/identity-account/useAccountExecutionDefaults.js +5 -2
  50. package/identity-account/useAccountExecutionDefaults.js.map +1 -1
  51. package/index.d.ts +6 -6
  52. package/index.d.ts.map +1 -1
  53. package/index.js +2 -2
  54. package/index.js.map +1 -1
  55. package/internal/MemoryEnabledRow.d.ts +37 -0
  56. package/internal/MemoryEnabledRow.d.ts.map +1 -0
  57. package/internal/MemoryEnabledRow.js +25 -0
  58. package/internal/MemoryEnabledRow.js.map +1 -0
  59. package/internal/VirtualizedThread.d.ts +3 -1
  60. package/internal/VirtualizedThread.d.ts.map +1 -1
  61. package/internal/VirtualizedThread.js +3 -1
  62. package/internal/VirtualizedThread.js.map +1 -1
  63. package/internal/useAutoScroll.d.ts +18 -0
  64. package/internal/useAutoScroll.d.ts.map +1 -1
  65. package/internal/useAutoScroll.js +50 -0
  66. package/internal/useAutoScroll.js.map +1 -1
  67. package/memory/MemoryProposalCard.d.ts +35 -0
  68. package/memory/MemoryProposalCard.d.ts.map +1 -0
  69. package/memory/MemoryProposalCard.js +73 -0
  70. package/memory/MemoryProposalCard.js.map +1 -0
  71. package/memory/index.d.ts +2 -0
  72. package/memory/index.d.ts.map +1 -1
  73. package/memory/index.js +2 -0
  74. package/memory/index.js.map +1 -1
  75. package/memory/useMemory.d.ts +39 -0
  76. package/memory/useMemory.d.ts.map +1 -0
  77. package/memory/useMemory.js +43 -0
  78. package/memory/useMemory.js.map +1 -0
  79. package/organization/OrgPreferencesPanel.d.ts.map +1 -1
  80. package/organization/OrgPreferencesPanel.js +36 -4
  81. package/organization/OrgPreferencesPanel.js.map +1 -1
  82. package/package.json +4 -4
  83. package/session/AutoApproveIndicator.d.ts +18 -0
  84. package/session/AutoApproveIndicator.d.ts.map +1 -0
  85. package/session/AutoApproveIndicator.js +23 -0
  86. package/session/AutoApproveIndicator.js.map +1 -0
  87. package/session/NewSessionViewer.d.ts.map +1 -1
  88. package/session/NewSessionViewer.js +10 -9
  89. package/session/NewSessionViewer.js.map +1 -1
  90. package/session/SessionViewer.d.ts +21 -9
  91. package/session/SessionViewer.d.ts.map +1 -1
  92. package/session/SessionViewer.js +31 -21
  93. package/session/SessionViewer.js.map +1 -1
  94. package/session/facets/SetupTab.d.ts +43 -6
  95. package/session/facets/SetupTab.d.ts.map +1 -1
  96. package/session/facets/SetupTab.js +32 -36
  97. package/session/facets/SetupTab.js.map +1 -1
  98. package/session/facets/UsageTab.d.ts +2 -1
  99. package/session/facets/UsageTab.d.ts.map +1 -1
  100. package/session/facets/UsageTab.js +11 -5
  101. package/session/facets/UsageTab.js.map +1 -1
  102. package/session/facets/index.d.ts +1 -1
  103. package/session/facets/index.d.ts.map +1 -1
  104. package/session/facets/primitives.d.ts +58 -0
  105. package/session/facets/primitives.d.ts.map +1 -0
  106. package/session/facets/primitives.js +59 -0
  107. package/session/facets/primitives.js.map +1 -0
  108. package/session/index.d.ts +1 -1
  109. package/session/index.d.ts.map +1 -1
  110. package/session/index.js.map +1 -1
  111. package/session/useNewSessionFlow.d.ts +9 -4
  112. package/session/useNewSessionFlow.d.ts.map +1 -1
  113. package/session/useNewSessionFlow.js +20 -9
  114. package/session/useNewSessionFlow.js.map +1 -1
  115. package/session/useSessionPageFlow.d.ts +28 -10
  116. package/session/useSessionPageFlow.d.ts.map +1 -1
  117. package/session/useSessionPageFlow.js +16 -10
  118. package/session/useSessionPageFlow.js.map +1 -1
  119. package/settings/MemorySection.d.ts.map +1 -1
  120. package/settings/MemorySection.js +1 -1
  121. package/settings/MemorySection.js.map +1 -1
  122. package/src/attachment/__tests__/prepare-image.test.ts +12 -6
  123. package/src/composer/ComposerToolbar.tsx +0 -57
  124. package/src/composer/SessionComposer.tsx +1 -13
  125. package/src/composer/index.ts +0 -1
  126. package/src/conversation/ConversationTimelineView.tsx +17 -1
  127. package/src/conversation/ConversationsWorkbench.tsx +25 -0
  128. package/src/conversation/__tests__/ConversationTimelineView.layout.test.tsx +37 -1
  129. package/src/execution/MessageThread.tsx +75 -3
  130. package/src/execution/RecalledMemoriesCard.tsx +164 -0
  131. package/src/execution/ResultView.tsx +6 -0
  132. package/src/execution/ToolCallItem.tsx +18 -1
  133. package/src/execution/__tests__/RecalledMemoriesCard.test.tsx +123 -0
  134. package/src/execution/__tests__/message-thread-recalled-memories.test.tsx +216 -0
  135. package/src/execution/__tests__/message-thread-scroll-on-send.test.tsx +141 -0
  136. package/src/execution/__tests__/message-thread-slots.test.tsx +36 -0
  137. package/src/execution/index.ts +2 -0
  138. package/src/execution/tool-categories.ts +7 -0
  139. package/src/identity-account/AccountPreferencesPanel.tsx +89 -2
  140. package/src/identity-account/__tests__/AccountPreferencesPanel.test.tsx +112 -0
  141. package/src/identity-account/__tests__/useAccountExecutionDefaults.test.tsx +34 -1
  142. package/src/identity-account/useAccountExecutionDefaults.ts +12 -2
  143. package/src/index.ts +8 -1
  144. package/src/internal/MemoryEnabledRow.tsx +92 -0
  145. package/src/internal/VirtualizedThread.tsx +5 -0
  146. package/src/internal/__tests__/useAutoScroll.layout.test.tsx +105 -4
  147. package/src/internal/__tests__/useAutoScroll.test.tsx +41 -1
  148. package/src/internal/useAutoScroll.ts +54 -0
  149. package/src/memory/MemoryProposalCard.tsx +181 -0
  150. package/src/memory/__tests__/MemoryProposalCard.test.tsx +236 -0
  151. package/src/memory/__tests__/memoryHooks.test.tsx +63 -0
  152. package/src/memory/index.ts +5 -0
  153. package/src/memory/useMemory.ts +80 -0
  154. package/src/organization/OrgPreferencesPanel.tsx +52 -3
  155. package/src/organization/__tests__/OrgPreferencesPanel.test.tsx +65 -0
  156. package/src/session/AutoApproveIndicator.tsx +45 -0
  157. package/src/session/NewSessionViewer.tsx +18 -15
  158. package/src/session/SessionViewer.tsx +70 -32
  159. package/src/session/__tests__/NewSessionViewer.test.tsx +32 -0
  160. package/src/session/__tests__/SessionViewer-autoApprove.test.tsx +244 -0
  161. package/src/session/__tests__/SessionViewer-transcriptExport.test.tsx +60 -22
  162. package/src/session/__tests__/useNewSessionFlow.test.tsx +56 -0
  163. package/src/session/__tests__/useSessionPageFlow.autoApprove.test.tsx +87 -0
  164. package/src/session/facets/SetupTab.tsx +193 -157
  165. package/src/session/facets/UsageTab.tsx +38 -38
  166. package/src/session/facets/index.ts +1 -0
  167. package/src/session/facets/primitives.tsx +169 -0
  168. package/src/session/index.ts +1 -0
  169. package/src/session/useNewSessionFlow.ts +30 -15
  170. package/src/session/useSessionPageFlow.ts +44 -20
  171. package/src/settings/MemorySection.tsx +4 -2
  172. package/src/switch/Switch.tsx +4 -0
  173. package/src/workflow/__tests__/WorkflowExecutionViewer.approvals.test.tsx +26 -7
  174. package/src/workflow/__tests__/task-presentation.test.ts +49 -12
  175. package/src/workflow/__tests__/useWorkflowCanvas.dirty.test.tsx +7 -2
  176. package/src/workflow/__tests__/useWorkflowCanvas.drag.test.tsx +7 -2
  177. package/src/workflow/__tests__/workflow-thread-scroll-on-send.test.tsx +145 -0
  178. package/src/workflow/thread/WorkflowTaskThread.tsx +59 -32
  179. package/src/workflow/thread/task-presentation.ts +52 -7
  180. package/src/workspace/WorkspaceSurface.tsx +26 -3
  181. package/styles.css +1 -1
  182. package/switch/Switch.d.ts +3 -1
  183. package/switch/Switch.d.ts.map +1 -1
  184. package/switch/Switch.js +2 -2
  185. package/switch/Switch.js.map +1 -1
  186. package/workflow/thread/WorkflowTaskThread.d.ts +13 -0
  187. package/workflow/thread/WorkflowTaskThread.d.ts.map +1 -1
  188. package/workflow/thread/WorkflowTaskThread.js +33 -25
  189. package/workflow/thread/WorkflowTaskThread.js.map +1 -1
  190. package/workflow/thread/task-presentation.d.ts.map +1 -1
  191. package/workflow/thread/task-presentation.js +50 -7
  192. package/workflow/thread/task-presentation.js.map +1 -1
  193. package/workspace/WorkspaceSurface.js +11 -2
  194. package/workspace/WorkspaceSurface.js.map +1 -1
  195. package/src/composer/__tests__/ComposerToolbar-autoApprove.test.tsx +0 -80
  196. package/src/session/__tests__/SessionViewer-autoApproveToggle.test.tsx +0 -178
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ import { memo, useId, useMemo, useState } from "react";
4
+ import type { RecalledMemoriesReport } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import type { RecalledMemoryFact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
6
+ import { cn } from "@stigmer/theme";
7
+
8
+ /**
9
+ * Resolve the report's injected memory ids against the execution's
10
+ * snapshot facts, preserving snapshot order (the stable prompt order —
11
+ * the selector re-sorts its subset the same way, so this join renders
12
+ * the facts exactly as the agent saw them).
13
+ *
14
+ * The contract guarantees injected ids are a subset of the snapshot
15
+ * (the merge path's never-invent pin), so an unknown id can only mean a
16
+ * bug upstream — it is skipped, never invented. The summary count stays
17
+ * the wire truth (`injectedMemoryIds.length`) regardless.
18
+ */
19
+ export function resolveInjectedFacts(
20
+ report: RecalledMemoriesReport,
21
+ facts: readonly RecalledMemoryFact[],
22
+ ): RecalledMemoryFact[] {
23
+ const injected = new Set(report.injectedMemoryIds);
24
+ return facts.filter((fact) => injected.has(fact.memoryId));
25
+ }
26
+
27
+ /** Props for {@link RecalledMemoriesCard}. */
28
+ export interface RecalledMemoriesCardProps {
29
+ /** The runner's injection report from the execution's status. */
30
+ readonly report: RecalledMemoriesReport;
31
+ /** The full candidate set from `spec.recalled_memories.facts`. */
32
+ readonly facts: readonly RecalledMemoryFact[];
33
+ /** Additional CSS classes for the root element. */
34
+ readonly className?: string;
35
+ }
36
+
37
+ /**
38
+ * Inline timeline card disclosing the semantic retriever's selection for
39
+ * one execution: "Recalled N of M memories", expandable to the injected
40
+ * facts (stigmer/stigmer#293 Phase 3a, DD-008 D5's transparency promise).
41
+ *
42
+ * Renders at the top of the execution's {@link MessageThread} segment,
43
+ * right after the user's turn — `spec.message` is the query the
44
+ * retriever embedded, so the card reads as "for this message, these
45
+ * memories were most relevant".
46
+ *
47
+ * Renders NOTHING unless the report says selection was active: an
48
+ * absent report and `selection_active=false` both mean wholesale (every
49
+ * snapshot fact injected — the shipped Phase 2 behavior), and unchanged
50
+ * behavior earns no UI noise. The same rule is applied by the thread
51
+ * builder; it is repeated here so directly-embedding platform builders
52
+ * get the correct contract by construction.
53
+ *
54
+ * All visual properties flow through `--stgm-*` tokens.
55
+ *
56
+ * @see resolveInjectedFacts - the report→snapshot join this card renders
57
+ * @see SummarizationCard - the sibling system-event card this follows
58
+ */
59
+ export const RecalledMemoriesCard = memo(function RecalledMemoriesCard({
60
+ report,
61
+ facts,
62
+ className,
63
+ }: RecalledMemoriesCardProps) {
64
+ const [expanded, setExpanded] = useState(false);
65
+ const listId = useId();
66
+
67
+ const injectedFacts = useMemo(
68
+ () => resolveInjectedFacts(report, facts),
69
+ [report, facts],
70
+ );
71
+
72
+ if (!report.selectionActive) return null;
73
+
74
+ const injectedCount = report.injectedMemoryIds.length;
75
+ const summary = `Recalled ${injectedCount} of ${facts.length} memories`;
76
+
77
+ return (
78
+ <div
79
+ role="status"
80
+ aria-label={summary}
81
+ className={cn(
82
+ "stg:mx-4 stg:rounded-md stg:border stg:border-border/50 stg:bg-muted/30",
83
+ className,
84
+ )}
85
+ >
86
+ <button
87
+ type="button"
88
+ aria-expanded={expanded}
89
+ aria-controls={listId}
90
+ onClick={() => setExpanded((prev) => !prev)}
91
+ className={cn(
92
+ "stg:flex stg:w-full stg:items-center stg:gap-3 stg:px-3 stg:py-2 stg:text-left",
93
+ "stg:text-xs stg:text-muted-foreground hover:stg:text-foreground",
94
+ )}
95
+ >
96
+ <MemoryIcon />
97
+ <span className="stg:flex stg:min-w-0 stg:flex-1 stg:flex-wrap stg:items-baseline stg:gap-x-2">
98
+ <span className="stg:font-medium">{summary}</span>
99
+ {report.embeddingModel && (
100
+ <>
101
+ <span aria-hidden="true" className="stg:text-muted-foreground/50">&middot;</span>
102
+ <span className="stg:text-muted-foreground/80">{report.embeddingModel}</span>
103
+ </>
104
+ )}
105
+ </span>
106
+ <ChevronIcon expanded={expanded} />
107
+ </button>
108
+ {expanded && (
109
+ <ul
110
+ id={listId}
111
+ className="stg:m-0 stg:flex stg:list-none stg:flex-col stg:gap-1.5 stg:border-t stg:border-border/50 stg:px-3 stg:py-2"
112
+ >
113
+ {injectedFacts.map((fact) => (
114
+ <li
115
+ key={fact.memoryId}
116
+ className="stg:text-xs stg:leading-relaxed stg:text-muted-foreground"
117
+ >
118
+ {fact.content}
119
+ </li>
120
+ ))}
121
+ </ul>
122
+ )}
123
+ </div>
124
+ );
125
+ });
126
+
127
+ function MemoryIcon() {
128
+ return (
129
+ <svg
130
+ width="16"
131
+ height="16"
132
+ viewBox="0 0 16 16"
133
+ fill="none"
134
+ stroke="currentColor"
135
+ strokeWidth="1.5"
136
+ strokeLinecap="round"
137
+ strokeLinejoin="round"
138
+ className="stg:shrink-0 stg:text-muted-foreground/70"
139
+ aria-hidden="true"
140
+ >
141
+ <path d="M8 2a3 3 0 0 0-3 3c-1.2.4-2 1.5-2 2.8 0 1 .5 1.9 1.3 2.4A2.8 2.8 0 0 0 7 13.8c.4.1.7.2 1 .2s.6-.1 1-.2a2.8 2.8 0 0 0 2.7-3.6c.8-.5 1.3-1.4 1.3-2.4 0-1.3-.8-2.4-2-2.8a3 3 0 0 0-3-3z" />
142
+ <path d="M8 2v12" />
143
+ </svg>
144
+ );
145
+ }
146
+
147
+ function ChevronIcon({ expanded }: { expanded: boolean }) {
148
+ return (
149
+ <svg
150
+ width="14"
151
+ height="14"
152
+ viewBox="0 0 16 16"
153
+ fill="none"
154
+ stroke="currentColor"
155
+ strokeWidth="1.5"
156
+ strokeLinecap="round"
157
+ strokeLinejoin="round"
158
+ className="stg:shrink-0 stg:text-muted-foreground/70"
159
+ aria-hidden="true"
160
+ >
161
+ {expanded ? <path d="M4 10l4-4 4 4" /> : <path d="M4 6l4 4 4-4" />}
162
+ </svg>
163
+ );
164
+ }
@@ -80,6 +80,12 @@ export function ResultView({ view, showFileName = true, showStats = true, classN
80
80
  return view.text ? <CollapsiblePre content={view.text} className={cn("stg:text-foreground", className)} /> : null;
81
81
  case "json":
82
82
  return <CollapsibleCode label="Result" content={formatJson(view.value)} className={className} />;
83
+ case "memoryProposal":
84
+ // Fallback for consumers composing ResultView directly: the verbatim
85
+ // proposed fact (DD-005 D6 — never paraphrased). The consent chip
86
+ // (Confirm/Reject, live lifecycle state) is the thread's primary
87
+ // surface for this view and renders it in place of this.
88
+ return <CollapsiblePre content={view.fact} className={cn("stg:text-foreground", className)} />;
83
89
  case "error":
84
90
  return <ErrorResultView message={view.message} className={className} />;
85
91
  case "empty":
@@ -26,6 +26,7 @@ import { TerminalTail } from "./TerminalSession.js";
26
26
  import { ToolCallDetail, formatHeaderDuration } from "./ToolCallDetail.js";
27
27
  import { SubAgentSection } from "./SubAgentSection.js";
28
28
  import { ApprovalCardBody } from "./ApprovalCard.js";
29
+ import { MemoryProposalCardBody } from "../memory/MemoryProposalCard.js";
29
30
  import { useApproval } from "./ApprovalContext.js";
30
31
  import { useFileReviewRowState } from "./FileReviewContext.js";
31
32
  import type { FileReviewRowState } from "./file-review-status.js";
@@ -351,7 +352,8 @@ export const ToolCallItem = memo(function ToolCallItem({
351
352
  (disclosure === "preview" || (isTailCategory && tailSession == null));
352
353
 
353
354
  // The disclosed body, shared by both layouts: a pending gate decides inline,
354
- // a sub-agent shows its delegation, otherwise the category detail.
355
+ // a sub-agent shows its delegation, a settled remember call renders the
356
+ // memory-proposal consent surface, otherwise the category detail.
355
357
  const body = isSubAgent ? (
356
358
  <SubAgentSection subAgentExecution={subAgentExecution} collapsible={false} />
357
359
  ) : approval ? (
@@ -364,6 +366,12 @@ export const ToolCallItem = memo(function ToolCallItem({
364
366
  isSubmitting={approval.isSubmitting}
365
367
  error={approval.error}
366
368
  />
369
+ ) : result.type === "memoryProposal" ? (
370
+ // The remember tool's consent chip (DD-005 D4): verbatim fact +
371
+ // Confirm/Reject, with the record's CURRENT lifecycle fetched so a
372
+ // reloaded thread never shows stale action buttons. Borderless — this
373
+ // row's card owns the chrome (the ApprovalCardBody posture).
374
+ <MemoryProposalCardBody memoryId={result.memoryId} fact={result.fact} />
367
375
  ) : (
368
376
  <ToolCallDetail
369
377
  toolCall={toolCall}
@@ -608,9 +616,18 @@ export const CATEGORY_ICON: Record<ToolCategory, () => React.JSX.Element> = {
608
616
  "sub-agent": BotIcon,
609
617
  internal: WrenchIcon,
610
618
  mcp: McpPlugIcon,
619
+ memory: BookmarkIcon,
611
620
  unknown: WrenchIcon,
612
621
  };
613
622
 
623
+ function BookmarkIcon() {
624
+ return (
625
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
626
+ <path d="M9 11L6 8.5L3 11V2.5C3 1.95 3.45 1.5 4 1.5H8C8.55 1.5 9 1.95 9 2.5V11Z" />
627
+ </svg>
628
+ );
629
+ }
630
+
614
631
  function GlobeIcon() {
615
632
  return (
616
633
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
@@ -0,0 +1,123 @@
1
+ // The retriever transparency card (stigmer/stigmer#293 Phase 3a, DD-008
2
+ // D5): an honest "Recalled N of M memories" join of the status report
3
+ // against the spec snapshot — and NOTHING for wholesale, the majority
4
+ // case that must stay noise-free.
5
+
6
+ import { describe, it, expect, afterEach } from "vitest";
7
+ import { cleanup, render, screen } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import { create } from "@bufbuild/protobuf";
10
+ import {
11
+ RecalledMemoriesReportSchema,
12
+ type RecalledMemoriesReport,
13
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
14
+ import {
15
+ RecalledMemoryFactSchema,
16
+ type RecalledMemoryFact,
17
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
18
+ import {
19
+ RecalledMemoriesCard,
20
+ resolveInjectedFacts,
21
+ } from "../RecalledMemoriesCard";
22
+
23
+ function makeFact(id: string, content: string): RecalledMemoryFact {
24
+ return create(RecalledMemoryFactSchema, { memoryId: id, content });
25
+ }
26
+
27
+ function makeReport(
28
+ overrides: Partial<Pick<RecalledMemoriesReport, "selectionActive" | "injectedMemoryIds" | "embeddingModel">>,
29
+ ): RecalledMemoriesReport {
30
+ return create(RecalledMemoriesReportSchema, {
31
+ selectionActive: true,
32
+ embeddingModel: "text-embedding-3-small",
33
+ ...overrides,
34
+ });
35
+ }
36
+
37
+ const SNAPSHOT: RecalledMemoryFact[] = [
38
+ makeFact("mem_a", "Prefers concise answers."),
39
+ makeFact("mem_b", "Works in the Europe/Berlin timezone."),
40
+ makeFact("mem_c", "Deploys with Bazel."),
41
+ makeFact("mem_d", "Reviews PRs on Fridays."),
42
+ ];
43
+
44
+ afterEach(cleanup);
45
+
46
+ describe("resolveInjectedFacts", () => {
47
+ it("preserves snapshot order regardless of the report's id order", () => {
48
+ const report = makeReport({ injectedMemoryIds: ["mem_d", "mem_a"] });
49
+ expect(resolveInjectedFacts(report, SNAPSHOT).map((f) => f.memoryId)).toEqual([
50
+ "mem_a",
51
+ "mem_d",
52
+ ]);
53
+ });
54
+
55
+ it("skips an unknown id — never invents a fact", () => {
56
+ const report = makeReport({ injectedMemoryIds: ["mem_b", "mem_ghost"] });
57
+ expect(resolveInjectedFacts(report, SNAPSHOT).map((f) => f.memoryId)).toEqual([
58
+ "mem_b",
59
+ ]);
60
+ });
61
+
62
+ it("returns nothing against an empty snapshot", () => {
63
+ const report = makeReport({ injectedMemoryIds: ["mem_a"] });
64
+ expect(resolveInjectedFacts(report, [])).toEqual([]);
65
+ });
66
+ });
67
+
68
+ describe("RecalledMemoriesCard", () => {
69
+ it("renders nothing for a wholesale report (selection_active=false)", () => {
70
+ const report = makeReport({
71
+ selectionActive: false,
72
+ injectedMemoryIds: [],
73
+ embeddingModel: "",
74
+ });
75
+ const { container } = render(
76
+ <RecalledMemoriesCard report={report} facts={SNAPSHOT} />,
77
+ );
78
+ expect(container.innerHTML).toBe("");
79
+ });
80
+
81
+ it("summarizes the selection with the wire count and the embedding model", () => {
82
+ const report = makeReport({ injectedMemoryIds: ["mem_a", "mem_c"] });
83
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
84
+
85
+ expect(screen.getByRole("status", { name: "Recalled 2 of 4 memories" })).toBeTruthy();
86
+ expect(screen.getByText("text-embedding-3-small")).toBeTruthy();
87
+ // Collapsed by default: the facts are behind the disclosure.
88
+ expect(screen.queryByText("Prefers concise answers.")).toBeNull();
89
+ });
90
+
91
+ it("expands to exactly the injected facts, in snapshot order", async () => {
92
+ const report = makeReport({ injectedMemoryIds: ["mem_c", "mem_a"] });
93
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
94
+ const user = userEvent.setup();
95
+
96
+ const toggle = screen.getByRole("button", { expanded: false });
97
+ expect(toggle.getAttribute("aria-controls")).toBeTruthy();
98
+
99
+ await user.click(toggle);
100
+
101
+ expect(toggle.getAttribute("aria-expanded")).toBe("true");
102
+ const items = screen.getAllByRole("listitem").map((li) => li.textContent);
103
+ expect(items).toEqual(["Prefers concise answers.", "Deploys with Bazel."]);
104
+ // Non-injected snapshot facts never render.
105
+ expect(screen.queryByText("Works in the Europe/Berlin timezone.")).toBeNull();
106
+
107
+ await user.click(toggle);
108
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
109
+ expect(screen.queryByRole("listitem")).toBeNull();
110
+ });
111
+
112
+ it("keeps the summary count honest when an id cannot be resolved", async () => {
113
+ // Contractually impossible (injected ids ⊂ snapshot, the merge path's
114
+ // never-invent pin) — but if it ever happens, the count stays the wire
115
+ // truth and the list simply omits the unresolvable entry.
116
+ const report = makeReport({ injectedMemoryIds: ["mem_b", "mem_ghost"] });
117
+ render(<RecalledMemoriesCard report={report} facts={SNAPSHOT} />);
118
+
119
+ expect(screen.getByRole("status", { name: "Recalled 2 of 4 memories" })).toBeTruthy();
120
+ await userEvent.setup().click(screen.getByRole("button"));
121
+ expect(screen.getAllByRole("listitem")).toHaveLength(1);
122
+ });
123
+ });
@@ -0,0 +1,216 @@
1
+ // Thread emission rules for the retriever transparency card
2
+ // (stigmer/stigmer#293 Phase 3a, DD-008 D5). The load-bearing pins:
3
+ //
4
+ // 1. The card is a PER-SEGMENT item derived from each execution's own
5
+ // spec+status — NOT a live-only tail indicator like setup-progress.
6
+ // A historical (non-active) execution keeps its card forever; that
7
+ // is the audit property, and the regression this file exists to
8
+ // catch (a live-only mechanism would render correctly during
9
+ // streaming and silently lose the record afterwards).
10
+ // 2. Absent report and selection_active=false both render NOTHING —
11
+ // wholesale is the unchanged majority behavior and earns no noise.
12
+ // 3. The card is gated on the REPORT, not the prompt bubble:
13
+ // syntheticUserPrompt deliberately skips some user turns (empty
14
+ // prompt), and those executions can still be selection-active.
15
+
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
17
+ import { render, screen, cleanup } from "@testing-library/react";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import {
20
+ AgentExecutionSchema,
21
+ AgentExecutionStatusSchema,
22
+ RecalledMemoriesReportSchema,
23
+ type AgentExecution,
24
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
25
+ import {
26
+ AgentExecutionSpecSchema,
27
+ RecalledMemoriesSchema,
28
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/spec_pb";
29
+ import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
30
+ import { AgentMessageSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
31
+ import {
32
+ ExecutionPhase,
33
+ MessageType,
34
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
+
36
+ import { MessageThread } from "../MessageThread";
37
+
38
+ // useAutoScroll depends on browser APIs not available in happy-dom.
39
+ beforeEach(() => {
40
+ vi.stubGlobal(
41
+ "IntersectionObserver",
42
+ vi.fn(() => ({
43
+ observe: vi.fn(),
44
+ unobserve: vi.fn(),
45
+ disconnect: vi.fn(),
46
+ takeRecords: vi.fn(() => []),
47
+ root: null,
48
+ rootMargin: "",
49
+ thresholds: [0],
50
+ })),
51
+ );
52
+ vi.stubGlobal(
53
+ "ResizeObserver",
54
+ vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
55
+ );
56
+ });
57
+
58
+ afterEach(() => {
59
+ cleanup();
60
+ vi.restoreAllMocks();
61
+ });
62
+
63
+ function makeExecution(opts: {
64
+ id: string;
65
+ specMessage?: string;
66
+ facts?: ReadonlyArray<{ id: string; content: string }>;
67
+ report?: { selectionActive: boolean; injectedMemoryIds?: string[] };
68
+ }): AgentExecution {
69
+ const exec = create(AgentExecutionSchema);
70
+ exec.metadata = create(ApiResourceMetadataSchema, { id: opts.id });
71
+
72
+ const spec = create(AgentExecutionSpecSchema);
73
+ spec.message = opts.specMessage ?? "Hello";
74
+ if (opts.facts) {
75
+ spec.recalledMemories = create(RecalledMemoriesSchema, {
76
+ enabled: true,
77
+ facts: opts.facts.map((f) => ({ memoryId: f.id, content: f.content })),
78
+ });
79
+ }
80
+ exec.spec = spec;
81
+
82
+ const status = create(AgentExecutionStatusSchema);
83
+ status.phase = ExecutionPhase.EXECUTION_COMPLETED;
84
+ status.messages = [
85
+ create(AgentMessageSchema, { type: MessageType.MESSAGE_AI, content: "Done." }),
86
+ ];
87
+ if (opts.report) {
88
+ status.recalledMemoriesReport = create(RecalledMemoriesReportSchema, {
89
+ selectionActive: opts.report.selectionActive,
90
+ injectedMemoryIds: opts.report.injectedMemoryIds ?? [],
91
+ embeddingModel: opts.report.selectionActive ? "text-embedding-3-small" : "",
92
+ });
93
+ }
94
+ exec.status = status;
95
+ return exec;
96
+ }
97
+
98
+ const FACTS = [
99
+ { id: "mem_a", content: "Prefers concise answers." },
100
+ { id: "mem_b", content: "Deploys with Bazel." },
101
+ { id: "mem_c", content: "Reviews PRs on Fridays." },
102
+ ];
103
+
104
+ describe("MessageThread recalled-memories item", () => {
105
+ it("emits the card inside a selection-active execution's segment, after the user turn", () => {
106
+ render(
107
+ <MessageThread
108
+ executions={[
109
+ makeExecution({
110
+ id: "e1",
111
+ specMessage: "prompt text",
112
+ facts: FACTS,
113
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a", "mem_c"] },
114
+ }),
115
+ ]}
116
+ />,
117
+ );
118
+
119
+ const card = screen.getByRole("status", { name: "Recalled 2 of 3 memories" });
120
+ const userBubble = screen.getByRole("article", { name: "User message" });
121
+ // Segment order: user turn first, then the card the turn's prompt selected.
122
+ expect(
123
+ userBubble.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING,
124
+ ).toBeTruthy();
125
+ });
126
+
127
+ it("emits nothing for an absent report (pre-3a and no-injection executions read identically)", () => {
128
+ render(
129
+ <MessageThread
130
+ executions={[makeExecution({ id: "e1", facts: FACTS })]}
131
+ />,
132
+ );
133
+ expect(screen.queryByRole("status", { name: /Recalled/ })).toBeNull();
134
+ });
135
+
136
+ it("emits nothing for a wholesale report (selection_active=false)", () => {
137
+ render(
138
+ <MessageThread
139
+ executions={[
140
+ makeExecution({
141
+ id: "e1",
142
+ facts: FACTS,
143
+ report: { selectionActive: false },
144
+ }),
145
+ ]}
146
+ />,
147
+ );
148
+ expect(screen.queryByRole("status", { name: /Recalled/ })).toBeNull();
149
+ });
150
+
151
+ it("keeps the card on a HISTORICAL execution while another execution streams", () => {
152
+ // The audit pin: the card derives from the execution's own segment, so
153
+ // it must survive the execution no longer being the active stream — a
154
+ // live-only mechanism (the setup-progress shape) would lose it here.
155
+ const historical = makeExecution({
156
+ id: "e-old",
157
+ specMessage: "earlier turn",
158
+ facts: FACTS,
159
+ report: { selectionActive: true, injectedMemoryIds: ["mem_b"] },
160
+ });
161
+ const live = makeExecution({ id: "e-live", specMessage: "current turn" });
162
+ live.status!.phase = ExecutionPhase.EXECUTION_IN_PROGRESS;
163
+
164
+ render(
165
+ <MessageThread executions={[historical]} activeStreamExecution={live} />,
166
+ );
167
+
168
+ expect(
169
+ screen.getByRole("status", { name: "Recalled 1 of 3 memories" }),
170
+ ).toBeTruthy();
171
+ });
172
+
173
+ it("emits one card per selection-active execution", () => {
174
+ render(
175
+ <MessageThread
176
+ executions={[
177
+ makeExecution({
178
+ id: "e1",
179
+ facts: FACTS,
180
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a"] },
181
+ }),
182
+ makeExecution({ id: "e2", facts: FACTS }),
183
+ makeExecution({
184
+ id: "e3",
185
+ facts: FACTS,
186
+ report: { selectionActive: true, injectedMemoryIds: ["mem_a", "mem_b"] },
187
+ }),
188
+ ]}
189
+ />,
190
+ );
191
+
192
+ expect(screen.getByRole("status", { name: "Recalled 1 of 3 memories" })).toBeTruthy();
193
+ expect(screen.getByRole("status", { name: "Recalled 2 of 3 memories" })).toBeTruthy();
194
+ expect(screen.getAllByRole("status", { name: /Recalled/ })).toHaveLength(2);
195
+ });
196
+
197
+ it("emits the card even when the user-turn bubble is skipped (empty prompt)", () => {
198
+ render(
199
+ <MessageThread
200
+ executions={[
201
+ makeExecution({
202
+ id: "e1",
203
+ specMessage: "",
204
+ facts: FACTS,
205
+ report: { selectionActive: true, injectedMemoryIds: ["mem_c"] },
206
+ }),
207
+ ]}
208
+ />,
209
+ );
210
+
211
+ expect(screen.queryByRole("article", { name: "User message" })).toBeNull();
212
+ expect(
213
+ screen.getByRole("status", { name: "Recalled 1 of 3 memories" }),
214
+ ).toBeTruthy();
215
+ });
216
+ });