diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx
index d3011eb..2bfffda 100644
--- a/apps/dashboard/src/features/conversations/conversation-detail.tsx
+++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx
@@ -32,6 +32,7 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: }
+ {message.isAi ? AI : null}
{message.text}
diff --git a/apps/dashboard/src/features/conversations/conversations.css b/apps/dashboard/src/features/conversations/conversations.css
index 718798d..f0e2946 100644
--- a/apps/dashboard/src/features/conversations/conversations.css
+++ b/apps/dashboard/src/features/conversations/conversations.css
@@ -123,3 +123,17 @@
text-align: left;
}
}
+.conversation-ai-badge {
+ display: inline-flex;
+ align-items: center;
+ min-height: 1.25rem;
+ padding: 0.125rem 0.4rem;
+ border: 1px solid rgba(99, 102, 241, 0.35);
+ border-radius: 999px;
+ background: rgba(99, 102, 241, 0.12);
+ color: #5558c9;
+ font-size: 0.6875rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ line-height: 1;
+}
diff --git a/packages/api-contracts/src/chats.ts b/packages/api-contracts/src/chats.ts
index 0be6ebd..22f4065 100644
--- a/packages/api-contracts/src/chats.ts
+++ b/packages/api-contracts/src/chats.ts
@@ -13,6 +13,7 @@ export const ChatMessageSchema = z.strictObject({
text: z.string(),
timestamp: z.number(),
isSelf: z.boolean(),
+ isAi: z.boolean().optional(),
media: z.array(ChatMediaSchema).optional(),
});
export const ActiveChatSchema = z.strictObject({
diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts
index 6c2bc7b..2a142d3 100644
--- a/src/services/conversation-automation.ts
+++ b/src/services/conversation-automation.ts
@@ -7,6 +7,7 @@ import type { ChatService } from './chat/service.js';
import {
buildConversationState,
chooseConversationIntent,
+ conversationMessageFingerprint,
MAX_AUTOMATED_CONVERSATION_TURNS,
mergeConversationState,
remainingAutomationTurns,
@@ -101,6 +102,7 @@ export class ConversationAutomationService {
state.askedForMedia = true;
state.automation.turnsUsed += 1;
state.automation.lastIncomingKey = incomingKey;
+ state.automation.aiMessageFingerprints.push(conversationMessageFingerprint(reply));
const exhausted = state.automation.turnsUsed >= MAX_AUTOMATED_CONVERSATION_TURNS;
if (exhausted) {
state.automation.status = 'human-required';
diff --git a/src/services/conversation-state.ts b/src/services/conversation-state.ts
index ea1e6a3..f377195 100644
--- a/src/services/conversation-state.ts
+++ b/src/services/conversation-state.ts
@@ -1,3 +1,5 @@
+import { createHash } from 'node:crypto';
+
export interface ConversationMessage {
sender: string;
text: string;
@@ -45,6 +47,8 @@ export interface ConversationAutomationState {
status: ConversationAutomationStatus;
lastIncomingKey: string | null;
handoffReason: string | null;
+ /** SHA-256 fingerprints avoid storing a second copy of generated message text. */
+ aiMessageFingerprints: string[];
}
export function createConversationAutomationState(): ConversationAutomationState {
@@ -53,9 +57,14 @@ export function createConversationAutomationState(): ConversationAutomationState
status: 'active',
lastIncomingKey: null,
handoffReason: null,
+ aiMessageFingerprints: [],
};
}
+export function conversationMessageFingerprint(message: string): string {
+ return createHash('sha256').update(message.trim()).digest('hex');
+}
+
export function remainingAutomationTurns(state: ConversationState): number {
if (state.automation.status !== 'active') return 0;
return Math.max(0, MAX_AUTOMATED_CONVERSATION_TURNS - state.automation.turnsUsed);
@@ -222,6 +231,9 @@ function parseAutomationState(value: unknown): ConversationAutomationState {
status,
lastIncomingKey: typeof parsed.lastIncomingKey === 'string' ? parsed.lastIncomingKey : null,
handoffReason: typeof parsed.handoffReason === 'string' ? parsed.handoffReason : null,
+ aiMessageFingerprints: Array.isArray(parsed.aiMessageFingerprints)
+ ? parsed.aiMessageFingerprints.filter((item): item is string => typeof item === 'string')
+ : [],
};
}
diff --git a/src/web/routes/conversations.ts b/src/web/routes/conversations.ts
index a7ffd58..28fe9f8 100644
--- a/src/web/routes/conversations.ts
+++ b/src/web/routes/conversations.ts
@@ -4,6 +4,7 @@ import type { WebContext } from '../context.js';
import {
buildConversationState,
chooseConversationIntent,
+ conversationMessageFingerprint,
mergeConversationState,
remainingAutomationTurns,
} from '../../services/conversation-state.js';
@@ -169,12 +170,14 @@ export function createConversationsRouter(ctx: WebContext): Router {
imageUrl: string | null;
sentAt: number;
} | null = null;
+ let automatedOpener: string | null = null;
try {
const messagesRepo = resolved.runtime?.resources.messagesRepo ?? ctx.repos.messages;
const sent = conversation.roomId
? messagesRepo.getByChatRoomId(conversation.roomId)
?? messagesRepo.getLatestByRecipient(conversation.username)
: messagesRepo.getLatestByRecipient(conversation.username);
+ automatedOpener = sent?.message ?? null;
if (sent && sent.postId) {
const url = sent.postUrl ?? (sent.subreddit
? `https://www.reddit.com/r/${sent.subreddit}/comments/${sent.postId}/`
@@ -205,9 +208,25 @@ export function createConversationsRouter(ctx: WebContext): Router {
intelligence.state.automation.handoffReason = intelligence.state.boundaries.join('; ');
}
if (conversation.roomId) stateRepo?.save(conversation.roomId, intelligence.state);
+ const aiFingerprints = new Map();
+ if (automatedOpener) {
+ aiFingerprints.set(conversationMessageFingerprint(automatedOpener), 1);
+ }
+ for (const fingerprint of intelligence.state.automation.aiMessageFingerprints) {
+ aiFingerprints.set(fingerprint, (aiFingerprints.get(fingerprint) ?? 0) + 1);
+ }
+ const annotatedMessages = conversation.messages?.map((message) => {
+ if (!message.isSelf) return message;
+ const fingerprint = conversationMessageFingerprint(message.text);
+ const remainingMatches = aiFingerprints.get(fingerprint) ?? 0;
+ if (remainingMatches === 0) return message;
+ aiFingerprints.set(fingerprint, remainingMatches - 1);
+ return { ...message, isAi: true };
+ });
res.json({
...conversation,
+ ...(annotatedMessages ? { messages: annotatedMessages } : {}),
owner: resolved.runtime
? { accountId: resolved.runtime.session.accountId, redditUsername: resolved.runtime.session.redditUsername }
: currentOwner(),
diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts
index 8a1ea6a..30cb23e 100644
--- a/tests/conversation-automation.test.ts
+++ b/tests/conversation-automation.test.ts
@@ -46,6 +46,7 @@ describe('conversation automation', () => {
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
+ expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3);
expect(await service.processConversation(incoming(3))).toBeNull();
});
diff --git a/tests/dashboard/conversations.test.tsx b/tests/dashboard/conversations.test.tsx
index cc77808..e204f5e 100644
--- a/tests/dashboard/conversations.test.tsx
+++ b/tests/dashboard/conversations.test.tsx
@@ -40,7 +40,7 @@ function detailFixture(chat = alice) {
return {
...chat,
messages: [
- { sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true },
+ { sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true, isAi: true },
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
],
triggeringPost: {
@@ -136,6 +136,7 @@ describe('ConversationsPage', () => {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
+ expect(screen.getByLabelText('Sent by AI')).toHaveTextContent('AI');
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
'src',
diff --git a/tests/openrouter-client.test.ts b/tests/openrouter-client.test.ts
index 4176db7..c11cfe4 100644
--- a/tests/openrouter-client.test.ts
+++ b/tests/openrouter-client.test.ts
@@ -574,7 +574,7 @@ describe('OpenRouterClient', () => {
boundaries: [],
askedForMedia: false,
topics: [],
- automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null },
+ automation: { turnsUsed: 0, status: 'active', lastIncomingKey: null, handoffReason: null, aiMessageFingerprints: [] },
},
{ kind: 'answer-question', reason: 'their latest message asks a direct question' },
);
diff --git a/tests/web/conversations-route.test.ts b/tests/web/conversations-route.test.ts
index 98aed96..b9ae6b7 100644
--- a/tests/web/conversations-route.test.ts
+++ b/tests/web/conversations-route.test.ts
@@ -3,6 +3,7 @@ import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createConversationsRouter } from '../../src/web/routes/conversations.js';
+import { buildConversationState, conversationMessageFingerprint } from '../../src/services/conversation-state.js';
const servers: Array> = [];
@@ -234,13 +235,14 @@ describe('conversations route', () => {
lastMessageSnippet: 'Hello',
isFromUs: false,
unread: false,
- messages: [{
- sender: 'alice',
- text: 'Hello',
- timestamp: 1_786_000_000_000,
- isSelf: false,
- }],
+ messages: [
+ { sender: 'me', text: 'Automated hello', timestamp: 1_785_999_900_000, isSelf: true },
+ { sender: 'alice', text: 'Hello', timestamp: 1_786_000_000_000, isSelf: false },
+ ],
};
+ const savedState = buildConversationState(conversation.messages);
+ savedState.automation.turnsUsed = 1;
+ savedState.automation.aiMessageFingerprints = [conversationMessageFingerprint('Automated hello')];
const refreshTopChats = vi.fn();
const loadConversationDetails = vi.fn();
const context = {
@@ -249,6 +251,7 @@ describe('conversations route', () => {
messages: { getLatestByRecipient: vi.fn().mockReturnValue(undefined) },
},
services: {
+ conversationState: { get: vi.fn().mockReturnValue(savedState), save: vi.fn() },
chat: {
getSyncStatus: vi.fn().mockReturnValue({
syncing: false,
@@ -270,11 +273,13 @@ describe('conversations route', () => {
const body = await response.json();
expect(body.username).toBe('alice');
expect(body.automation).toEqual({
- turnsUsed: 0,
- remainingTurns: 3,
+ turnsUsed: 1,
+ remainingTurns: 2,
status: 'active',
handoffReason: null,
});
+ expect(body.messages[0]).toMatchObject({ text: 'Automated hello', isAi: true });
+ expect(body.messages[1].isAi).toBeUndefined();
expect(refreshTopChats).not.toHaveBeenCalled();
expect(loadConversationDetails).not.toHaveBeenCalled();
});
@@ -310,10 +315,12 @@ describe('conversations route', () => {
const response = await request(app, '/api/conversations/room-alice');
expect(response.status).toBe(200);
- expect((await response.json()).triggeringPost).toEqual({
+ const body = await response.json();
+ expect(body.triggeringPost).toEqual({
id: 'post-exact', subreddit: 'example', title: 'Exact source post',
url: roomOrigin.postUrl, imageUrl: roomOrigin.postImageUrl, sentAt: 9,
});
+ expect(body.messages[0]).toMatchObject({ text: 'Hello', isAi: true });
expect(context.repos.messages.getLatestByRecipient).not.toHaveBeenCalled();
});