@open-mercato/ai-assistant 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1

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 (135) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +30 -4
  3. package/dist/frontend/components/AiChatButton.js +3 -2
  4. package/dist/frontend/components/AiChatButton.js.map +2 -2
  5. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +364 -0
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +7 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js +7 -7
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.js.map +2 -2
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +182 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +7 -0
  11. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js +316 -0
  12. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.js.map +7 -0
  13. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js +8 -7
  14. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/models/route.js.map +2 -2
  15. package/dist/modules/ai_assistant/api/ai/chat/route.js +43 -20
  16. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +2 -2
  17. package/dist/modules/ai_assistant/api/settings/route.js +4 -3
  18. package/dist/modules/ai_assistant/api/settings/route.js.map +2 -2
  19. package/dist/modules/ai_assistant/api/usage/daily/route.js +111 -0
  20. package/dist/modules/ai_assistant/api/usage/daily/route.js.map +7 -0
  21. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js +108 -0
  22. package/dist/modules/ai_assistant/api/usage/sessions/[sessionId]/route.js.map +7 -0
  23. package/dist/modules/ai_assistant/api/usage/sessions/route.js +153 -0
  24. package/dist/modules/ai_assistant/api/usage/sessions/route.js.map +7 -0
  25. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +335 -38
  26. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +2 -2
  27. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js +2 -7
  28. package/dist/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.js.map +2 -2
  29. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +44 -35
  30. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +2 -2
  31. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js +282 -0
  32. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.js.map +7 -0
  33. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js +10 -0
  34. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.js.map +7 -0
  35. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js +25 -0
  36. package/dist/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.js.map +7 -0
  37. package/dist/modules/ai_assistant/cli.js +12 -0
  38. package/dist/modules/ai_assistant/cli.js.map +2 -2
  39. package/dist/modules/ai_assistant/components/AiAssistantSettingsPageClient.js.map +1 -1
  40. package/dist/modules/ai_assistant/data/entities.js +177 -1
  41. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  42. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js +104 -2
  43. package/dist/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.js.map +2 -2
  44. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js +168 -0
  45. package/dist/modules/ai_assistant/data/repositories/AiTokenUsageRepository.js.map +7 -0
  46. package/dist/modules/ai_assistant/events.js +8 -0
  47. package/dist/modules/ai_assistant/events.js.map +2 -2
  48. package/dist/modules/ai_assistant/i18n/de.json +74 -1
  49. package/dist/modules/ai_assistant/i18n/en.json +74 -1
  50. package/dist/modules/ai_assistant/i18n/es.json +75 -2
  51. package/dist/modules/ai_assistant/i18n/pl.json +74 -1
  52. package/dist/modules/ai_assistant/lib/agent-policy.js.map +2 -2
  53. package/dist/modules/ai_assistant/lib/agent-runtime.js +588 -23
  54. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +3 -3
  55. package/dist/modules/ai_assistant/lib/agent-tools.js +6 -1
  56. package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
  57. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +2 -2
  58. package/dist/modules/ai_assistant/lib/model-factory.js +63 -22
  59. package/dist/modules/ai_assistant/lib/model-factory.js.map +2 -2
  60. package/dist/modules/ai_assistant/lib/token-usage-recorder.js +78 -0
  61. package/dist/modules/ai_assistant/lib/token-usage-recorder.js.map +7 -0
  62. package/dist/modules/ai_assistant/lib/usage-serialization.js +33 -0
  63. package/dist/modules/ai_assistant/lib/usage-serialization.js.map +7 -0
  64. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js +25 -0
  65. package/dist/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.js.map +7 -0
  66. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js +88 -0
  67. package/dist/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.js.map +7 -0
  68. package/dist/modules/ai_assistant/setup.js +34 -0
  69. package/dist/modules/ai_assistant/setup.js.map +2 -2
  70. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js +114 -0
  71. package/dist/modules/ai_assistant/workers/ai-token-usage-prune.js.map +7 -0
  72. package/generated/entities/ai_agent_runtime_override/index.ts +7 -0
  73. package/generated/entities/ai_token_usage_daily/index.ts +16 -0
  74. package/generated/entities/ai_token_usage_event/index.ts +19 -0
  75. package/generated/entities.ids.generated.ts +2 -0
  76. package/generated/entity-fields-registry.ts +47 -1
  77. package/package.json +15 -7
  78. package/src/frontend/components/AiChatButton.tsx +3 -2
  79. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +521 -0
  80. package/src/modules/ai_assistant/__integration__/TC-AI-RUNTIME-OVERRIDES-006-model-picker.spec.ts +8 -8
  81. package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +231 -0
  82. package/src/modules/ai_assistant/__tests__/events.test.ts +4 -3
  83. package/src/modules/ai_assistant/__tests__/settings-page-logic.test.ts +5 -5
  84. package/src/modules/ai_assistant/__tests__/token-usage-recorder.test.ts +109 -0
  85. package/src/modules/ai_assistant/api/ai/agents/[agentId]/loop-override/route.ts +388 -0
  86. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/__tests__/route.test.ts +5 -0
  87. package/src/modules/ai_assistant/api/ai/agents/[agentId]/models/route.ts +8 -7
  88. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +102 -5
  89. package/src/modules/ai_assistant/api/ai/chat/route.ts +55 -18
  90. package/src/modules/ai_assistant/api/settings/route.ts +5 -3
  91. package/src/modules/ai_assistant/api/usage/daily/__tests__/route.test.ts +159 -0
  92. package/src/modules/ai_assistant/api/usage/daily/route.ts +126 -0
  93. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/__tests__/route.test.ts +143 -0
  94. package/src/modules/ai_assistant/api/usage/sessions/[sessionId]/route.ts +130 -0
  95. package/src/modules/ai_assistant/api/usage/sessions/__tests__/route.test.ts +123 -0
  96. package/src/modules/ai_assistant/api/usage/sessions/route.ts +184 -0
  97. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +372 -16
  98. package/src/modules/ai_assistant/backend/config/ai-assistant/allowlist/AiTenantAllowlistPageClient.tsx +1 -4
  99. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +26 -9
  100. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/AiUsageStatsPageClient.tsx +469 -0
  101. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.meta.ts +23 -0
  102. package/src/modules/ai_assistant/backend/config/ai-assistant/usage/page.tsx +12 -0
  103. package/src/modules/ai_assistant/cli.ts +18 -0
  104. package/src/modules/ai_assistant/components/AiAssistantSettingsPageClient.tsx +1 -1
  105. package/src/modules/ai_assistant/data/entities.ts +237 -0
  106. package/src/modules/ai_assistant/data/repositories/AiAgentRuntimeOverrideRepository.ts +135 -3
  107. package/src/modules/ai_assistant/data/repositories/AiTokenUsageRepository.ts +213 -0
  108. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentRuntimeOverrideRepository.test.ts +223 -0
  109. package/src/modules/ai_assistant/data/repositories/__tests__/AiTokenUsageRepository.test.ts +58 -0
  110. package/src/modules/ai_assistant/events.ts +8 -0
  111. package/src/modules/ai_assistant/i18n/de.json +74 -1
  112. package/src/modules/ai_assistant/i18n/en.json +74 -1
  113. package/src/modules/ai_assistant/i18n/es.json +75 -2
  114. package/src/modules/ai_assistant/i18n/pl.json +74 -1
  115. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase0.test.ts +439 -0
  116. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase1.test.ts +243 -0
  117. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase2.test.ts +388 -0
  118. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-loop-phase3.test.ts +359 -0
  119. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-phase4a.test.ts +2 -2
  120. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +2 -1
  121. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +12 -13
  122. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +77 -14
  123. package/src/modules/ai_assistant/lib/agent-policy.ts +9 -0
  124. package/src/modules/ai_assistant/lib/agent-runtime.ts +1148 -43
  125. package/src/modules/ai_assistant/lib/agent-tools.ts +5 -1
  126. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +289 -2
  127. package/src/modules/ai_assistant/lib/model-factory.ts +128 -43
  128. package/src/modules/ai_assistant/lib/token-usage-recorder.ts +122 -0
  129. package/src/modules/ai_assistant/lib/usage-serialization.ts +29 -0
  130. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +791 -0
  131. package/src/modules/ai_assistant/migrations/Migration20260508160000_ai_agent_loop_overrides.ts +25 -0
  132. package/src/modules/ai_assistant/migrations/Migration20260508170000_ai_token_usage.ts +89 -0
  133. package/src/modules/ai_assistant/setup.ts +49 -0
  134. package/src/modules/ai_assistant/workers/__tests__/ai-token-usage-prune.test.ts +144 -0
  135. package/src/modules/ai_assistant/workers/ai-token-usage-prune.ts +188 -0
@@ -0,0 +1,231 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth';
3
+
4
+ /**
5
+ * TC-AI-TOKEN-USAGE-001 through TC-AI-TOKEN-USAGE-005
6
+ *
7
+ * Integration coverage for Phase 6 (Token Usage Tracking & Stats Page) of
8
+ * spec `2026-04-28-ai-agents-agentic-loop-controls`.
9
+ *
10
+ * TC-AI-TOKEN-USAGE-001 — Usage page loads and renders summary tiles (ACL gate).
11
+ * TC-AI-TOKEN-USAGE-002 — Date filter apply re-fetches with updated params.
12
+ * TC-AI-TOKEN-USAGE-003 — Sessions list renders when API returns session rows.
13
+ * TC-AI-TOKEN-USAGE-004 — Clicking a session row opens the detail dialog.
14
+ * TC-AI-TOKEN-USAGE-005 — Unauthenticated visit to usage page redirects to login.
15
+ *
16
+ * All API calls are intercepted via page.route() stubs — no real DB needed.
17
+ */
18
+
19
+ const USAGE_PAGE = '/backend/config/ai-assistant/usage';
20
+
21
+ const EMPTY_DAILY_PAYLOAD = { rows: [], total: 0 };
22
+ const EMPTY_SESSIONS_PAYLOAD = { sessions: [], total: 0, limit: 50, offset: 0 };
23
+
24
+ const DAILY_ROW = {
25
+ id: 'row-1',
26
+ tenantId: 'tenant-1',
27
+ organizationId: null,
28
+ day: '2026-05-01',
29
+ agentId: 'catalog.assistant',
30
+ modelId: 'claude-haiku-4-5',
31
+ providerId: 'anthropic',
32
+ inputTokens: '1000',
33
+ outputTokens: '500',
34
+ cachedInputTokens: '0',
35
+ reasoningTokens: '0',
36
+ stepCount: '5',
37
+ turnCount: '3',
38
+ sessionCount: '2',
39
+ createdAt: '2026-05-01T12:00:00.000Z',
40
+ updatedAt: '2026-05-01T12:00:00.000Z',
41
+ };
42
+
43
+ const SESSION_ROW = {
44
+ sessionId: '00000000-0000-0000-0000-000000000001',
45
+ agentId: 'catalog.assistant',
46
+ moduleId: 'catalog',
47
+ userId: 'user-1',
48
+ startedAt: '2026-05-01T10:00:00.000Z',
49
+ lastEventAt: '2026-05-01T10:05:00.000Z',
50
+ stepCount: 5,
51
+ turnCount: 3,
52
+ inputTokens: 1000,
53
+ outputTokens: 500,
54
+ cachedInputTokens: 0,
55
+ reasoningTokens: 0,
56
+ };
57
+
58
+ const STEP_EVENT = {
59
+ id: 'evt-1',
60
+ tenantId: 'tenant-1',
61
+ organizationId: null,
62
+ userId: 'user-1',
63
+ agentId: 'catalog.assistant',
64
+ moduleId: 'catalog',
65
+ sessionId: '00000000-0000-0000-0000-000000000001',
66
+ turnId: '00000000-0000-0000-0000-000000000002',
67
+ stepIndex: 0,
68
+ providerId: 'anthropic',
69
+ modelId: 'claude-haiku-4-5',
70
+ inputTokens: 1000,
71
+ outputTokens: 500,
72
+ cachedInputTokens: null,
73
+ reasoningTokens: null,
74
+ finishReason: 'stop',
75
+ loopAbortReason: null,
76
+ createdAt: '2026-05-01T10:00:00.000Z',
77
+ updatedAt: '2026-05-01T10:00:00.000Z',
78
+ };
79
+
80
+ test.describe('TC-AI-TOKEN-USAGE-001–005: token usage stats page', () => {
81
+ test('TC-AI-TOKEN-USAGE-001: usage page renders summary tiles for superadmin', async ({ page }) => {
82
+ await login(page, 'superadmin');
83
+
84
+ await page.route('**/api/ai_assistant/usage/daily**', async (route) => {
85
+ await route.fulfill({
86
+ status: 200,
87
+ contentType: 'application/json',
88
+ body: JSON.stringify({ rows: [DAILY_ROW], total: 1 }),
89
+ });
90
+ });
91
+
92
+ await page.route('**/api/ai_assistant/usage/sessions**', async (route) => {
93
+ await route.fulfill({
94
+ status: 200,
95
+ contentType: 'application/json',
96
+ body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD),
97
+ });
98
+ });
99
+
100
+ await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
101
+
102
+ const summaryTile = page.locator('p.font-semibold.text-xl', { hasText: /^(1,000|500)$/ }).first();
103
+ await expect(summaryTile).toBeVisible({ timeout: 15_000 });
104
+ });
105
+
106
+ test('TC-AI-TOKEN-USAGE-002: apply filter triggers re-fetch with new date params', async ({ page }) => {
107
+ await login(page, 'superadmin');
108
+
109
+ const fetchedUrls: string[] = [];
110
+
111
+ await page.route('**/api/ai_assistant/usage/daily**', async (route) => {
112
+ fetchedUrls.push(route.request().url());
113
+ await route.fulfill({
114
+ status: 200,
115
+ contentType: 'application/json',
116
+ body: JSON.stringify(EMPTY_DAILY_PAYLOAD),
117
+ });
118
+ });
119
+
120
+ await page.route('**/api/ai_assistant/usage/sessions**', async (route) => {
121
+ await route.fulfill({
122
+ status: 200,
123
+ contentType: 'application/json',
124
+ body: JSON.stringify(EMPTY_SESSIONS_PAYLOAD),
125
+ });
126
+ });
127
+
128
+ await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
129
+
130
+ const fromInput = page.locator('#usage-from');
131
+ const toInput = page.locator('#usage-to');
132
+ const applyButton = page.getByRole('button', { name: /apply/i });
133
+
134
+ await expect(fromInput).toBeVisible({ timeout: 10_000 });
135
+
136
+ await fromInput.fill('2026-04-01');
137
+ await toInput.fill('2026-04-30');
138
+ await applyButton.click();
139
+
140
+ await page.waitForTimeout(500);
141
+
142
+ const hasNewDates = fetchedUrls.some((url) => url.includes('from=2026-04-01'));
143
+ expect(hasNewDates).toBe(true);
144
+ });
145
+
146
+ test('TC-AI-TOKEN-USAGE-003: sessions list renders rows when API returns sessions', async ({ page }) => {
147
+ await login(page, 'superadmin');
148
+
149
+ await page.route('**/api/ai_assistant/usage/daily**', async (route) => {
150
+ await route.fulfill({
151
+ status: 200,
152
+ contentType: 'application/json',
153
+ body: JSON.stringify(EMPTY_DAILY_PAYLOAD),
154
+ });
155
+ });
156
+
157
+ await page.route('**/api/ai_assistant/usage/sessions**', async (route, request) => {
158
+ if (request.url().includes('/sessions/')) {
159
+ await route.continue();
160
+ return;
161
+ }
162
+ await route.fulfill({
163
+ status: 200,
164
+ contentType: 'application/json',
165
+ body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 }),
166
+ });
167
+ });
168
+
169
+ await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
170
+
171
+ const sessionCell = page.getByText('00000000').first();
172
+ await expect(sessionCell).toBeVisible({ timeout: 15_000 });
173
+ });
174
+
175
+ test('TC-AI-TOKEN-USAGE-004: clicking a session row opens the detail dialog', async ({ page }) => {
176
+ await login(page, 'superadmin');
177
+
178
+ await page.route('**/api/ai_assistant/usage/daily**', async (route) => {
179
+ await route.fulfill({
180
+ status: 200,
181
+ contentType: 'application/json',
182
+ body: JSON.stringify(EMPTY_DAILY_PAYLOAD),
183
+ });
184
+ });
185
+
186
+ await page.route('**/api/ai_assistant/usage/sessions/00000000-0000-0000-0000-000000000001', async (route) => {
187
+ await route.fulfill({
188
+ status: 200,
189
+ contentType: 'application/json',
190
+ body: JSON.stringify({ events: [STEP_EVENT], total: 1, sessionId: SESSION_ROW.sessionId }),
191
+ });
192
+ });
193
+
194
+ await page.route('**/api/ai_assistant/usage/sessions**', async (route, request) => {
195
+ if (request.url().includes('/00000000-0000-0000-0000-000000000001')) {
196
+ // Fall back to the previously registered (more specific) handler.
197
+ await route.fallback();
198
+ return;
199
+ }
200
+ await route.fulfill({
201
+ status: 200,
202
+ contentType: 'application/json',
203
+ body: JSON.stringify({ sessions: [SESSION_ROW], total: 1, limit: 50, offset: 0 }),
204
+ });
205
+ });
206
+
207
+ await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
208
+
209
+ const sessionCell = page.getByText('00000000').first();
210
+ await expect(sessionCell).toBeVisible({ timeout: 15_000 });
211
+ await sessionCell.click();
212
+
213
+ const dialogTitle = page.getByRole('dialog');
214
+ await expect(dialogTitle).toBeVisible({ timeout: 10_000 });
215
+
216
+ const modelCell = page.getByText('claude-haiku-4-5').first();
217
+ await expect(modelCell).toBeVisible({ timeout: 5_000 });
218
+ });
219
+
220
+ test('TC-AI-TOKEN-USAGE-005: unauthenticated visit redirects to login', async ({ browser }) => {
221
+ const context = await browser.newContext();
222
+ const page = await context.newPage();
223
+ try {
224
+ await page.goto(USAGE_PAGE, { waitUntil: 'domcontentloaded' });
225
+ await page.waitForURL(/\/login/, { timeout: 15_000 });
226
+ expect(page.url()).toMatch(/\/login/);
227
+ } finally {
228
+ await context.close();
229
+ }
230
+ });
231
+ });
@@ -29,12 +29,13 @@ const FROZEN_EVENT_IDS: ReadonlyArray<AiAssistantEventId> = [
29
29
  describe('ai_assistant events module', () => {
30
30
  it('declares the three FROZEN pending-action events under moduleId=ai_assistant', () => {
31
31
  expect(eventsConfig.moduleId).toBe('ai_assistant')
32
- const declaredIds = eventsConfig.events.map((event) => event.id).sort()
33
- expect(declaredIds).toEqual([...FROZEN_EVENT_IDS].sort())
32
+ const declaredIds = eventsConfig.events.map((event) => event.id)
33
+ expect(declaredIds).toEqual(expect.arrayContaining([...FROZEN_EVENT_IDS]))
34
34
  })
35
35
 
36
- it('every declared event has category=system and entity=ai_pending_action', () => {
36
+ it('every FROZEN pending-action event has category=system and entity=ai_pending_action', () => {
37
37
  for (const event of eventsConfig.events) {
38
+ if (!FROZEN_EVENT_IDS.includes(event.id as AiAssistantEventId)) continue
38
39
  expect(event.category).toBe('system')
39
40
  expect(event.entity).toBe('ai_pending_action')
40
41
  expect(event.module).toBe('ai_assistant')
@@ -10,7 +10,7 @@
10
10
  type AgentResolution = {
11
11
  agentId: string
12
12
  moduleId: string
13
- allowRuntimeModelOverride: boolean
13
+ allowRuntimeOverride: boolean
14
14
  providerId: string
15
15
  modelId: string
16
16
  baseURL: string | null
@@ -30,7 +30,7 @@ describe('AiAssistantSettingsPageClient — per-agent override detection', () =>
30
30
  const baseAgent: Omit<AgentResolution, 'source'> = {
31
31
  agentId: 'catalog.merchandising_assistant',
32
32
  moduleId: 'catalog',
33
- allowRuntimeModelOverride: true,
33
+ allowRuntimeOverride: true,
34
34
  providerId: 'anthropic',
35
35
  modelId: 'claude-haiku-4-5',
36
36
  baseURL: null,
@@ -66,7 +66,7 @@ describe('AiAssistantSettingsPageClient — resolution table filtering', () => {
66
66
  {
67
67
  agentId: 'catalog.catalog_assistant',
68
68
  moduleId: 'catalog',
69
- allowRuntimeModelOverride: true,
69
+ allowRuntimeOverride: true,
70
70
  providerId: 'anthropic',
71
71
  modelId: 'claude-haiku-4-5',
72
72
  baseURL: null,
@@ -75,7 +75,7 @@ describe('AiAssistantSettingsPageClient — resolution table filtering', () => {
75
75
  {
76
76
  agentId: 'catalog.merchandising_assistant',
77
77
  moduleId: 'catalog',
78
- allowRuntimeModelOverride: true,
78
+ allowRuntimeOverride: true,
79
79
  providerId: 'openai',
80
80
  modelId: 'gpt-4o',
81
81
  baseURL: null,
@@ -84,7 +84,7 @@ describe('AiAssistantSettingsPageClient — resolution table filtering', () => {
84
84
  {
85
85
  agentId: 'customers.account_assistant',
86
86
  moduleId: 'customers',
87
- allowRuntimeModelOverride: false,
87
+ allowRuntimeOverride: false,
88
88
  providerId: 'anthropic',
89
89
  modelId: 'claude-haiku-4-5',
90
90
  baseURL: null,
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Unit tests for the token-usage recorder (R12 compliance).
3
+ *
4
+ * Verifies that `recordTokenUsage` never throws regardless of DB/emit failures,
5
+ * and that a missing `tenantId` causes an early return without writing anything.
6
+ *
7
+ * Phase 6.3 of spec `2026-04-28-ai-agents-agentic-loop-controls`.
8
+ */
9
+
10
+ import { recordTokenUsage, type RecordTokenUsageInput } from '../lib/token-usage-recorder'
11
+ import type { AwilixContainer } from 'awilix'
12
+
13
+ function makeInput(overrides: Partial<RecordTokenUsageInput> = {}): RecordTokenUsageInput {
14
+ return {
15
+ authContext: {
16
+ tenantId: 'tenant-1',
17
+ organizationId: null,
18
+ userId: 'user-1',
19
+ },
20
+ agentId: 'catalog.assistant',
21
+ moduleId: 'catalog',
22
+ sessionId: '00000000-0000-0000-0000-000000000001',
23
+ turnId: '00000000-0000-0000-0000-000000000002',
24
+ stepIndex: 0,
25
+ providerId: 'anthropic',
26
+ modelId: 'claude-haiku-4-5',
27
+ usage: {
28
+ inputTokens: 100,
29
+ outputTokens: 50,
30
+ },
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function makeContainer(options: {
36
+ createEventThrows?: boolean
37
+ upsertDailyThrows?: boolean
38
+ }): AwilixContainer {
39
+ const createEvent = jest.fn(async () => {
40
+ if (options.createEventThrows) throw new Error('DB write failed')
41
+ return {}
42
+ })
43
+ const upsertDaily = jest.fn(async () => {
44
+ if (options.upsertDailyThrows) throw new Error('Upsert failed')
45
+ })
46
+
47
+ const repoInstance = { createEvent, upsertDaily }
48
+
49
+ const em = {
50
+ fork: () => em,
51
+ }
52
+
53
+ const container: Partial<AwilixContainer> = {
54
+ resolve: (name: string) => {
55
+ if (name === 'em') return em as unknown as ReturnType<AwilixContainer['resolve']>
56
+ throw new Error(`Unknown token: ${name}`)
57
+ },
58
+ }
59
+
60
+ jest.mock('../data/repositories/AiTokenUsageRepository', () => ({
61
+ AiTokenUsageRepository: jest.fn().mockImplementation(() => repoInstance),
62
+ }))
63
+
64
+ return container as AwilixContainer
65
+ }
66
+
67
+ describe('recordTokenUsage — R12 compliance', () => {
68
+ beforeEach(() => {
69
+ jest.resetModules()
70
+ jest.clearAllMocks()
71
+ })
72
+
73
+ it('never throws when createEvent throws', async () => {
74
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
75
+ const input = makeInput()
76
+ const container = makeContainer({ createEventThrows: true })
77
+ await expect(recordTokenUsage(input, container)).resolves.toBeUndefined()
78
+ warnSpy.mockRestore()
79
+ })
80
+
81
+ it('never throws when upsertDaily throws', async () => {
82
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
83
+ const input = makeInput()
84
+ const container = makeContainer({ upsertDailyThrows: true })
85
+ await expect(recordTokenUsage(input, container)).resolves.toBeUndefined()
86
+ warnSpy.mockRestore()
87
+ })
88
+
89
+ it('logs a warn when DB write fails', async () => {
90
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
91
+ const input = makeInput()
92
+ const container = makeContainer({ createEventThrows: true })
93
+ await recordTokenUsage(input, container)
94
+ expect(warnSpy).toHaveBeenCalledWith(
95
+ expect.stringContaining('[AI token-usage]'),
96
+ expect.anything(),
97
+ )
98
+ warnSpy.mockRestore()
99
+ })
100
+
101
+ it('returns immediately without writing when tenantId is falsy', async () => {
102
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
103
+ const input = makeInput({ authContext: { tenantId: null, organizationId: null, userId: 'user-1' } })
104
+ const container = makeContainer({})
105
+ await expect(recordTokenUsage(input, container)).resolves.toBeUndefined()
106
+ expect(warnSpy).not.toHaveBeenCalled()
107
+ warnSpy.mockRestore()
108
+ })
109
+ })