From 142378df33a57e74cdc03829be01146e94640e94 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Wed, 12 Aug 2026 08:23:22 +0100 Subject: [PATCH] feat: show the conversation evidence behind each learning trajectory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/features/accounts/accounts-page.tsx | 91 +++++++++++--- .../src/features/accounts/accounts.css | 2 + .../src/features/learning/learning-api.ts | 12 +- .../src/features/learning/learning-page.tsx | 61 ++++++++- .../src/features/learning/learning.css | 19 +++ .../src/features/settings/settings-api.ts | 11 ++ .../src/features/settings/settings-queries.ts | 26 +++- config.yaml | 4 +- docs/api.md | 2 + docs/conversation-learning.md | 2 +- packages/api-contracts/src/learning.ts | 24 ++++ packages/api-contracts/src/messages.ts | 4 + packages/api-contracts/src/session.ts | 6 + src/database/account-repositories.ts | 31 +++++ src/services/reddit-accounts.ts | 16 +++ src/web/api-catalog.ts | 11 +- src/web/learning-contracts.ts | 20 +++ src/web/routes/learning.ts | 11 ++ src/web/routes/login.ts | 22 ++++ tests/account-repositories.test.ts | 64 ++++++++++ tests/dashboard/learning.test.tsx | 112 +++++++++++++++++ tests/login-status.test.ts | 48 +++++++ tests/reddit-accounts-service.test.ts | 104 +++++++++++++++ tests/web/learning-route.test.ts | 118 ++++++++++++++++++ tests/web/stats-route.test.ts | 43 ++++++- 25 files changed, 836 insertions(+), 28 deletions(-) create mode 100644 tests/dashboard/learning.test.tsx create mode 100644 tests/reddit-accounts-service.test.ts create mode 100644 tests/web/learning-route.test.ts diff --git a/apps/dashboard/src/features/accounts/accounts-page.tsx b/apps/dashboard/src/features/accounts/accounts-page.tsx index 3722abd..93b633f 100644 --- a/apps/dashboard/src/features/accounts/accounts-page.tsx +++ b/apps/dashboard/src/features/accounts/accounts-page.tsx @@ -1,13 +1,19 @@ import { useEffect, useRef, useState } from 'react'; -import type { AccountEstablishmentSummary } from '../../../../../packages/api-contracts/src/index.js'; +import type { + AccountEstablishmentSummary, + RedditAccountSummary, +} from '../../../../../packages/api-contracts/src/index.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { useImportRedditSessionMutation, useLoginStatusQuery, useRedditAccountsQuery, + useRemoveRedditAccountMutation, } from '../settings/settings-queries.js'; import './accounts.css'; +const CONFIRM_TIMEOUT_MS = 8_000; + function errorMessage(error: Error | null): string { return error?.message ?? 'Something went wrong.'; } @@ -85,6 +91,73 @@ function establishmentPill(establishment: AccountEstablishmentSummary | null | u return Established; } +/** + * Removal is destructive — the account's messages, activity and sync state go + * with it — so the button arms a confirmation that disarms itself if ignored. + */ +function AccountRow({ account }: { account: RedditAccountSummary }) { + const removeMutation = useRemoveRedditAccountMutation(); + const [confirmRemove, setConfirmRemove] = useState(false); + + useEffect(() => { + if (!confirmRemove) return; + const timer = globalThis.setTimeout(() => setConfirmRemove(false), CONFIRM_TIMEOUT_MS); + return () => globalThis.clearTimeout(timer); + }, [confirmRemove]); + + return ( +
+
+ + {account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'} +
+
+ {establishmentPill(account.establishment)} + + {account.isRunning ? 'Running' : 'Paused'} + +
+
+ {confirmRemove ? ( + <> + + Removes u/{account.redditUsername}, its stored session and all of its messages, + conversations and activity. This cannot be undone. + + + + + ) : ( + + )} +
+ {removeMutation.isError ? ( + {errorMessage(removeMutation.error)} + ) : null} + {account.establishment && account.establishment.state !== 'unknown' ? ( + + ) : null} +
+ ); +} + function sessionLabel(status: ReturnType['data']): string { if (!status) return 'Unknown'; if (!status.ready) return 'Browser unavailable'; @@ -211,21 +284,7 @@ export function AccountsPage() { ) : null} {accounts.map((account) => ( -
-
- - {account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'} -
-
- {establishmentPill(account.establishment)} - - {account.isRunning ? 'Running' : 'Paused'} - -
- {account.establishment && account.establishment.state !== 'unknown' ? ( - - ) : null} -
+ ))} {accountsQuery.isSuccess && accounts.length === 0 ? (
diff --git a/apps/dashboard/src/features/accounts/accounts.css b/apps/dashboard/src/features/accounts/accounts.css index 3dd1fac..e8d5dab 100644 --- a/apps/dashboard/src/features/accounts/accounts.css +++ b/apps/dashboard/src/features/accounts/accounts.css @@ -9,6 +9,8 @@ .account-row span { color: var(--muted); font-size: .82rem; } .account-row .pill { font-size: .7rem; } .account-row > .account-row-pills { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; } +.account-row > .account-row-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: .4rem; margin-left: auto; } +.account-row .account-remove-warning { flex-basis: 100%; text-align: right; color: var(--danger-text, var(--muted)); font-size: .78rem; } /* Establishment evidence sits on its own row beneath the account summary. */ .account-establishment { flex-basis: 100%; display: grid; gap: .4rem; padding-top: .7rem; border-top: 1px solid var(--border); } diff --git a/apps/dashboard/src/features/learning/learning-api.ts b/apps/dashboard/src/features/learning/learning-api.ts index f316954..117a4ef 100644 --- a/apps/dashboard/src/features/learning/learning-api.ts +++ b/apps/dashboard/src/features/learning/learning-api.ts @@ -1,4 +1,10 @@ -import { LearningOverviewResponseSchema, LearningReviewResponseSchema, type LearningOverviewResponse } from '../../../../../packages/api-contracts/src/index.js'; +import { + LearningOverviewResponseSchema, + LearningReviewResponseSchema, + LearningTrajectoryDetailResponseSchema, + type LearningOverviewResponse, + type LearningTrajectoryDetailResponse, +} from '../../../../../packages/api-contracts/src/index.js'; import { createDashboardRequest } from '../../api/request.js'; async function parse(response: Response, schema: { parse(value: unknown): T }): Promise { @@ -12,6 +18,10 @@ export const learningApi = { getOverview(): Promise { return request('/api/learning').then((response) => parse(response, LearningOverviewResponseSchema)); }, + getTrajectory(id: string): Promise { + return request(`/api/learning/trajectories/${encodeURIComponent(id)}`) + .then((response) => parse(response, LearningTrajectoryDetailResponseSchema)); + }, review(id: string, status: 'eligible' | 'excluded'): Promise<{ success: true }> { return request(`/api/learning/trajectories/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }), diff --git a/apps/dashboard/src/features/learning/learning-page.tsx b/apps/dashboard/src/features/learning/learning-page.tsx index 72b7bd1..4c4f35c 100644 --- a/apps/dashboard/src/features/learning/learning-page.tsx +++ b/apps/dashboard/src/features/learning/learning-page.tsx @@ -1,11 +1,57 @@ +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { learningApi } from './learning-api.js'; import './learning.css'; const humanCredits = new Set(['human-created-success', 'human-improved-success', 'human-completed-ai-trajectory']); +function TrajectoryDetail({ id }: { id: string }) { + const detail = useQuery({ queryKey: ['learning', 'trajectory', id], queryFn: () => learningApi.getTrajectory(id) }); + if (detail.isPending) return
Loading decisions…
; + if (detail.isError) return
{detail.error.message}
; + const { trajectory, decisions } = detail.data; + return ( +
+
+
Conversation
{trajectory.conversationId}
+
Policy mode
{trajectory.policyMode ?? 'Unknown'}
+
Outcome
{trajectory.outcomeType.replaceAll('-', ' ')}
+
Matures
{new Date(trajectory.maturityAt).toLocaleString()}
+ {trajectory.invalidationReason ?
Invalidated
{trajectory.invalidationReason}
: null} +
+ {decisions.length === 0 ?

No manual decisions were captured for this trajectory.

: ( +
    + {decisions.map((decision) => ( +
  1. +
    + {decision.stage.replaceAll('-', ' ')} + {decision.isDirectMediaInvitation ? Direct media ask : null} + {decision.qualityStatus} + {decision.turnsUntilOutcome} turn(s) to outcome +
    + {decision.context.length > 0 ? ( +
    + {decision.context.map((turn) => ( +

    + {turn.role === 'recipient' ? 'Them' : turn.actor === 'human' ? 'You' : 'AI'} + {turn.text} +

    + ))} +
    + ) : null} +

    Your message{decision.chosenResponse}

    +
  2. + ))} +
+ )} +

Identifiers are redacted before storage. Text shown here is what generation would receive.

+
+ ); +} + export function LearningPage() { const client = useQueryClient(); + const [expanded, setExpanded] = useState(null); const overview = useQuery({ queryKey: ['learning'], queryFn: learningApi.getOverview }); const review = useMutation({ mutationFn: ({ id, status }: { id: string; status: 'eligible' | 'excluded' }) => learningApi.review(id, status), @@ -27,11 +73,16 @@ export function LearningPage() {
Policy {overview.data.policyVersion}

Attributed trajectories

n = {summary.total}
{trajectories.length === 0 ?

No received-image trajectories have been extracted yet. A full chat sync will backfill authoritative histories.

: ( -
{trajectories.map((item) => ( - - - - ))}
CreditCommunityConfidenceStatusUpdatedReview
{item.creditClass.replaceAll('-', ' ')}{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}{item.attributionConfidence}{item.status}{new Date(item.updatedAt).toLocaleString()}
+
{trajectories.flatMap((item) => { + const isOpen = expanded === item.id; + return [ + + + + , + ...(isOpen ? [] : []), + ]; + })}
ExpandCreditCommunityConfidenceStatusUpdatedReview
{item.creditClass.replaceAll('-', ' ')}{item.subreddit ? `r/${item.subreddit}` : 'Unknown'}{item.attributionConfidence}{item.status}{new Date(item.updatedAt).toLocaleString()}
)}
diff --git a/apps/dashboard/src/features/learning/learning.css b/apps/dashboard/src/features/learning/learning.css index f043346..b6f4e07 100644 --- a/apps/dashboard/src/features/learning/learning.css +++ b/apps/dashboard/src/features/learning/learning.css @@ -8,5 +8,24 @@ .learning-table tr.human-led th:first-child { color: var(--accent); } .review-actions { display: flex; gap: .4rem; } .review-actions .button { padding: .4rem .6rem; } +.expand-toggle { padding: .3rem .5rem; line-height: 1; } +.detail-row > td { background: var(--surface-strong); padding: .85rem 1rem; } +.trajectory-detail { display: grid; gap: .85rem; } +.trajectory-facts { display: flex; flex-wrap: wrap; gap: .35rem 1.4rem; margin: 0; } +.trajectory-facts dt { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; } +.trajectory-facts dd { margin: 0; font-size: .85rem; word-break: break-all; } +.decision-list { display: grid; gap: .75rem; list-style: none; margin: 0; padding: 0; } +.decision-card { border: 1px solid var(--border); border-radius: .6rem; display: grid; gap: .5rem; padding: .7rem .8rem; } +.decision-heading { align-items: center; display: flex; flex-wrap: wrap; gap: .5rem; } +.decision-stage { color: var(--accent); font-size: .78rem; text-transform: capitalize; } +.decision-flag { border-radius: 999px; background: var(--surface); color: var(--warning); font-size: .72rem; padding: .2rem .5rem; } +.decision-turns { color: var(--muted); font-size: .72rem; margin-left: auto; } +.decision-context { display: grid; gap: .3rem; } +.turn { display: grid; gap: .1rem; margin: 0; } +.turn-actor { color: var(--muted); font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; } +.turn-text { font-size: .85rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.turn-recipient .turn-text { color: var(--muted); } +.decision-chosen { border-left: 2px solid var(--accent); display: grid; gap: .1rem; margin: 0; padding-left: .6rem; } +.decision-note { color: var(--muted); font-size: .72rem; margin: 0; } @media (max-width: 900px) { .learning-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 600px) { .learning-metrics { grid-template-columns: 1fr; } } diff --git a/apps/dashboard/src/features/settings/settings-api.ts b/apps/dashboard/src/features/settings/settings-api.ts index 2716dd5..8b0c386 100644 --- a/apps/dashboard/src/features/settings/settings-api.ts +++ b/apps/dashboard/src/features/settings/settings-api.ts @@ -6,6 +6,7 @@ import { ErrorResponseSchema, LoginStatusResponseSchema, RedditAccountsResponseSchema, + RedditAccountRemovalResponseSchema, RedditCookieImportRequestSchema, RedditCookieImportResponseSchema, type ConfigResponse, @@ -52,6 +53,16 @@ export function fetchRedditAccounts(): Promise { return request('/api/accounts').then((response) => parseResponse(response, RedditAccountsResponseSchema)); } +export async function removeRedditAccount(accountId: string): Promise { + const response = await request(`/api/accounts/${encodeURIComponent(accountId)}`, { method: 'DELETE' }); + // A refused removal carries its reason in the same envelope as a successful + // one, so parse the body before falling back to the bare status code. + const parsed = RedditAccountRemovalResponseSchema.safeParse(await response.json() as unknown); + if (!parsed.success) throw new Error(`Request failed (${response.status})`); + if (!parsed.data.ok) throw new Error(parsed.data.error); + return parsed.data.account; +} + export async function importRedditSession(exported: unknown): Promise { const body = RedditCookieImportRequestSchema.parse(exported); const response = await parseResponse( diff --git a/apps/dashboard/src/features/settings/settings-queries.ts b/apps/dashboard/src/features/settings/settings-queries.ts index 51c0561..ce08524 100644 --- a/apps/dashboard/src/features/settings/settings-queries.ts +++ b/apps/dashboard/src/features/settings/settings-queries.ts @@ -1,6 +1,13 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js'; -import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js'; +import { + fetchLoginStatus, + fetchRedditAccounts, + fetchSettings, + importRedditSession, + removeRedditAccount, + saveSettings, +} from './settings-api.js'; import { fetchPushNotifications, savePushNotifications, @@ -75,3 +82,20 @@ export function useImportRedditSessionMutation() { }, }); } + +export function useRemoveRedditAccountMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: removeRedditAccount, + onSuccess() { + invalidateAccountContent(queryClient); + // Removal takes the account's outreach history with it, so every + // account-derived view on the dashboard is now stale. + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'growth'] }); + void queryClient.invalidateQueries({ queryKey: ['dashboard', 'stats'] }); + void queryClient.invalidateQueries({ queryKey: ['performance'] }); + void queryClient.invalidateQueries({ queryKey: ['outreach'] }); + void queryClient.invalidateQueries({ queryKey: ['attention'] }); + }, + }); +} diff --git a/config.yaml b/config.yaml index 661d0d4..15db9df 100644 --- a/config.yaml +++ b/config.yaml @@ -65,8 +65,8 @@ conversations: learning: captureEnabled: true # Build an auditable dataset without changing messages - generationEnabled: false # Enable only after the candidate policy passes offline evaluation - shadowMode: true # Record which examples would be used without injecting them + generationEnabled: true # Retrieve eligible examples for reply generation + shadowMode: false # Inject the retrieved examples into prompts (true = audit only) maturityHours: 12 maxContextExamples: 3 maxTurnsPerExample: 8 diff --git a/docs/api.md b/docs/api.md index dac1f2b..e979e94 100644 --- a/docs/api.md +++ b/docs/api.md @@ -64,6 +64,7 @@ an administrator credential. | `GET` | `/api/stats` | Get bot statistics | read | no | | `GET` | `/api/analytics/candidate-funnel` | Get durable candidate funnel counts | read | no | | `GET` | `/api/learning` | Inspect attributed learning trajectories and credit | read | no | +| `GET` | `/api/learning/trajectories/{id}` | Inspect the redacted decisions behind a learning trajectory | read | no | | `PATCH` | `/api/learning/trajectories/{id}` | Include or exclude a learning trajectory | admin | yes | | `GET` | `/api/activity` | Get recent activity | read | no | | `GET` | `/api/posts/by-author/{username}` | Get processed posts by author | read | no | @@ -127,6 +128,7 @@ an administrator credential. | `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes | | `GET` | `/api/accounts` | List Reddit accounts | read | no | | `POST` | `/api/accounts/{accountId}/activate` | Activate a Reddit account | admin | yes | +| `DELETE` | `/api/accounts/{accountId}` | Remove a Reddit account and the content it owns | admin | yes | | `GET` | `/api/logs/stream` | Stream legacy log events | read | no | | `GET` | `/api/events/stream` | Stream typed application events | read | no | | `GET` | `/api/logs` | Get recent structured logs | read | no | diff --git a/docs/conversation-learning.md b/docs/conversation-learning.md index 27d557a..970ae86 100644 --- a/docs/conversation-learning.md +++ b/docs/conversation-learning.md @@ -50,7 +50,7 @@ The repository/provider/evaluation interfaces are the framework seams. A hosted 1. Deploy with capture enabled, generation disabled, and shadow mode enabled. 2. Run a full chat sync to backfill authoritative histories. -3. Review inferred and fingerprint-attributed trajectories in `/learning`. +3. Review inferred and fingerprint-attributed trajectories in `/learning`. Expanding a row calls `GET /api/learning/trajectories/{id}` and shows the redacted context turns and chosen response behind each decision, so include/exclude is a judgement on visible evidence rather than on metadata alone. 4. Let examples mature and run the offline evaluation against a backup/non-production copy. 5. Compare human, autonomous, and recipient-initiated cohorts; do not blend them into the opener strategy metric. 6. Enable generation while retaining shadow mode to exercise selection/auditing. diff --git a/packages/api-contracts/src/learning.ts b/packages/api-contracts/src/learning.ts index a58ce19..ad147d7 100644 --- a/packages/api-contracts/src/learning.ts +++ b/packages/api-contracts/src/learning.ts @@ -32,4 +32,28 @@ export const LearningOverviewResponseSchema = z.strictObject({ export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) }); export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) }); +export const LearningStageSchema = z.enum([ + 'rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown', +]); +export const LearningDecisionStatusSchema = z.enum(['eligible', 'excluded', 'invalidated', 'needs-review']); +export const LearningTurnSchema = z.strictObject({ + eventId: z.string(), timestamp: z.number(), role: z.enum(['recipient', 'operator']), + actor: z.enum(['recipient', 'ai', 'human']), + kind: z.enum(['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal']), + text: z.string(), +}); +export const LearningDecisionSchema = z.strictObject({ + id: z.string(), trajectoryId: z.string(), accountId: z.string(), decisionEventId: z.string(), + stage: LearningStageSchema, context: z.array(LearningTurnSchema), chosenResponse: z.string(), + turnsUntilOutcome: z.number().int(), isDirectMediaInvitation: z.boolean(), + qualityStatus: LearningDecisionStatusSchema, createdAt: z.number(), updatedAt: z.number(), +}); +export const LearningTrajectoryDetailResponseSchema = z.strictObject({ + trajectory: LearningTrajectorySchema, + decisions: z.array(LearningDecisionSchema), +}); + export type LearningOverviewResponse = z.infer; +export type LearningTrajectoryDetailResponse = z.infer; +export type LearningDecision = z.infer; +export type LearningTurn = z.infer; diff --git a/packages/api-contracts/src/messages.ts b/packages/api-contracts/src/messages.ts index 41a852f..ee921ab 100644 --- a/packages/api-contracts/src/messages.ts +++ b/packages/api-contracts/src/messages.ts @@ -25,6 +25,10 @@ export const SentMessageSchema = z.strictObject({ postImageAnalysis: z.string().optional(), postCreatedAt: z.number().optional(), owner: RedditAccountOwnerSchema.optional(), + // Attribution/learning fields added by the conversation learning loop. + matrixEventId: z.string().nullable().optional(), + imageOutcomeCredit: z.string().nullable().optional(), + imageOutcomeAt: z.number().nullable().optional(), }); export const MessagesResponseSchema = z.array(SentMessageSchema); diff --git a/packages/api-contracts/src/session.ts b/packages/api-contracts/src/session.ts index 10e0c43..e54c0aa 100644 --- a/packages/api-contracts/src/session.ts +++ b/packages/api-contracts/src/session.ts @@ -88,6 +88,11 @@ export const RedditAccountActivationResponseSchema = z.union([ z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }), z.strictObject({ ok: z.literal(false), error: z.string() }), ]); +/** The removed account is echoed back so the caller can name what it deleted. */ +export const RedditAccountRemovalResponseSchema = z.union([ + z.strictObject({ ok: z.literal(true), account: RedditAccountSummarySchema }), + z.strictObject({ ok: z.literal(false), error: z.string() }), +]); export type RedditSessionState = z.infer; export type LoginStatusResponse = z.infer; @@ -96,3 +101,4 @@ export type RedditAccountSummary = z.infer; export type AccountEstablishmentState = z.infer; export type AccountEstablishmentSummary = z.infer; export type RedditAccountOwner = z.infer; +export type RedditAccountRemovalResponse = z.infer; diff --git a/src/database/account-repositories.ts b/src/database/account-repositories.ts index f9f0f28..bd190e1 100644 --- a/src/database/account-repositories.ts +++ b/src/database/account-repositories.ts @@ -43,6 +43,20 @@ type AccountRow = { last_imported_at: number | null; }; +/** + * Tables whose foreign key to `reddit_accounts` predates cascading deletes, + * ordered children first so each delete leaves no dangling composite reference. + */ +const ACCOUNT_OWNED_TABLES = [ + 'conversation_outcomes', + 'successful_messages', + 'delivery_outbox', + 'sent_messages', + 'conversation_states', + 'activity_log', + 'processed_posts', +] as const; + export class RedditAccountsRepository { constructor(private readonly db: Database.Database) {} @@ -128,6 +142,23 @@ export class RedditAccountsRepository { `).run(active ? 1 : 0, status, now, accountId).changes === 1; } + /** + * Remove the account and everything it owns. Account-scoped tables added + * after the ownership migration cascade on their own, but the tables that + * migration created reference `reddit_accounts` with no delete action, so + * their rows are cleared explicitly — children before parents — or SQLite + * would refuse the delete. + */ + delete(accountId: string): boolean { + requireText(accountId, 'accountId'); + return this.db.transaction((id: string) => { + for (const table of ACCOUNT_OWNED_TABLES) { + this.db.prepare(`DELETE FROM ${table} WHERE account_id = ?`).run(id); + } + return this.db.prepare('DELETE FROM reddit_accounts WHERE account_id = ?').run(id).changes === 1; + })(accountId); + } + markLoginRequired(accountId: string, now: number = Date.now()): boolean { return this.db.prepare(` UPDATE reddit_accounts diff --git a/src/services/reddit-accounts.ts b/src/services/reddit-accounts.ts index 2553954..8bbfde8 100644 --- a/src/services/reddit-accounts.ts +++ b/src/services/reddit-accounts.ts @@ -90,6 +90,22 @@ export class RedditAccountsService { return this.importSession(cookies, credentials); } + /** + * Stop the account's loop and drop it, along with the content it owns, from + * the database. Downloaded media files stay on disk: the media library can + * hand an image to another account, so the files are not ours alone to bin. + */ + async remove(accountId: string): Promise { + const account = this.get(accountId); + if (!account) throw new Error('Reddit account not found'); + await this.runtimes?.stop(accountId); + if (!this.accounts.delete(accountId)) throw new Error('Reddit account not found'); + if (this.auth.getSnapshot().accountId === accountId) { + this.auth.invalidate(`Reddit account u/${account.redditUsername} was removed`); + } + return { ...account, isRunning: false, status: 'login-required' }; + } + async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise { try { const verified = await this.importer.import(cookies, credentials); diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index 1b46a67..8419c7c 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -20,7 +20,12 @@ import { GrowthSubredditCreateRequestSchema, GrowthSubredditUpdateRequestSchema, } from './growth-contracts.js'; -import { LearningOverviewResponseSchema, LearningReviewRequestSchema, LearningReviewResponseSchema } from './learning-contracts.js'; +import { + LearningOverviewResponseSchema, + LearningReviewRequestSchema, + LearningReviewResponseSchema, + LearningTrajectoryDetailResponseSchema, +} from './learning-contracts.js'; export type ApiAccess = 'read' | 'control' | 'admin'; export type ApiMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -84,6 +89,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('getStats', 'GET', '/api/stats', 'Get bot statistics', ['Dashboard']), operation('getCandidateFunnel', 'GET', '/api/analytics/candidate-funnel', 'Get durable candidate funnel counts', ['Analytics']), operation('getLearningOverview', 'GET', '/api/learning', 'Inspect attributed learning trajectories and credit', ['Learning'], { responseSchema: LearningOverviewResponseSchema }), + operation('getLearningTrajectory', 'GET', '/api/learning/trajectories/{id}', 'Inspect the redacted decisions behind a learning trajectory', ['Learning'], { responseSchema: LearningTrajectoryDetailResponseSchema }), operation('reviewLearningTrajectory', 'PATCH', '/api/learning/trajectories/{id}', 'Include or exclude a learning trajectory', ['Learning'], { requestSchema: LearningReviewRequestSchema, responseSchema: LearningReviewResponseSchema }), operation('getActivity', 'GET', '/api/activity', 'Get recent activity', ['Dashboard']), operation('getPostsByAuthor', 'GET', '/api/posts/by-author/{username}', 'Get processed posts by author', ['Dashboard']), @@ -229,6 +235,9 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('importSession', 'POST', '/api/login/session-import', 'Import Reddit and Matrix credentials', ['Accounts']), operation('listAccounts', 'GET', '/api/accounts', 'List Reddit accounts', ['Accounts']), operation('activateAccount', 'POST', '/api/accounts/{accountId}/activate', 'Activate a Reddit account', ['Accounts']), + operation('removeAccount', 'DELETE', '/api/accounts/{accountId}', 'Remove a Reddit account and the content it owns', ['Accounts'], { + access: 'admin', + }), operation('streamLogs', 'GET', '/api/logs/stream', 'Stream legacy log events', ['Events'], { streaming: true }), operation('streamEvents', 'GET', '/api/events/stream', 'Stream typed application events', ['Events'], { streaming: true }), operation('getLogs', 'GET', '/api/logs', 'Get recent structured logs', ['Events']), diff --git a/src/web/learning-contracts.ts b/src/web/learning-contracts.ts index 561f436..f13b706 100644 --- a/src/web/learning-contracts.ts +++ b/src/web/learning-contracts.ts @@ -26,3 +26,23 @@ export const LearningOverviewResponseSchema = z.strictObject({ }); export const LearningReviewRequestSchema = z.strictObject({ status: z.enum(['eligible', 'excluded']) }); export const LearningReviewResponseSchema = z.strictObject({ success: z.literal(true) }); +export const LearningStageSchema = z.enum([ + 'rapport', 'answer', 'flirt', 'disclosure', 'media-invitation', 'handoff', 'unknown', +]); +export const LearningDecisionStatusSchema = z.enum(['eligible', 'excluded', 'invalidated', 'needs-review']); +export const LearningTurnSchema = z.strictObject({ + eventId: z.string(), timestamp: z.number(), role: z.enum(['recipient', 'operator']), + actor: z.enum(['recipient', 'ai', 'human']), + kind: z.enum(['inbound-text', 'opener', 'automation-reply', 'manual-text', 'library-reciprocal']), + text: z.string(), +}); +export const LearningDecisionSchema = z.strictObject({ + id: z.string(), trajectoryId: z.string(), accountId: z.string(), decisionEventId: z.string(), + stage: LearningStageSchema, context: z.array(LearningTurnSchema), chosenResponse: z.string(), + turnsUntilOutcome: z.number().int(), isDirectMediaInvitation: z.boolean(), + qualityStatus: LearningDecisionStatusSchema, createdAt: z.number(), updatedAt: z.number(), +}); +export const LearningTrajectoryDetailResponseSchema = z.strictObject({ + trajectory: LearningTrajectorySchema, + decisions: z.array(LearningDecisionSchema), +}); diff --git a/src/web/routes/learning.ts b/src/web/routes/learning.ts index 29c6c55..a062113 100644 --- a/src/web/routes/learning.ts +++ b/src/web/routes/learning.ts @@ -35,6 +35,17 @@ export function createLearningRouter(ctx: WebContext): Router { }); }); + router.get('/learning/trajectories/:id', (req, res) => { + for (const runtime of runtimes()) { + const detail = runtime.resources.conversationLearningRepo.getTrajectory(req.params.id); + if (!detail) continue; + const { decisions, ...trajectory } = detail; + res.json({ trajectory, decisions }); + return; + } + res.status(404).json({ error: 'Learning trajectory not found' }); + }); + router.patch('/learning/trajectories/:id', (req, res) => { const parsed = LearningReviewRequestSchema.safeParse(req.body); if (!parsed.success) { diff --git a/src/web/routes/login.ts b/src/web/routes/login.ts index c02ba6e..51db689 100644 --- a/src/web/routes/login.ts +++ b/src/web/routes/login.ts @@ -158,5 +158,27 @@ export function createLoginRouter(ctx: WebContext): Router { } }); + router.delete('/accounts/:accountId', async (req, res) => { + const accounts = ctx.services.redditAccounts; + if (!accounts) { + res.status(503).json({ ok: false, error: 'Reddit account service not initialised' }); + return; + } + const accountId = decodeURIComponent(req.params.accountId); + try { + const account = await accounts.remove(accountId); + ctx.logger.info(`Removed Reddit account u/${account.redditUsername} and the content it owned`); + res.json({ ok: true, account }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Could not remove Reddit account'; + if (message === 'Reddit account not found') { + res.status(404).json({ ok: false, error: message }); + return; + } + ctx.logger.error('Failed to remove Reddit account:', error); + res.status(500).json({ ok: false, error: message }); + } + }); + return router; } diff --git a/tests/account-repositories.test.ts b/tests/account-repositories.test.ts index c1d3a68..bf79cc0 100644 --- a/tests/account-repositories.test.ts +++ b/tests/account-repositories.test.ts @@ -134,6 +134,70 @@ describe('multi-account database ownership', () => { expect(db.pragma('foreign_key_check')).toEqual([]); }); + it('removes an account with everything it owns and leaves other accounts intact', () => { + addAccount('t2_alice', 'alice-bot', 100); + addAccount('t2_bob', 'bob-bot', 110); + + for (const accountId of ['t2_alice', 't2_bob']) { + db.prepare(` + INSERT INTO processed_posts (account_id, id, subreddit, author, title, processed_at) + VALUES (?, 'post-one', 'test', 'recipient', 'title', 1) + `).run(accountId); + db.prepare(` + INSERT INTO sent_messages (account_id, post_id, recipient, subject, message, sent_at) + VALUES (?, 'post-one', 'recipient', 'subject', 'message', 2) + `).run(accountId); + db.prepare(` + INSERT INTO successful_messages (account_id, original_message, created_at) + VALUES (?, 'message', 3) + `).run(accountId); + db.prepare(` + INSERT INTO delivery_outbox ( + account_id, post_id, recipient, subject, message, idempotency_key, + status, created_at, updated_at + ) VALUES (?, 'post-one', 'recipient', 'subject', 'message', 'key', 'prepared', 1, 1) + `).run(accountId); + db.prepare(` + INSERT INTO activity_log (account_id, timestamp, type, message) VALUES (?, 4, 'sent', 'activity') + `).run(accountId); + db.prepare(` + INSERT INTO conversation_states (account_id, conversation_id, state_json, updated_at) + VALUES (?, 'room', '{}', 5) + `).run(accountId); + content.upsertConversation({ + accountId, conversationId: 'room', username: 'recipient', roomName: 'Recipient', + payloadJson: '{}', updatedAt: 6, + }); + content.saveSyncState({ + accountId, nextBatch: 'cursor', lastFullSyncAt: 6, lastDeltaSyncAt: 6, + cooldownJson: null, updatedAt: 6, + }); + } + + expect(accounts.delete('t2_alice')).toBe(true); + + expect(accounts.get('t2_alice')).toBeNull(); + expect(accounts.listPublic().map((account) => account.accountId)).toContain('t2_bob'); + for (const table of [ + 'processed_posts', 'sent_messages', 'successful_messages', 'delivery_outbox', + 'activity_log', 'conversation_states', 'reddit_conversations', 'reddit_account_sync_state', + ]) { + expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_alice') as object }) + .toEqual({ table, count: 0 }); + expect({ table, ...db.prepare(`SELECT COUNT(*) count FROM ${table} WHERE account_id = ?`).get('t2_bob') as object }) + .toEqual({ table, count: 1 }); + } + expect(db.pragma('foreign_key_check')).toEqual([]); + }); + + it('reports an unknown account as not removed and rejects a blank id', () => { + addAccount('t2_alice', 'alice-bot', 100); + + expect(accounts.delete('t2_missing')).toBe(false); + expect(() => accounts.delete(' ')).toThrow(/accountId/); + expect(accounts.get('t2_alice')).not.toBeNull(); + }); + it('scopes legacy account-produced table uniqueness by account', () => { addAccount('t2_alice', 'alice-bot', 100); addAccount('t2_bob', 'bob-bot', 110); diff --git a/tests/dashboard/learning.test.tsx b/tests/dashboard/learning.test.tsx new file mode 100644 index 0000000..f587cb7 --- /dev/null +++ b/tests/dashboard/learning.test.tsx @@ -0,0 +1,112 @@ +// @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( + + + , + ); +} + +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'); + }); +}); diff --git a/tests/login-status.test.ts b/tests/login-status.test.ts index 765cffe..c6dae9f 100644 --- a/tests/login-status.test.ts +++ b/tests/login-status.test.ts @@ -149,6 +149,54 @@ describe('Reddit session status', () => { expect((await request(app, '/login/start', { method: 'POST' })).status).toBe(404); }); + it('removes an account and reports the account it deleted', async () => { + const remove = vi.fn().mockResolvedValue({ accountId: 't2_alice', redditUsername: 'alice-bot' }); + const app = express(); + app.use(createLoginRouter({ + services: { redditAccounts: { remove } }, + logger: { error: () => undefined, info: () => undefined }, + } as never)); + + const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + ok: true, + account: { accountId: 't2_alice', redditUsername: 'alice-bot' }, + }); + expect(remove).toHaveBeenCalledWith('t2_alice'); + }); + + it('answers 404 when the account to remove does not exist', async () => { + const app = express(); + app.use(createLoginRouter({ + services: { + redditAccounts: { remove: async () => { throw new Error('Reddit account not found'); } }, + }, + logger: { error: () => undefined, info: () => undefined }, + } as never)); + + const response = await request(app, '/accounts/t2_missing', { method: 'DELETE' }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ ok: false, error: 'Reddit account not found' }); + }); + + it('reports a failed removal as a server error rather than a silent success', async () => { + const app = express(); + app.use(createLoginRouter({ + services: { + redditAccounts: { remove: async () => { throw new Error('database is locked'); } }, + }, + logger: { error: () => undefined, info: () => undefined }, + } as never)); + + const response = await request(app, '/accounts/t2_alice', { method: 'DELETE' }); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ ok: false, error: 'database is locked' }); + }); + it('classifies Reddit navigation failures as unreachable', () => { expect( classifyRedditSessionError( diff --git a/tests/reddit-accounts-service.test.ts b/tests/reddit-accounts-service.test.ts new file mode 100644 index 0000000..aa14c49 --- /dev/null +++ b/tests/reddit-accounts-service.test.ts @@ -0,0 +1,104 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { RedditAccountsRepository } from '../src/database/account-repositories.js'; +import { runMigrations } from '../src/database/client.js'; +import { AccountAuthContext } from '../src/services/account-auth-context.js'; +import { RedditAccountsService } from '../src/services/reddit-accounts.js'; + +const silentLogger = { + info: () => undefined, + warn: () => undefined, + error: () => undefined, + debug: () => undefined, +} as never; + +describe('removing a Reddit account', () => { + let db: Database.Database; + let accounts: RedditAccountsRepository; + let auth: AccountAuthContext; + let stopped: string[]; + let service: RedditAccountsService; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + accounts = new RedditAccountsRepository(db); + auth = new AccountAuthContext(silentLogger); + stopped = []; + const runtimes = { + get: () => null, + stop: async (accountId: string) => { stopped.push(accountId); }, + }; + service = new RedditAccountsService( + accounts, + { import: async () => { throw new Error('not used'); } } as never, + auth, + runtimes as never, + ); + accounts.upsertVerified({ + accountId: 't2_alice', + redditUsername: 'alice-bot', + matrixUserId: '@t2_alice:reddit.com', + cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'secret' }]), + matrixCredentialsJson: JSON.stringify({ accessToken: 'matrix-secret' }), + now: 100, + }); + }); + + afterEach(() => db.close()); + + it('stops the runtime before dropping the account and its stored credentials', async () => { + const removed = await service.remove('t2_alice'); + + expect(stopped).toEqual(['t2_alice']); + expect(removed).toMatchObject({ + accountId: 't2_alice', + redditUsername: 'alice-bot', + isRunning: false, + status: 'login-required', + }); + expect(accounts.get('t2_alice')).toBeNull(); + expect(service.list()).toEqual([]); + }); + + it('drops the authenticated session when the removed account held it', async () => { + auth.markAuthenticated({ + accountId: 't2_alice', + redditUsername: 'alice-bot', + matrixUserId: '@t2_alice:reddit.com', + }); + expect(auth.getSnapshot().accountId).toBe('t2_alice'); + + await service.remove('t2_alice'); + + expect(auth.getSnapshot()).toMatchObject({ status: 'login-required', accountId: null }); + }); + + it('leaves another account\'s session alone', async () => { + accounts.upsertVerified({ + accountId: 't2_bob', + redditUsername: 'bob-bot', + matrixUserId: '@t2_bob:reddit.com', + cookiesJson: JSON.stringify([{ name: 'reddit_session', value: 'bob-secret' }]), + matrixCredentialsJson: JSON.stringify({ accessToken: 'bob-matrix-secret' }), + now: 110, + }); + auth.markAuthenticated({ + accountId: 't2_bob', + redditUsername: 'bob-bot', + matrixUserId: '@t2_bob:reddit.com', + }); + + await service.remove('t2_alice'); + + expect(auth.getSnapshot()).toMatchObject({ status: 'authenticated', accountId: 't2_bob' }); + expect(service.list().map((account) => account.accountId)).toEqual(['t2_bob']); + }); + + it('refuses to remove an account it does not know, without stopping anything', async () => { + await expect(service.remove('t2_missing')).rejects.toThrow('Reddit account not found'); + expect(stopped).toEqual([]); + expect(accounts.get('t2_alice')).not.toBeNull(); + }); +}); diff --git a/tests/web/learning-route.test.ts b/tests/web/learning-route.test.ts new file mode 100644 index 0000000..203241c --- /dev/null +++ b/tests/web/learning-route.test.ts @@ -0,0 +1,118 @@ +import express from 'express'; +import Database from 'better-sqlite3'; +import type { AddressInfo } from 'node:net'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LearningTrajectoryDetailResponseSchema } from '../../packages/api-contracts/src/index.js'; +import { runMigrations } from '../../src/database/client.js'; +import { ConversationLearningRepository } from '../../src/database/conversation-learning-repository.js'; +import type { WebContext } from '../../src/web/context.js'; +import { createLearningRouter } from '../../src/web/routes/learning.js'; + +const ACCOUNT = '@t2_learner:reddit.com'; +const CONVERSATION = '!room:reddit.com'; +const servers: Array> = []; + +async function call(app: express.Express, path: string): Promise<{ status: number; json: any }> { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}${path}`); + const text = await response.text(); + return { status: response.status, json: text ? JSON.parse(text) : null }; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => ( + new Promise((resolve) => server.close(() => resolve())) + ))); +}); + +describe('GET /api/learning/trajectories/:id', () => { + let db: Database.Database; + let repo: ConversationLearningRepository; + let app: express.Express; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + db.prepare(` + INSERT INTO reddit_accounts ( + account_id, reddit_username, matrix_user_id, status, is_active, + cookies_json, matrix_credentials_json, created_at, updated_at + ) VALUES (?, 'learner', ?, 'verified', 1, '[]', '{}', 0, 0) + `).run(ACCOUNT, ACCOUNT); + repo = new ConversationLearningRepository(db, ACCOUNT); + repo.upsertTrajectory({ + id: 'traj-1', + conversationId: CONVERSATION, + sentMessageId: null, + subreddit: 'cats', + sourceType: 'chat', + policyMode: 'r4r-media', + firstHumanEventId: '$human1', + outcomeEventId: '$image1', + outcomeType: 'image-received', + creditClass: 'human-created-success', + attributionConfidence: 'exact', + status: 'eligible', + maturityAt: 1_000, + invalidatedAt: null, + invalidationReason: null, + now: 900, + }); + repo.upsertDecision({ + id: 'dec-1', + trajectoryId: 'traj-1', + decisionEventId: '$human1', + stage: 'media-invitation', + context: [{ + eventId: '$in1', timestamp: 500, role: 'recipient', actor: 'recipient', + kind: 'inbound-text', text: 'hey there', + }], + chosenResponse: 'no pressure, only if you want to', + turnsUntilOutcome: 2, + isDirectMediaInvitation: true, + qualityStatus: 'eligible', + now: 900, + }); + + const ctx = { + config: { learning: { captureEnabled: true, generationEnabled: false, shadowMode: true, policyVersion: 'baseline-v1' } }, + services: { + accountRuntimes: { + list: () => [{ accountId: ACCOUNT }], + get: () => ({ resources: { conversationLearningRepo: repo } }), + }, + }, + } as unknown as WebContext; + + app = express(); + app.use(express.json()); + app.use('/api', createLearningRouter(ctx)); + }); + + it('returns the redacted decisions behind a trajectory', async () => { + const { status, json } = await call(app, '/api/learning/trajectories/traj-1'); + expect(status).toBe(200); + const parsed = LearningTrajectoryDetailResponseSchema.parse(json); + expect(parsed.trajectory.conversationId).toBe(CONVERSATION); + expect(parsed.trajectory.creditClass).toBe('human-created-success'); + expect(parsed.decisions).toHaveLength(1); + expect(parsed.decisions[0]?.chosenResponse).toBe('no pressure, only if you want to'); + expect(parsed.decisions[0]?.context[0]?.text).toBe('hey there'); + expect(parsed.decisions[0]?.isDirectMediaInvitation).toBe(true); + }); + + it('does not nest decisions inside the trajectory object', async () => { + const { json } = await call(app, '/api/learning/trajectories/traj-1'); + expect(json.trajectory.decisions).toBeUndefined(); + }); + + it('404s for an unknown trajectory', async () => { + const { status, json } = await call(app, '/api/learning/trajectories/missing'); + expect(status).toBe(404); + expect(json.error).toBe('Learning trajectory not found'); + }); +}); diff --git a/tests/web/stats-route.test.ts b/tests/web/stats-route.test.ts index a58e4cc..1a38991 100644 --- a/tests/web/stats-route.test.ts +++ b/tests/web/stats-route.test.ts @@ -1,7 +1,7 @@ import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; -import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js'; +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'; @@ -95,3 +95,44 @@ describe('stats route account status', () => { 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); + }); +});