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); + }); +});