Reviewers were asked to include or exclude learning trajectories while only seeing metadata — credit class, confidence, status. The decisions and their redacted context turns were already captured and stored, but no endpoint returned them, so the evidence was unreachable from the UI. Add GET /api/learning/trajectories/:id and an expandable row on the learning page that fetches on demand. Also widen SentMessageSchema to accept matrixEventId, imageOutcomeCredit and imageOutcomeAt. The learning loop added these to SentMessageRecord and they flow out of /api/messages, but the strict contract rejected them, breaking the dashboard. Enable generation with shadow mode off so eligible examples reach reply prompts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
139 lines
5.5 KiB
TypeScript
139 lines
5.5 KiB
TypeScript
import express from 'express';
|
|
import type { AddressInfo } from 'node:net';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import { MessagesResponseSchema, StatsResponseSchema } from '../../packages/api-contracts/src/index.js';
|
|
import type { WebContext } from '../../src/web/context.js';
|
|
import { createStatsRouter } from '../../src/web/routes/stats.js';
|
|
|
|
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
|
|
|
async function request(app: express.Express, path: string): Promise<Response> {
|
|
const server = app.listen(0);
|
|
servers.push(server);
|
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
|
const { port } = server.address() as AddressInfo;
|
|
return fetch(`http://127.0.0.1:${port}${path}`);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
|
});
|
|
|
|
describe('stats route account status', () => {
|
|
it('reports sending availability and Matrix cooldown independently for every account', async () => {
|
|
const now = Date.now();
|
|
const account = (
|
|
accountId: string,
|
|
redditUsername: string,
|
|
cooldown: { until: number; reason: string; redditCode: string | null; setAt: number } | null,
|
|
) => ({
|
|
session: { accountId, redditUsername },
|
|
resources: {
|
|
accountAuth: { isFullyAuthenticated: () => true },
|
|
chatService: { getInvitationCooldown: () => cooldown },
|
|
rateLimiter: {
|
|
getStats: () => ({ messagesThisHour: cooldown ? 5 : 2, minutesSinceLastMessage: 1 }),
|
|
getTimers: () => ({
|
|
nextMessageAvailableAt: cooldown?.until ?? null,
|
|
hourlyResetAt: now + 3_600_000,
|
|
isHourlyLimitReached: false,
|
|
messagesRemaining: cooldown ? 3 : 6,
|
|
}),
|
|
},
|
|
conversationAutomation: { getPriorityStatus: () => ({ processing: false, actionableBacklog: 0 }) },
|
|
messagesRepo: { getRecent: () => [] },
|
|
activityLogRepo: { getRecent: () => [], countSentSkipped: () => 0 },
|
|
},
|
|
});
|
|
const ready = account('@t2_ready:reddit.com', 'ready-account', null);
|
|
const limited = account('@t2_limited:reddit.com', 'limited-account', {
|
|
until: now + 600_000,
|
|
reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED',
|
|
redditCode: 'rate.invitation_limit_mature_account',
|
|
setAt: now,
|
|
});
|
|
const runtimes = new Map([[ready.session.accountId, ready], [limited.session.accountId, limited]]);
|
|
const context = {
|
|
config: {
|
|
browser: { dryRun: false },
|
|
rateLimit: { maxMessagesPerHour: 8 },
|
|
reddit: { subreddits: [], targetGenders: [] },
|
|
},
|
|
services: {
|
|
redditAccounts: { list: () => [...runtimes.values()].map((runtime) => runtime.session) },
|
|
accountRuntimes: {
|
|
list: () => [...runtimes.values()].map((runtime) => ({
|
|
...runtime.session,
|
|
matrixUserId: runtime.session.accountId,
|
|
state: 'running',
|
|
updatedAt: now,
|
|
failure: null,
|
|
})),
|
|
get: (accountId: string) => runtimes.get(accountId) ?? null,
|
|
},
|
|
},
|
|
repos: { messages: { getRecent: () => [] } },
|
|
activity: { recent: () => [], totalProcessed: () => 0 },
|
|
getBotRunning: () => true,
|
|
getBotMode: () => 'running',
|
|
getMessagesThisHour: () => 0,
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createStatsRouter(context));
|
|
|
|
const response = await request(app, '/api/stats');
|
|
const stats = StatsResponseSchema.parse(await response.json());
|
|
|
|
expect(stats.accounts).toEqual([
|
|
expect.objectContaining({ redditUsername: 'ready-account', sendingState: 'ready', messagesThisHour: 2 }),
|
|
expect.objectContaining({
|
|
redditUsername: 'limited-account',
|
|
sendingState: 'matrix-cooldown',
|
|
invitationCooldown: expect.objectContaining({ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED' }),
|
|
}),
|
|
]);
|
|
expect(stats.invitationCooldown).toMatchObject({ redditUsername: 'limited-account' });
|
|
expect(stats.accounts[1]?.invitationCooldown).not.toHaveProperty('setAt');
|
|
});
|
|
});
|
|
|
|
describe('messages route contract', () => {
|
|
it('accepts the attribution fields the learning loop adds to sent messages', async () => {
|
|
const context = {
|
|
services: {},
|
|
repos: {
|
|
messages: {
|
|
getRecent: () => [{
|
|
id: 1,
|
|
postId: 'p1',
|
|
recipient: 'someone',
|
|
subject: 'hey',
|
|
message: 'hello there',
|
|
sentAt: 1_700_000_000_000,
|
|
gotResponse: true,
|
|
gotImage: true,
|
|
responseCheckedAt: 1_700_000_100_000,
|
|
strategyId: null,
|
|
chatRoomId: '!room:reddit.com',
|
|
// Added by the conversation learning loop; must not break the dashboard.
|
|
matrixEventId: '$opener1',
|
|
imageOutcomeCredit: 'human-created-success',
|
|
imageOutcomeAt: 1_700_000_200_000,
|
|
}],
|
|
},
|
|
},
|
|
activity: { recent: () => [], totalProcessed: () => 0 },
|
|
getBotRunning: () => true,
|
|
getBotMode: () => 'running',
|
|
getMessagesThisHour: () => 0,
|
|
} as unknown as WebContext;
|
|
const app = express().use('/api', createStatsRouter(context));
|
|
|
|
const response = await request(app, '/api/messages');
|
|
const messages = MessagesResponseSchema.parse(await response.json());
|
|
|
|
expect(messages[0]?.matrixEventId).toBe('$opener1');
|
|
expect(messages[0]?.imageOutcomeCredit).toBe('human-created-success');
|
|
expect(messages[0]?.imageOutcomeAt).toBe(1_700_000_200_000);
|
|
});
|
|
});
|