Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions __tests__/unit/services/cat-post-mentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Wall posts reach the queue through a database trigger, because posts are
* written straight from the browser to a Postgres function and there is no
* server seam to hook. That trigger is a PREFILTER: it queues anything
* containing the substring "@cat", so `@catalogue` arrives here too.
*
* The point of these tests is that the prefilter is not the rule. Detection has
* ONE implementation — the resolver — and the worker is where its verdict is
* applied. A second copy of "what counts as a mention", written in SQL, is
* exactly the kind of duplication that drifts silently.
*/

const replyToPostMention = jest.fn().mockResolvedValue(true);
const resolveMentions = jest.fn();
const claimCatMentions = jest.fn();
const completeCatMention = jest.fn();
const failCatMention = jest.fn();

jest.mock('@/services/mentions/cat-account', () => ({
ensureCatAccount: jest.fn().mockResolvedValue({ id: 'cat-1', username: 'cat' }),
}));
jest.mock('@/services/mentions/cat-post-reply', () => ({
replyToPostMention: (...a: unknown[]) => replyToPostMention(...a),
}));
jest.mock('@/services/mentions/cat-reply', () => ({
replyToConversationMention: jest.fn().mockResolvedValue(true),
}));
jest.mock('@/services/mentions/resolve', () => ({
resolveMentions: (...a: unknown[]) => resolveMentions(...a),
}));
jest.mock('@/services/mentions/queue', () => ({
claimCatMentions: (...a: unknown[]) => claimCatMentions(...a),
completeCatMention: (...a: unknown[]) => completeCatMention(...a),
failCatMention: (...a: unknown[]) => failCatMention(...a),
MAX_ATTEMPTS: 3,
}));

import { runCatMentions } from '@/services/mentions/worker';

const postMention = {
id: 'q1',
source_type: 'timeline_event',
source_id: 'e1',
requester_id: 'u1',
conversation_id: null,
parent_event_id: 'e1',
attempts: 1,
};

/** Admin stub returning one post's text. */
const admin = (description: string) =>
({
from: () => ({
select: () => ({ eq: () => ({ maybeSingle: () => Promise.resolve({ data: { title: null, description }, error: null }) }) }),
}),
}) as never;

beforeEach(() => {
replyToPostMention.mockClear().mockResolvedValue(true);
resolveMentions.mockReset();
claimCatMentions.mockReset().mockResolvedValue([postMention]);
completeCatMention.mockReset();
failCatMention.mockReset();
});

describe('wall-post mentions', () => {
it('answers a post that really tags the Cat', async () => {
resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: true });
const result = await runCatMentions(admin('@cat is this goal realistic?'));
expect(replyToPostMention).toHaveBeenCalledWith(expect.anything(), {
eventId: 'e1',
catId: 'cat-1',
});
expect(result.answered).toBe(1);
});

it('discards the prefilter’s over-selection without replying', async () => {
// The trigger queued this because it contains "@cat" as a substring. The
// resolver says otherwise, and the resolver is the authority.
resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: false });
const result = await runCatMentions(admin('browsing the @catalogue today'));
expect(replyToPostMention).not.toHaveBeenCalled();
expect(result.answered).toBe(1);
expect(result.failed).toBe(0);
});

it('treats a discarded mention as resolved, not failed', async () => {
resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: false });
await runCatMentions(admin('the @catalogue'));
// Marking it failed would retry it three times and then log an error about
// a post that never asked the Cat anything.
expect(completeCatMention).toHaveBeenCalled();
expect(failCatMention).not.toHaveBeenCalled();
});

it('does not consult the resolver for a private-message mention', async () => {
claimCatMentions.mockResolvedValue([
{ ...postMention, conversation_id: 'c1', parent_event_id: null },
]);
await runCatMentions(admin('irrelevant'));
// Those arrive through an API route that already resolved them.
expect(resolveMentions).not.toHaveBeenCalled();
});
});
129 changes: 129 additions & 0 deletions src/services/mentions/cat-post-reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Answering a mention on the wall.
*
* Same promise as in a private message — the Cat answers where it was asked —
* but the context is different in a way that matters. On X, tagging Grok under
* a reply gets you an answer about that reply. Here the Cat reads the THREAD:
* the post that started it and the replies leading to the one that tagged it.
* "@cat is this realistic?" three replies deep is a question about the
* conversation, and answering it from the last sentence alone is the difference
* between an assistant and an autocomplete.
*/

import { CAT_CONTEXT_MESSAGE_WINDOW, CAT_DISPLAY_NAME, CAT_MENTION } from '@/config/cat-identity';
import { DATABASE_TABLES } from '@/config/database-tables';
import { callPlatformJson, parseJsonLoose } from '@/services/cat/platform-llm';
import { writeTimelineReply } from '@/services/timeline/write-timeline-reply';
import { logger } from '@/utils/logger';
import type { SupabaseClient } from '@supabase/supabase-js';

export const CAT_POST_FALLBACK =
"I couldn't work that one out just now — tag me again and I'll try afresh.";

const SYSTEM_PROMPT = [
`You are ${CAT_DISPLAY_NAME}, the OrangeCat agent, replying publicly under someone's post.`,
`You were tagged with ${CAT_MENTION}. Answer the question using the thread for context.`,
'This is a public reply: be brief, specific and useful to anyone reading, not just the asker.',
'Two or three sentences. No preamble, no restating the question, no sign-off.',
'If you do not know something, say so in one clause and say what would settle it.',
'Respond as JSON: {"reply": "<your message>"}',
].join('\n');

interface ThreadEvent {
id: string;
actor_id: string;
title: string | null;
description: string | null;
parent_event_id: string | null;
thread_id: string | null;
created_at: string;
}

/**
* The post that was tagged, plus the thread it belongs to, oldest first.
*
* Falls back to the tagged post alone when it starts a thread of its own — a
* top-level post has no ancestors, and that is not a failure.
*/
export async function loadThreadContext(
admin: SupabaseClient,
eventId: string
): Promise<ThreadEvent[]> {
const { data: tagged } = await admin
.from(DATABASE_TABLES.TIMELINE_EVENTS)
.select('id, actor_id, title, description, parent_event_id, thread_id, created_at')
.eq('id', eventId)
.maybeSingle();

if (!tagged) {
return [];
}
const event = tagged as ThreadEvent;
const threadId = event.thread_id ?? event.parent_event_id ?? event.id;

const { data: thread } = await admin
.from(DATABASE_TABLES.TIMELINE_EVENTS)
.select('id, actor_id, title, description, parent_event_id, thread_id, created_at')
.or(`id.eq.${threadId},thread_id.eq.${threadId}`)
.eq('is_deleted', false)
.order('created_at', { ascending: true })
.limit(CAT_CONTEXT_MESSAGE_WINDOW);

const rows = (thread ?? []) as ThreadEvent[];
// The tagged post itself must be present even if the thread query missed it
// (a brand-new row, or one whose thread_id was never set).
return rows.some(r => r.id === event.id) ? rows : [...rows, event];
}

export function buildThreadPrompt(events: ThreadEvent[], taggedId: string): string {
const lines = events.map(e => {
const body = (e.description ?? e.title ?? '').trim();
const marker = e.id === taggedId ? ' <- tagged you here' : '';
return `- ${body}${marker}`;
});
return ['Thread (oldest first):', lines.join('\n'), '', 'Reply to the post that tagged you.'].join(
'\n'
);
}

/**
* @returns true when a reply was posted. As in a private message, a failure to
* think still produces an answer rather than silence — under a public post
* that matters more, not less, because everyone can see nothing happened.
*/
export async function replyToPostMention(
admin: SupabaseClient,
params: { eventId: string; catId: string }
): Promise<boolean> {
const { eventId, catId } = params;

const thread = await loadThreadContext(admin, eventId);
if (thread.length === 0) {
logger.warn('Cat tagged on a post it cannot read', { eventId }, 'CatPostReply');
return false;
}

let reply = '';
try {
const raw = await callPlatformJson(SYSTEM_PROMPT, buildThreadPrompt(thread, eventId), {
timeoutMs: 30_000,
});
reply = (parseJsonLoose<{ reply?: string }>(raw)?.reply ?? '').trim();
} catch (error) {
logger.error(
'Cat post reply generation failed',
{ eventId, error: error instanceof Error ? error.message : String(error) },
'CatPostReply'
);
}

await writeTimelineReply(admin, {
parentEventId: eventId,
actorId: catId,
description: reply || CAT_POST_FALLBACK,
// Marked so the UI can render a Cat reply distinctly rather than leaving a
// reader to work out from the avatar that this one was written by an agent.
metadata: { is_cat_reply: true, answered_event_id: eventId },
});
return true;
}
37 changes: 35 additions & 2 deletions src/services/mentions/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

import { ensureCatAccount } from '@/services/mentions/cat-account';
import { replyToConversationMention } from '@/services/mentions/cat-reply';
import { replyToPostMention } from '@/services/mentions/cat-post-reply';
import { resolveMentions } from '@/services/mentions/resolve';
import { DATABASE_TABLES } from '@/config/database-tables';
import {
claimCatMentions,
completeCatMention,
Expand Down Expand Up @@ -94,7 +97,37 @@ async function answer(
catId,
});
}
// Wall posts are queued by the same table but answered by a later change;
// until then such a row would be retried forever, so it fails fast instead.

if (mention.parent_event_id) {
// The database trigger is a PREFILTER: it queues anything containing the
// substring "@cat", so `@catalogue` and `bob@catering.com` arrive here too.
// The resolver is the authority, and this is where its verdict is applied —
// detection has one implementation, not one per surface.
if (!(await postActuallyTagsTheCat(admin, mention.parent_event_id))) {
return true; // Nothing owed. Resolved, not failed.
}
return replyToPostMention(admin, { eventId: mention.parent_event_id, catId });
}

return false;
}

/** Ask the real resolver whether the post's text mentions the Cat. */
async function postActuallyTagsTheCat(
admin: SupabaseClient,
eventId: string
): Promise<boolean> {
const { data } = await admin
.from(DATABASE_TABLES.TIMELINE_EVENTS)
.select('title, description')
.eq('id', eventId)
.maybeSingle();

if (!data) {
return false;
}
const row = data as { title: string | null; description: string | null };
const text = `${row.description ?? ''}\n${row.title ?? ''}`;
const { mentionsCat } = await resolveMentions(admin, text);
return mentionsCat;
}
97 changes: 97 additions & 0 deletions src/services/timeline/write-timeline-reply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Writing a timeline reply as a trusted server caller.
*
* The same split as features/messaging/server/write-message.ts, for the same
* reason. `create_timeline_event` re-imposes the RLS actor check by hand —
* `v_actor_id IS DISTINCT FROM auth.uid()` raises "Actor mismatch" — which is
* exactly right when a browser calls it, and impossible for the Cat: a worker
* has no `auth.uid()` at all, so every Cat reply would raise.
*
* Relaxing that check is not an option; it is the only thing standing between
* one authenticated user and posting as another. So authorization stays there
* for callers that have a session, and this exists for callers that have
* already established authority some other way — which is why it takes an admin
* client explicitly instead of creating one.
*/

import { DATABASE_TABLES } from '@/config/database-tables';
import { logger } from '@/utils/logger';
import type { SupabaseClient } from '@supabase/supabase-js';

export interface WriteTimelineReplyInput {
parentEventId: string;
actorId: string;
description: string;
metadata?: Record<string, unknown>;
}

/** @returns the new event id, or null if it could not be written. */
export async function writeTimelineReply(
admin: SupabaseClient,
input: WriteTimelineReplyInput
): Promise<string | null> {
const { data: parent } = await admin
.from(DATABASE_TABLES.TIMELINE_EVENTS)
.select('id, thread_id, thread_depth, visibility')
.eq('id', input.parentEventId)
.maybeSingle();

if (!parent) {
logger.warn('Cannot reply to a post that is not there', { id: input.parentEventId }, 'TimelineWrite');
return null;
}

const parentRow = parent as {
id: string;
thread_id: string | null;
thread_depth: number | null;
visibility: string | null;
};

const title = input.description.slice(0, 140) || 'Update';
const now = new Date().toISOString();

const { data, error } = await admin
.from(DATABASE_TABLES.TIMELINE_EVENTS)
.insert({
event_type: 'status_update',
actor_id: input.actorId,
actor_type: 'user',
subject_type: 'profile',
subject_id: input.actorId,
title,
description: input.description,
content: { text: input.description },
// A reply inherits its parent's audience. Answering a followers-only post
// in public would republish the question to people who could not see it.
visibility: parentRow.visibility ?? 'public',
metadata: input.metadata ?? {},
parent_event_id: parentRow.id,
thread_id: parentRow.thread_id ?? parentRow.id,
thread_depth: (parentRow.thread_depth ?? 0) + 1,
event_timestamp: now,
created_at: now,
updated_at: now,
})
.select('id')
.single();

if (error || !data) {
logger.error('Failed to write a timeline reply', { error: error?.message }, 'TimelineWrite');
return null;
}

const eventId = (data as { id: string }).id;

// Without a visibility row the reply exists and appears on nobody's timeline —
// the same trap create_post_with_visibility exists to avoid.
const { error: visError } = await admin
.from('timeline_event_visibility')
.insert({ event_id: eventId, timeline_type: 'profile', timeline_owner_id: input.actorId });

if (visError) {
logger.warn('Cat reply written but not routed to a timeline', { eventId, error: visError.message }, 'TimelineWrite');
}

return eventId;
}
Loading
Loading