Skip to content

Commit 46d21c7

Browse files
authored
Merge pull request #1560 from mnfst/fix-dashboard-spec-badge
fix: show specificity category badge in dashboard Recent Messages
2 parents bd60731 + 71112c7 commit 46d21c7

File tree

8 files changed

+232
-45
lines changed

8 files changed

+232
-45
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"manifest": patch
3+
---
4+
5+
Fix dashboard Recent Messages showing the complexity tier (e.g. `STANDARD`) instead of the specificity category (e.g. `CODING`) for messages routed by specificity. The Overview analytics endpoint now projects `specificity_category` alongside `routing_tier`, matching the full Messages log.

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,17 @@ See `packages/backend/.env.example` for all variables. Key ones:
370370
- **Tenant**: A user's data boundary. Created from `user.id` on first agent creation.
371371
- **Agent**: An AI agent owned by a tenant. Has a unique OTLP ingest key.
372372

373+
### Message list endpoints (shared projection contract)
374+
375+
Any backend endpoint that returns rows rendered by the frontend `MessageTable` / `ModelCell` component **must** project its SELECT through `selectMessageRowColumns()` in `packages/backend/src/analytics/services/query-helpers.ts`. The helper is the single source of truth for the columns the shared badge/provider/auth rendering reads (including `specificity_category`, `routing_tier`, `routing_reason`, `auth_type`, `fallback_from_model`).
376+
377+
- Adding a new column the UI needs → edit the helper once, never duplicate the projection across query services.
378+
- Endpoint-specific fields that don't belong to the shared `MessageRow` contract (e.g. `description`, `service_type`, `cache_read_tokens`, `duration_ms` for the full Messages log) stay as explicit `.addSelect` chained after the helper call.
379+
- Current call sites: `getRecentActivity()` in `timeseries-queries.service.ts` (Overview "Recent Messages") and `getMessages()` in `messages-query.service.ts` (Messages log).
380+
- A `query-helpers.spec.ts` test pins the required alias set — it fails loudly if anyone drops a field from the helper. Don't bypass it by hand-rolling a new SELECT chain.
381+
382+
This rule exists because the Overview and Messages pages previously drifted and the Recent Messages badge read `STANDARD` instead of the specificity category (`CODING` etc.) — the frontend already shares the rendering code, so the divergence was purely backend projection drift.
383+
373384
## Content Security Policy (CSP)
374385

375386
Helmet enforces a strict CSP in `main.ts`. The policy only allows `'self'` origins — **no external CDNs are permitted**.

packages/backend/src/analytics/services/messages-query.service.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,4 +874,33 @@ describe('MessagesQueryService', () => {
874874
expect(result.items).toEqual([]);
875875
});
876876
});
877+
878+
// Shared contract with TimeseriesQueriesService.getRecentActivity:
879+
// both endpoints must project `specificity_category` so the frontend
880+
// MessageTable/ModelCell badge renders the specificity category instead of
881+
// falling back to the complexity tier. See selectMessageRowColumns helper.
882+
it('propagates specificity_category rows returned by the helper projection', async () => {
883+
mockGetRawOne.mockResolvedValueOnce({ total: 1 });
884+
mockGetRawMany
885+
.mockResolvedValueOnce([
886+
{
887+
id: 'msg-spec',
888+
timestamp: '2026-02-16 10:00:00',
889+
model: 'claude-opus-4-6',
890+
cost: 0.1,
891+
routing_tier: 'standard',
892+
routing_reason: 'specificity',
893+
specificity_category: 'coding',
894+
},
895+
])
896+
.mockResolvedValueOnce([{ model: 'claude-opus-4-6' }]);
897+
898+
const result = await service.getMessages({
899+
range: '24h',
900+
userId: 'test-user',
901+
limit: 20,
902+
});
903+
904+
expect(result.items[0]).toHaveProperty('specificity_category', 'coding');
905+
});
877906
});

packages/backend/src/analytics/services/messages-query.service.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
33
import { Brackets, DataSource, Repository } from 'typeorm';
44
import { AgentMessage } from '../../entities/agent-message.entity';
55
import { rangeToInterval } from '../../common/utils/range.util';
6-
import { addTenantFilter, formatTimestamp } from './query-helpers';
6+
import { addTenantFilter, formatTimestamp, selectMessageRowColumns } from './query-helpers';
77
import { TenantCacheService } from '../../common/services/tenant-cache.service';
88
import {
99
DbDialect,
@@ -103,32 +103,13 @@ export class MessagesQueryService {
103103

104104
// Data (with cursor) — treat negative costs as NULL (invalid pricing)
105105
const costExpr = sqlCastFloat(sqlSanitizeCost('at.cost_usd'), this.dialect);
106-
const dataQb = baseQb
107-
.clone()
108-
.select('at.id', 'id')
109-
.addSelect('at.timestamp', 'timestamp')
110-
.addSelect('at.agent_name', 'agent_name')
111-
.addSelect('at.model', 'model')
112-
.addSelect('at.provider', 'provider')
113-
.addSelect('at.model', 'display_name')
106+
const dataQb = selectMessageRowColumns(baseQb.clone(), costExpr)
114107
.addSelect('at.description', 'description')
115108
.addSelect('at.service_type', 'service_type')
116-
.addSelect('at.input_tokens', 'input_tokens')
117-
.addSelect('at.output_tokens', 'output_tokens')
118-
.addSelect('at.status', 'status')
119-
.addSelect('at.input_tokens + at.output_tokens', 'total_tokens')
120-
.addSelect(costExpr, 'cost')
121-
.addSelect('at.routing_tier', 'routing_tier')
122-
.addSelect('at.routing_reason', 'routing_reason')
123-
.addSelect('at.specificity_category', 'specificity_category')
124109
.addSelect('at.cache_read_tokens', 'cache_read_tokens')
125110
.addSelect('at.cache_creation_tokens', 'cache_creation_tokens')
126111
.addSelect('at.duration_ms', 'duration_ms')
127-
.addSelect('at.error_message', 'error_message')
128-
.addSelect('at.error_http_status', 'error_http_status')
129-
.addSelect('at.auth_type', 'auth_type')
130-
.addSelect('at.fallback_from_model', 'fallback_from_model')
131-
.addSelect('at.fallback_index', 'fallback_index');
112+
.addSelect('at.error_http_status', 'error_http_status');
132113

133114
if (params.cursor) {
134115
const sepIdx = params.cursor.indexOf('|');

packages/backend/src/analytics/services/query-helpers.spec.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { computeTrend, downsample, formatTimestamp, addTenantFilter } from './query-helpers';
1+
import {
2+
computeTrend,
3+
downsample,
4+
formatTimestamp,
5+
addTenantFilter,
6+
selectMessageRowColumns,
7+
MESSAGE_ROW_SELECT_ALIASES,
8+
} from './query-helpers';
29
import { SelectQueryBuilder } from 'typeorm';
310

411
describe('computeTrend', () => {
@@ -170,3 +177,71 @@ describe('addTenantFilter', () => {
170177
expect(mockAndWhere).toHaveBeenCalledTimes(2);
171178
});
172179
});
180+
181+
describe('selectMessageRowColumns', () => {
182+
function makeMockQb() {
183+
const selectCalls: Array<[string, string]> = [];
184+
const addSelectCalls: Array<[string, string]> = [];
185+
const qb = {
186+
select: jest.fn().mockImplementation((expr: string, alias: string) => {
187+
selectCalls.push([expr, alias]);
188+
return qb;
189+
}),
190+
addSelect: jest.fn().mockImplementation((expr: string, alias: string) => {
191+
addSelectCalls.push([expr, alias]);
192+
return qb;
193+
}),
194+
};
195+
return { qb: qb as unknown as SelectQueryBuilder<never>, selectCalls, addSelectCalls };
196+
}
197+
198+
it('projects exactly the columns declared in MESSAGE_ROW_SELECT_ALIASES', () => {
199+
const { qb, selectCalls, addSelectCalls } = makeMockQb();
200+
selectMessageRowColumns(qb, 'CAST(at.cost_usd AS FLOAT)');
201+
202+
expect(selectCalls).toHaveLength(1);
203+
expect(addSelectCalls).toHaveLength(MESSAGE_ROW_SELECT_ALIASES.length - 1);
204+
205+
const emittedAliases = [selectCalls[0]![1], ...addSelectCalls.map(([, alias]) => alias)];
206+
expect(emittedAliases).toEqual([...MESSAGE_ROW_SELECT_ALIASES]);
207+
});
208+
209+
it('uses the caller-supplied cost expression so dialect handling stays at the call site', () => {
210+
const { qb, addSelectCalls } = makeMockQb();
211+
const costExpr = 'SOME_DIALECT_SPECIFIC_CAST(at.cost_usd)';
212+
selectMessageRowColumns(qb, costExpr);
213+
214+
const costCall = addSelectCalls.find(([, alias]) => alias === 'cost');
215+
expect(costCall).toEqual([costExpr, 'cost']);
216+
});
217+
218+
it('computes total_tokens from input_tokens + output_tokens', () => {
219+
const { qb, addSelectCalls } = makeMockQb();
220+
selectMessageRowColumns(qb, 'cost');
221+
222+
const totalCall = addSelectCalls.find(([, alias]) => alias === 'total_tokens');
223+
expect(totalCall).toEqual(['at.input_tokens + at.output_tokens', 'total_tokens']);
224+
});
225+
226+
it('aliases display_name from at.model', () => {
227+
const { qb, addSelectCalls } = makeMockQb();
228+
selectMessageRowColumns(qb, 'cost');
229+
230+
const displayNameCall = addSelectCalls.find(([, alias]) => alias === 'display_name');
231+
expect(displayNameCall).toEqual(['at.model', 'display_name']);
232+
});
233+
234+
it('projects specificity_category so the frontend badge can render it', () => {
235+
const { qb, addSelectCalls } = makeMockQb();
236+
selectMessageRowColumns(qb, 'cost');
237+
238+
const specCall = addSelectCalls.find(([, alias]) => alias === 'specificity_category');
239+
expect(specCall).toEqual(['at.specificity_category', 'specificity_category']);
240+
});
241+
242+
it('returns the query builder for chaining', () => {
243+
const { qb } = makeMockQb();
244+
const result = selectMessageRowColumns(qb, 'cost');
245+
expect(result).toBe(qb);
246+
});
247+
});

packages/backend/src/analytics/services/query-helpers.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,60 @@ export function addTenantFilter<T extends ObjectLiteral>(
5555
}
5656
return qb;
5757
}
58+
59+
/**
60+
* Single source of truth for the columns projected by any endpoint that
61+
* returns rows rendered by the frontend `MessageTable` / `ModelCell` component.
62+
* The frontend `MessageRow` type (packages/frontend/src/components/message-table-types.ts)
63+
* is the downstream contract — every field it declares must be selected here so
64+
* the shared badge/provider rendering works identically across every call site.
65+
*
66+
* Assumes the query builder aliases `agent_messages` as `at`. Callers pass a
67+
* dialect-specific `costExpr` (e.g. `sqlCastFloat(sqlSanitizeCost('at.cost_usd'), dialect)`)
68+
* so per-service dialect handling stays at the call site.
69+
*/
70+
export const MESSAGE_ROW_SELECT_ALIASES = [
71+
'id',
72+
'timestamp',
73+
'agent_name',
74+
'model',
75+
'provider',
76+
'display_name',
77+
'input_tokens',
78+
'output_tokens',
79+
'status',
80+
'total_tokens',
81+
'cost',
82+
'routing_tier',
83+
'routing_reason',
84+
'specificity_category',
85+
'error_message',
86+
'auth_type',
87+
'fallback_from_model',
88+
'fallback_index',
89+
] as const;
90+
91+
export function selectMessageRowColumns<T extends ObjectLiteral>(
92+
qb: SelectQueryBuilder<T>,
93+
costExpr: string,
94+
): SelectQueryBuilder<T> {
95+
return qb
96+
.select('at.id', 'id')
97+
.addSelect('at.timestamp', 'timestamp')
98+
.addSelect('at.agent_name', 'agent_name')
99+
.addSelect('at.model', 'model')
100+
.addSelect('at.provider', 'provider')
101+
.addSelect('at.model', 'display_name')
102+
.addSelect('at.input_tokens', 'input_tokens')
103+
.addSelect('at.output_tokens', 'output_tokens')
104+
.addSelect('at.status', 'status')
105+
.addSelect('at.input_tokens + at.output_tokens', 'total_tokens')
106+
.addSelect(costExpr, 'cost')
107+
.addSelect('at.routing_tier', 'routing_tier')
108+
.addSelect('at.routing_reason', 'routing_reason')
109+
.addSelect('at.specificity_category', 'specificity_category')
110+
.addSelect('at.error_message', 'error_message')
111+
.addSelect('at.auth_type', 'auth_type')
112+
.addSelect('at.fallback_from_model', 'fallback_from_model')
113+
.addSelect('at.fallback_index', 'fallback_index');
114+
}

packages/backend/src/analytics/services/timeseries-queries.service.spec.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,28 @@ describe('TimeseriesQueriesService', () => {
1010
let service: TimeseriesQueriesService;
1111
let mockGetRawMany: jest.Mock;
1212
let mockGetMany: jest.Mock;
13+
let mockTurnQb: {
14+
select: jest.Mock;
15+
addSelect: jest.Mock;
16+
leftJoin: jest.Mock;
17+
where: jest.Mock;
18+
andWhere: jest.Mock;
19+
orWhere: jest.Mock;
20+
groupBy: jest.Mock;
21+
addGroupBy: jest.Mock;
22+
orderBy: jest.Mock;
23+
addOrderBy: jest.Mock;
24+
limit: jest.Mock;
25+
getRawMany: jest.Mock;
26+
getRawOne: jest.Mock;
27+
getMany: jest.Mock;
28+
};
1329

1430
beforeEach(async () => {
1531
mockGetRawMany = jest.fn().mockResolvedValue([]);
1632
mockGetMany = jest.fn().mockResolvedValue([]);
1733

18-
const mockTurnQb = {
34+
mockTurnQb = {
1935
select: jest.fn().mockReturnThis(),
2036
addSelect: jest.fn().mockReturnThis(),
2137
leftJoin: jest.fn().mockReturnThis(),
@@ -157,6 +173,35 @@ describe('TimeseriesQueriesService', () => {
157173
mockGetRawMany.mockResolvedValue([]);
158174
expect(await service.getRecentActivity('24h', 'u1', 10)).toEqual([]);
159175
});
176+
177+
it('propagates specificity_category rows returned by the helper projection', async () => {
178+
mockGetRawMany.mockResolvedValue([
179+
{
180+
id: '1',
181+
timestamp: '2026-02-16T10:00:00',
182+
agent_name: 'bot-1',
183+
model: 'claude-opus-4-6',
184+
routing_tier: 'standard',
185+
routing_reason: 'specificity',
186+
specificity_category: 'coding',
187+
},
188+
]);
189+
const rows = (await service.getRecentActivity('24h', 'u1')) as Array<Record<string, unknown>>;
190+
expect(rows[0]!['specificity_category']).toBe('coding');
191+
});
192+
193+
it('projects specificity_category through the shared helper (regression: dashboard badge drift)', async () => {
194+
mockGetRawMany.mockResolvedValue([]);
195+
await service.getRecentActivity('24h', 'u1');
196+
197+
const projectedAliases = [
198+
...mockTurnQb.select.mock.calls.map((call) => call[1]),
199+
...mockTurnQb.addSelect.mock.calls.map((call) => call[1]),
200+
];
201+
expect(projectedAliases).toContain('specificity_category');
202+
expect(projectedAliases).toContain('routing_tier');
203+
expect(projectedAliases).toContain('routing_reason');
204+
});
160205
});
161206

162207
describe('getTimeseries', () => {

packages/backend/src/analytics/services/timeseries-queries.service.ts

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DataSource, Repository } from 'typeorm';
44
import { AgentMessage } from '../../entities/agent-message.entity';
55
import { Agent } from '../../entities/agent.entity';
66
import { rangeToInterval } from '../../common/utils/range.util';
7-
import { addTenantFilter } from './query-helpers';
7+
import { addTenantFilter, selectMessageRowColumns } from './query-helpers';
88
import { TenantCacheService } from '../../common/services/tenant-cache.service';
99
import {
1010
DbDialect,
@@ -126,26 +126,10 @@ export class TimeseriesQueriesService {
126126

127127
const costExpr = sqlCastFloat(sqlSanitizeCost('at.cost_usd'), this.dialect);
128128

129-
const qb = this.turnRepo
130-
.createQueryBuilder('at')
131-
.select('at.id', 'id')
132-
.addSelect('at.timestamp', 'timestamp')
133-
.addSelect('at.agent_name', 'agent_name')
134-
.addSelect('at.model', 'model')
135-
.addSelect('at.provider', 'provider')
136-
.addSelect('at.model', 'display_name')
137-
.addSelect('at.input_tokens', 'input_tokens')
138-
.addSelect('at.output_tokens', 'output_tokens')
139-
.addSelect('at.status', 'status')
140-
.addSelect('at.input_tokens + at.output_tokens', 'total_tokens')
141-
.addSelect(costExpr, 'cost')
142-
.addSelect('at.routing_tier', 'routing_tier')
143-
.addSelect('at.routing_reason', 'routing_reason')
144-
.addSelect('at.error_message', 'error_message')
145-
.addSelect('at.auth_type', 'auth_type')
146-
.addSelect('at.fallback_from_model', 'fallback_from_model')
147-
.addSelect('at.fallback_index', 'fallback_index')
148-
.where('at.timestamp >= :cutoff', { cutoff });
129+
const qb = selectMessageRowColumns(this.turnRepo.createQueryBuilder('at'), costExpr).where(
130+
'at.timestamp >= :cutoff',
131+
{ cutoff },
132+
);
149133
addTenantFilter(qb, userId, agentName, resolved);
150134
return qb.orderBy('at.timestamp', 'DESC').limit(limit).getRawMany();
151135
}

0 commit comments

Comments
 (0)