stagent 0.1.10 → 0.1.12

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 (112) hide show
  1. package/README.md +58 -27
  2. package/package.json +3 -3
  3. package/src/__tests__/e2e/blueprint.test.ts +63 -0
  4. package/src/__tests__/e2e/cross-runtime.test.ts +77 -0
  5. package/src/__tests__/e2e/helpers.ts +286 -0
  6. package/src/__tests__/e2e/parallel-workflow.test.ts +120 -0
  7. package/src/__tests__/e2e/sequence-workflow.test.ts +109 -0
  8. package/src/__tests__/e2e/setup.ts +156 -0
  9. package/src/__tests__/e2e/single-task.test.ts +170 -0
  10. package/src/app/api/command-palette/recent/route.ts +41 -18
  11. package/src/app/api/context/batch/route.ts +44 -0
  12. package/src/app/api/permissions/presets/route.ts +80 -0
  13. package/src/app/api/playbook/status/route.ts +15 -0
  14. package/src/app/api/profiles/route.ts +23 -21
  15. package/src/app/api/settings/pricing/route.ts +15 -0
  16. package/src/app/costs/page.tsx +53 -43
  17. package/src/app/globals.css +0 -5
  18. package/src/app/playbook/[slug]/page.tsx +76 -0
  19. package/src/app/playbook/page.tsx +54 -0
  20. package/src/app/profiles/page.tsx +7 -4
  21. package/src/app/settings/page.tsx +2 -2
  22. package/src/app/tasks/page.tsx +5 -0
  23. package/src/components/costs/cost-dashboard.tsx +226 -320
  24. package/src/components/dashboard/activity-feed.tsx +6 -2
  25. package/src/components/notifications/batch-proposal-review.tsx +150 -0
  26. package/src/components/notifications/notification-item.tsx +6 -3
  27. package/src/components/notifications/pending-approval-host.tsx +57 -11
  28. package/src/components/playbook/adoption-heatmap.tsx +69 -0
  29. package/src/components/playbook/journey-card.tsx +110 -0
  30. package/src/components/playbook/playbook-action-button.tsx +22 -0
  31. package/src/components/playbook/playbook-browser.tsx +143 -0
  32. package/src/components/playbook/playbook-card.tsx +102 -0
  33. package/src/components/playbook/playbook-detail-view.tsx +223 -0
  34. package/src/components/playbook/playbook-homepage.tsx +142 -0
  35. package/src/components/playbook/playbook-toc.tsx +90 -0
  36. package/src/components/playbook/playbook-updated-badge.tsx +23 -0
  37. package/src/components/playbook/related-docs.tsx +30 -0
  38. package/src/components/profiles/__tests__/learned-context-panel.test.tsx +175 -0
  39. package/src/components/profiles/context-proposal-review.tsx +7 -3
  40. package/src/components/profiles/learned-context-panel.tsx +116 -8
  41. package/src/components/profiles/profile-detail-view.tsx +7 -19
  42. package/src/components/profiles/profile-form-view.tsx +0 -22
  43. package/src/components/settings/__tests__/auth-config-section.test.tsx +147 -0
  44. package/src/components/settings/api-key-form.tsx +5 -43
  45. package/src/components/settings/auth-config-section.tsx +10 -6
  46. package/src/components/settings/auth-status-badge.tsx +8 -0
  47. package/src/components/settings/budget-guardrails-section.tsx +403 -620
  48. package/src/components/settings/connection-test-control.tsx +63 -0
  49. package/src/components/settings/permissions-section.tsx +85 -75
  50. package/src/components/settings/permissions-sections.tsx +24 -0
  51. package/src/components/settings/presets-section.tsx +159 -0
  52. package/src/components/settings/pricing-registry-panel.tsx +164 -0
  53. package/src/components/shared/app-sidebar.tsx +2 -0
  54. package/src/components/shared/command-palette.tsx +30 -0
  55. package/src/components/shared/light-markdown.tsx +134 -0
  56. package/src/components/workflows/loop-status-view.tsx +8 -4
  57. package/src/components/workflows/workflow-status-view.tsx +16 -9
  58. package/src/lib/agents/__tests__/claude-agent.test.ts +7 -2
  59. package/src/lib/agents/__tests__/learned-context.test.ts +500 -0
  60. package/src/lib/agents/__tests__/pattern-extractor.test.ts +243 -0
  61. package/src/lib/agents/__tests__/sweep.test.ts +202 -0
  62. package/src/lib/agents/claude-agent.ts +104 -78
  63. package/src/lib/agents/learned-context.ts +32 -28
  64. package/src/lib/agents/learning-session.ts +234 -0
  65. package/src/lib/agents/pattern-extractor.ts +34 -64
  66. package/src/lib/agents/profiles/__tests__/sort.test.ts +42 -0
  67. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +0 -1
  68. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +0 -1
  69. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +0 -1
  70. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +0 -1
  71. package/src/lib/agents/profiles/builtins/general/profile.yaml +0 -1
  72. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +0 -1
  73. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +0 -1
  74. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +0 -1
  75. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +0 -1
  76. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +0 -1
  77. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +0 -1
  78. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +0 -1
  79. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +0 -1
  80. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +0 -1
  81. package/src/lib/agents/profiles/registry.ts +0 -1
  82. package/src/lib/agents/profiles/sort.ts +7 -0
  83. package/src/lib/agents/profiles/types.ts +0 -1
  84. package/src/lib/agents/runtime/catalog.ts +1 -1
  85. package/src/lib/agents/runtime/claude.ts +66 -0
  86. package/src/lib/constants/settings.ts +1 -0
  87. package/src/lib/constants/task-status.ts +6 -0
  88. package/src/lib/data/seed-data/profiles.ts +0 -3
  89. package/src/lib/db/schema.ts +3 -0
  90. package/src/lib/docs/adoption.ts +105 -0
  91. package/src/lib/docs/journey-tracker.ts +21 -0
  92. package/src/lib/docs/reader.ts +102 -0
  93. package/src/lib/docs/types.ts +54 -0
  94. package/src/lib/docs/usage-stage.ts +60 -0
  95. package/src/lib/notifications/actionable.ts +18 -10
  96. package/src/lib/settings/__tests__/budget-guardrails.test.ts +86 -24
  97. package/src/lib/settings/budget-guardrails.ts +213 -85
  98. package/src/lib/settings/permission-presets.ts +150 -0
  99. package/src/lib/settings/runtime-setup.ts +71 -0
  100. package/src/lib/usage/__tests__/ledger.test.ts +29 -5
  101. package/src/lib/usage/__tests__/pricing-registry.test.ts +78 -0
  102. package/src/lib/usage/ledger.ts +4 -2
  103. package/src/lib/usage/pricing-registry.ts +570 -0
  104. package/src/lib/usage/pricing.ts +15 -41
  105. package/src/lib/utils/__tests__/learned-context-history.test.ts +171 -0
  106. package/src/lib/utils/learned-context-history.ts +150 -0
  107. package/src/lib/validators/__tests__/profile.test.ts +0 -15
  108. package/src/lib/validators/__tests__/settings.test.ts +23 -16
  109. package/src/lib/validators/profile.ts +0 -1
  110. package/src/lib/validators/settings.ts +3 -9
  111. package/src/lib/workflows/__tests__/engine.test.ts +2 -0
  112. package/src/lib/workflows/engine.ts +20 -1
@@ -0,0 +1,500 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // ─── Mock infrastructure ──────────────────────────────────────────────
4
+
5
+ const {
6
+ mockAll,
7
+ mockLimit,
8
+ mockOrderBy,
9
+ mockWhere,
10
+ mockFrom,
11
+ mockSelect,
12
+ mockValues,
13
+ mockInsert,
14
+ mockSetWhere,
15
+ mockSet,
16
+ mockUpdate,
17
+ } = vi.hoisted(() => {
18
+ const mockAll = vi.fn();
19
+ const mockLimit = vi.fn().mockReturnValue({ all: mockAll });
20
+ const mockOrderBy = vi.fn().mockReturnValue({ limit: mockLimit, all: mockAll });
21
+ const mockWhere = vi.fn().mockReturnValue({ orderBy: mockOrderBy, all: mockAll });
22
+ const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
23
+ const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
24
+ const mockValues = vi.fn().mockResolvedValue(undefined);
25
+ const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
26
+ const mockSetWhere = vi.fn().mockResolvedValue(undefined);
27
+ const mockSet = vi.fn().mockReturnValue({ where: mockSetWhere });
28
+ const mockUpdate = vi.fn().mockReturnValue({ set: mockSet });
29
+
30
+ return {
31
+ mockAll,
32
+ mockLimit,
33
+ mockOrderBy,
34
+ mockWhere,
35
+ mockFrom,
36
+ mockSelect,
37
+ mockValues,
38
+ mockInsert,
39
+ mockSetWhere,
40
+ mockSet,
41
+ mockUpdate,
42
+ };
43
+ });
44
+
45
+ vi.mock("@/lib/db", () => ({
46
+ db: {
47
+ select: mockSelect,
48
+ insert: mockInsert,
49
+ update: mockUpdate,
50
+ },
51
+ }));
52
+
53
+ vi.mock("@/lib/db/schema", () => ({
54
+ learnedContext: {
55
+ profileId: "profile_id",
56
+ version: "version",
57
+ changeType: "change_type",
58
+ content: "content",
59
+ proposalNotificationId: "proposal_notification_id",
60
+ },
61
+ notifications: {
62
+ id: "id",
63
+ },
64
+ }));
65
+
66
+ vi.mock("drizzle-orm", () => ({
67
+ eq: vi.fn((_col: string, val: unknown) => ({ col: _col, val })),
68
+ and: vi.fn((...conditions: unknown[]) => conditions),
69
+ desc: vi.fn((col: string) => ({ desc: col })),
70
+ }));
71
+
72
+ // Mock runMetaCompletion for summarization
73
+ const { mockRunMetaCompletion } = vi.hoisted(() => ({
74
+ mockRunMetaCompletion: vi.fn(),
75
+ }));
76
+ vi.mock("../runtime/claude", () => ({
77
+ runMetaCompletion: mockRunMetaCompletion,
78
+ }));
79
+
80
+ // ─── Import under test ────────────────────────────────────────────────
81
+
82
+ import {
83
+ getActiveLearnedContext,
84
+ getContextHistory,
85
+ proposeContextAddition,
86
+ approveProposal,
87
+ rejectProposal,
88
+ rollbackToVersion,
89
+ checkContextSize,
90
+ summarizeContext,
91
+ addDirectContext,
92
+ } from "../learned-context";
93
+
94
+ // ─── Helpers ──────────────────────────────────────────────────────────
95
+
96
+ function resetMockChain() {
97
+ mockAll.mockReset();
98
+ mockLimit.mockReset().mockReturnValue({ all: mockAll });
99
+ mockOrderBy.mockReset().mockReturnValue({ limit: mockLimit, all: mockAll });
100
+ mockWhere.mockReset().mockReturnValue({ orderBy: mockOrderBy, all: mockAll });
101
+ mockFrom.mockReset().mockReturnValue({ where: mockWhere });
102
+ mockSelect.mockReset().mockReturnValue({ from: mockFrom });
103
+ mockValues.mockReset().mockResolvedValue(undefined);
104
+ mockInsert.mockReset().mockReturnValue({ values: mockValues });
105
+ mockSetWhere.mockReset().mockResolvedValue(undefined);
106
+ mockSet.mockReset().mockReturnValue({ where: mockSetWhere });
107
+ mockUpdate.mockReset().mockReturnValue({ set: mockSet });
108
+ mockRunMetaCompletion.mockReset();
109
+ }
110
+
111
+ beforeEach(resetMockChain);
112
+
113
+ // ═════════════════════════════════════════════════════════════════════
114
+ // getActiveLearnedContext
115
+ // ═════════════════════════════════════════════════════════════════════
116
+
117
+ describe("getActiveLearnedContext", () => {
118
+ it("returns content from the latest approved version", () => {
119
+ mockAll.mockReturnValue([{ content: "Use retry pattern for flaky APIs" }]);
120
+
121
+ const result = getActiveLearnedContext("code-reviewer");
122
+
123
+ expect(result).toBe("Use retry pattern for flaky APIs");
124
+ });
125
+
126
+ it("returns null when no approved context exists", () => {
127
+ mockAll.mockReturnValue([]);
128
+
129
+ const result = getActiveLearnedContext("general");
130
+
131
+ expect(result).toBeNull();
132
+ });
133
+ });
134
+
135
+ // ═════════════════════════════════════════════════════════════════════
136
+ // getContextHistory
137
+ // ═════════════════════════════════════════════════════════════════════
138
+
139
+ describe("getContextHistory", () => {
140
+ it("returns all versions ordered by version descending", async () => {
141
+ const rows = [
142
+ { id: "v3", profileId: "general", version: 3, changeType: "approved" },
143
+ { id: "v2", profileId: "general", version: 2, changeType: "proposal" },
144
+ { id: "v1", profileId: "general", version: 1, changeType: "approved" },
145
+ ];
146
+ mockAll.mockReturnValue(rows);
147
+
148
+ const result = await getContextHistory("general");
149
+
150
+ expect(result).toEqual(rows);
151
+ expect(result).toHaveLength(3);
152
+ });
153
+ });
154
+
155
+ // ═════════════════════════════════════════════════════════════════════
156
+ // proposeContextAddition
157
+ // ═════════════════════════════════════════════════════════════════════
158
+
159
+ describe("proposeContextAddition", () => {
160
+ it("inserts a proposal row and a notification", async () => {
161
+ // getNextVersion query returns no existing versions
162
+ mockAll.mockReturnValue([]);
163
+
164
+ const notificationId = await proposeContextAddition(
165
+ "code-reviewer",
166
+ "task-42",
167
+ "Always check for null pointers"
168
+ );
169
+
170
+ expect(notificationId).toBeDefined();
171
+ expect(typeof notificationId).toBe("string");
172
+
173
+ // Two inserts: learned_context row + notification
174
+ expect(mockInsert).toHaveBeenCalledTimes(2);
175
+
176
+ // Proposal row has correct shape
177
+ expect(mockValues).toHaveBeenCalledWith(
178
+ expect.objectContaining({
179
+ profileId: "code-reviewer",
180
+ version: 1,
181
+ content: null, // not yet approved
182
+ diff: "Always check for null pointers",
183
+ changeType: "proposal",
184
+ sourceTaskId: "task-42",
185
+ proposedAdditions: "Always check for null pointers",
186
+ })
187
+ );
188
+
189
+ // Notification has correct type
190
+ expect(mockValues).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ type: "context_proposal",
193
+ title: "Context proposal for code-reviewer",
194
+ })
195
+ );
196
+ });
197
+
198
+ it("increments version based on existing versions", async () => {
199
+ mockAll.mockReturnValue([{ version: 5 }]);
200
+
201
+ await proposeContextAddition("general", "task-1", "New pattern");
202
+
203
+ expect(mockValues).toHaveBeenCalledWith(
204
+ expect.objectContaining({ version: 6 })
205
+ );
206
+ });
207
+ });
208
+
209
+ // ═════════════════════════════════════════════════════════════════════
210
+ // approveProposal
211
+ // ═════════════════════════════════════════════════════════════════════
212
+
213
+ describe("approveProposal", () => {
214
+ it("merges additions into current context and creates approved version", async () => {
215
+ // First call: find proposal by notification ID
216
+ mockAll
217
+ .mockReturnValueOnce([
218
+ {
219
+ id: "proposal-1",
220
+ profileId: "general",
221
+ proposedAdditions: "New pattern: use early returns",
222
+ sourceTaskId: "task-1",
223
+ },
224
+ ])
225
+ // Second call: getActiveLearnedContext (existing context)
226
+ .mockReturnValueOnce([{ content: "Existing pattern: validate inputs" }])
227
+ // Third call: getNextVersion
228
+ .mockReturnValueOnce([{ version: 2 }])
229
+ // Fourth call: checkContextSize -> getActiveLearnedContext
230
+ .mockReturnValueOnce([
231
+ { content: "Existing pattern: validate inputs\n\nNew pattern: use early returns" },
232
+ ]);
233
+
234
+ await approveProposal("notif-1");
235
+
236
+ // Approved version inserted with merged content
237
+ expect(mockValues).toHaveBeenCalledWith(
238
+ expect.objectContaining({
239
+ changeType: "approved",
240
+ content: "Existing pattern: validate inputs\n\nNew pattern: use early returns",
241
+ diff: "New pattern: use early returns",
242
+ approvedBy: "human",
243
+ })
244
+ );
245
+
246
+ // Notification marked as responded
247
+ expect(mockSet).toHaveBeenCalledWith(
248
+ expect.objectContaining({
249
+ response: JSON.stringify({ action: "approved" }),
250
+ })
251
+ );
252
+ });
253
+
254
+ it("throws if proposal not found", async () => {
255
+ mockAll.mockReturnValue([]);
256
+
257
+ await expect(approveProposal("notif-nonexistent")).rejects.toThrow(
258
+ "Proposal not found"
259
+ );
260
+ });
261
+
262
+ it("uses editedContent when provided", async () => {
263
+ mockAll
264
+ .mockReturnValueOnce([
265
+ {
266
+ id: "proposal-1",
267
+ profileId: "general",
268
+ proposedAdditions: "Original text",
269
+ sourceTaskId: "task-1",
270
+ },
271
+ ])
272
+ .mockReturnValueOnce([]) // no existing context
273
+ .mockReturnValueOnce([]) // no existing versions
274
+ .mockReturnValueOnce([{ content: "Edited by human" }]); // checkContextSize
275
+
276
+ await approveProposal("notif-1", "Edited by human");
277
+
278
+ expect(mockValues).toHaveBeenCalledWith(
279
+ expect.objectContaining({
280
+ content: "Edited by human",
281
+ diff: "Edited by human",
282
+ })
283
+ );
284
+ });
285
+ });
286
+
287
+ // ═════════════════════════════════════════════════════════════════════
288
+ // rejectProposal
289
+ // ═════════════════════════════════════════════════════════════════════
290
+
291
+ describe("rejectProposal", () => {
292
+ it("creates rejected version and marks notification responded", async () => {
293
+ mockAll
294
+ .mockReturnValueOnce([
295
+ {
296
+ id: "proposal-1",
297
+ profileId: "general",
298
+ proposedAdditions: "Bad pattern",
299
+ sourceTaskId: "task-1",
300
+ },
301
+ ])
302
+ // getActiveLearnedContext for preserving current content
303
+ .mockReturnValueOnce([{ content: "Good pattern" }])
304
+ // getNextVersion
305
+ .mockReturnValueOnce([{ version: 3 }]);
306
+
307
+ await rejectProposal("notif-1");
308
+
309
+ expect(mockValues).toHaveBeenCalledWith(
310
+ expect.objectContaining({
311
+ changeType: "rejected",
312
+ diff: "Bad pattern",
313
+ })
314
+ );
315
+
316
+ expect(mockSet).toHaveBeenCalledWith(
317
+ expect.objectContaining({
318
+ response: JSON.stringify({ action: "rejected" }),
319
+ })
320
+ );
321
+ });
322
+
323
+ it("throws if proposal not found", async () => {
324
+ mockAll.mockReturnValue([]);
325
+
326
+ await expect(rejectProposal("notif-nonexistent")).rejects.toThrow(
327
+ "Proposal not found"
328
+ );
329
+ });
330
+ });
331
+
332
+ // ═════════════════════════════════════════════════════════════════════
333
+ // rollbackToVersion
334
+ // ═════════════════════════════════════════════════════════════════════
335
+
336
+ describe("rollbackToVersion", () => {
337
+ it("creates a new version with the target version's content", async () => {
338
+ mockAll
339
+ // Find target version
340
+ .mockReturnValueOnce([
341
+ { id: "v2", profileId: "general", version: 2, content: "Version 2 content" },
342
+ ])
343
+ // getNextVersion
344
+ .mockReturnValueOnce([{ version: 5 }]);
345
+
346
+ await rollbackToVersion("general", 2);
347
+
348
+ expect(mockValues).toHaveBeenCalledWith(
349
+ expect.objectContaining({
350
+ profileId: "general",
351
+ version: 6,
352
+ content: "Version 2 content",
353
+ diff: "Rolled back to version 2",
354
+ changeType: "rollback",
355
+ })
356
+ );
357
+ });
358
+
359
+ it("throws if target version not found", async () => {
360
+ mockAll.mockReturnValue([]);
361
+
362
+ await expect(rollbackToVersion("general", 999)).rejects.toThrow(
363
+ "Version 999 not found"
364
+ );
365
+ });
366
+ });
367
+
368
+ // ═════════════════════════════════════════════════════════════════════
369
+ // checkContextSize
370
+ // ═════════════════════════════════════════════════════════════════════
371
+
372
+ describe("checkContextSize", () => {
373
+ it("reports size and summarization need", () => {
374
+ mockAll.mockReturnValue([{ content: "x".repeat(7000) }]);
375
+
376
+ const result = checkContextSize("general");
377
+
378
+ expect(result.currentSize).toBe(7000);
379
+ expect(result.limit).toBe(8000);
380
+ expect(result.needsSummarization).toBe(true);
381
+ });
382
+
383
+ it("reports no summarization needed for small context", () => {
384
+ mockAll.mockReturnValue([{ content: "small" }]);
385
+
386
+ const result = checkContextSize("general");
387
+
388
+ expect(result.currentSize).toBe(5);
389
+ expect(result.needsSummarization).toBe(false);
390
+ });
391
+
392
+ it("handles no context gracefully", () => {
393
+ mockAll.mockReturnValue([]);
394
+
395
+ const result = checkContextSize("general");
396
+
397
+ expect(result.currentSize).toBe(0);
398
+ expect(result.needsSummarization).toBe(false);
399
+ });
400
+ });
401
+
402
+ // ═════════════════════════════════════════════════════════════════════
403
+ // summarizeContext
404
+ // ═════════════════════════════════════════════════════════════════════
405
+
406
+ describe("summarizeContext", () => {
407
+ it("calls runMetaCompletion to summarize and inserts a summarization version", async () => {
408
+ const longContent = "x".repeat(7000);
409
+ mockAll
410
+ // getActiveLearnedContext
411
+ .mockReturnValueOnce([{ content: longContent }])
412
+ // getNextVersion
413
+ .mockReturnValueOnce([{ version: 4 }]);
414
+
415
+ mockRunMetaCompletion.mockResolvedValue({
416
+ text: "condensed version",
417
+ usage: {},
418
+ });
419
+
420
+ await summarizeContext("general");
421
+
422
+ expect(mockRunMetaCompletion).toHaveBeenCalledOnce();
423
+ expect(mockRunMetaCompletion).toHaveBeenCalledWith(
424
+ expect.objectContaining({ activityType: "context_summarization" })
425
+ );
426
+ expect(mockValues).toHaveBeenCalledWith(
427
+ expect.objectContaining({
428
+ changeType: "summarization",
429
+ content: "condensed version",
430
+ })
431
+ );
432
+ });
433
+
434
+ it("skips summarization when content is below threshold", async () => {
435
+ mockAll.mockReturnValue([{ content: "short" }]);
436
+
437
+ await summarizeContext("general");
438
+
439
+ expect(mockRunMetaCompletion).not.toHaveBeenCalled();
440
+ expect(mockInsert).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it("skips if summarized text is longer than original", async () => {
444
+ const longContent = "x".repeat(7000);
445
+ mockAll.mockReturnValueOnce([{ content: longContent }]);
446
+
447
+ mockRunMetaCompletion.mockResolvedValue({
448
+ text: "x".repeat(8000),
449
+ usage: {},
450
+ });
451
+
452
+ await summarizeContext("general");
453
+
454
+ // No insert because summarized is longer
455
+ expect(mockInsert).not.toHaveBeenCalled();
456
+ });
457
+ });
458
+
459
+ // ═════════════════════════════════════════════════════════════════════
460
+ // addDirectContext
461
+ // ═════════════════════════════════════════════════════════════════════
462
+
463
+ describe("addDirectContext", () => {
464
+ it("merges new content with existing and creates approved version", async () => {
465
+ mockAll
466
+ // getActiveLearnedContext
467
+ .mockReturnValueOnce([{ content: "Existing" }])
468
+ // getNextVersion
469
+ .mockReturnValueOnce([{ version: 1 }])
470
+ // checkContextSize -> getActiveLearnedContext
471
+ .mockReturnValueOnce([{ content: "Existing\n\nNew pattern" }]);
472
+
473
+ await addDirectContext("general", "New pattern");
474
+
475
+ expect(mockValues).toHaveBeenCalledWith(
476
+ expect.objectContaining({
477
+ content: "Existing\n\nNew pattern",
478
+ diff: "New pattern",
479
+ changeType: "approved",
480
+ approvedBy: "human",
481
+ })
482
+ );
483
+ });
484
+
485
+ it("handles first context addition (no existing content)", async () => {
486
+ mockAll
487
+ .mockReturnValueOnce([]) // no existing context
488
+ .mockReturnValueOnce([]) // no existing versions
489
+ .mockReturnValueOnce([{ content: "First pattern" }]); // checkContextSize
490
+
491
+ await addDirectContext("general", "First pattern");
492
+
493
+ expect(mockValues).toHaveBeenCalledWith(
494
+ expect.objectContaining({
495
+ content: "First pattern",
496
+ version: 1,
497
+ })
498
+ );
499
+ });
500
+ });