Files
RedditBot/tests/dashboard/learning.test.tsx
T
Jamie BruntonandClaude Opus 5 142378df33 feat: show the conversation evidence behind each learning trajectory
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>
2026-08-12 08:23:22 +01:00

113 lines
4.4 KiB
TypeScript

// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { LearningPage } from '../../apps/dashboard/src/features/learning/learning-page.js';
const TRAJECTORY_ID = 'traj-1';
let detailCalls: string[] = [];
function summary() {
return {
total: 1, provisional: 0, eligible: 1, excluded: 0, invalidated: 0, needsReview: 0,
humanCreated: 1, humanImproved: 0, humanCompletedAi: 0, autonomous: 0,
recipientInitiated: 0, uncertain: 0, exact: 1, fingerprint: 0, inferred: 0,
};
}
function trajectory() {
return {
id: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com', conversationId: '!room:reddit.com',
sentMessageId: 7, subreddit: 'cats', sourceType: 'chat' as const, policyMode: 'r4r-media' as const,
firstHumanEventId: '$human1', outcomeEventId: '$image1', outcomeType: 'image-received' as const,
creditClass: 'human-created-success' as const, attributionConfidence: 'exact' as const,
status: 'eligible' as const, maturityAt: 1_700_000_100_000, invalidatedAt: null,
invalidationReason: null, reviewedAt: null, createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
function decision() {
return {
id: 'dec-1', trajectoryId: TRAJECTORY_ID, accountId: '@t2_learner:reddit.com',
decisionEventId: '$human1', stage: 'media-invitation' as const,
context: [{
eventId: '$in1', timestamp: 1_700_000_010_000, role: 'recipient' as const,
actor: 'recipient' as const, kind: 'inbound-text' as const, text: 'hey how was your weekend',
}],
chosenResponse: 'no pressure, only if you want to',
turnsUntilOutcome: 2, isDirectMediaInvitation: true, qualityStatus: 'eligible' as const,
createdAt: 1_700_000_000_000, updatedAt: 1_700_000_050_000,
};
}
const server = setupServer(
http.get('/api/learning', () => HttpResponse.json({
summary: summary(), trajectories: [trajectory()],
captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1',
})),
http.get('/api/learning/trajectories/:id', ({ params }) => {
detailCalls.push(String(params.id));
return HttpResponse.json({ trajectory: trajectory(), decisions: [decision()] });
}),
);
function renderLearning() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<LearningPage />
</QueryClientProvider>,
);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
server.resetHandlers();
cleanup();
});
afterAll(() => server.close());
beforeEach(() => {
detailCalls = [];
});
describe('learning page trajectory detail', () => {
it('does not fetch decisions until a row is expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
expect(detailCalls).toEqual([]);
});
it('reveals the redacted conversation evidence when expanded', async () => {
renderLearning();
await screen.findByText(/human created success/i);
await userEvent.click(screen.getByRole('button', { name: /show conversation evidence/i }));
expect(await screen.findByText('no pressure, only if you want to')).toBeInTheDocument();
expect(screen.getByText('hey how was your weekend')).toBeInTheDocument();
expect(screen.getByText('!room:reddit.com')).toBeInTheDocument();
expect(screen.getByText(/direct media ask/i)).toBeInTheDocument();
await waitFor(() => expect(detailCalls).toEqual([TRAJECTORY_ID]));
});
it('collapses the evidence again on a second click', async () => {
renderLearning();
await screen.findByText(/human created success/i);
const toggle = screen.getByRole('button', { name: /show conversation evidence/i });
await userEvent.click(toggle);
await screen.findByText('no pressure, only if you want to');
await userEvent.click(toggle);
await waitFor(() => {
expect(screen.queryByText('no pressure, only if you want to')).not.toBeInTheDocument();
});
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});
});