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
15 changes: 14 additions & 1 deletion src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const LEGACY_MESSAGES_FILE = join(LEGACY_DATA_DIR, 'messages.jsonl')
// All reads go directly to SQLite — no in-memory message cache.
// JSONL file is kept as an append-only audit trail.

// Sorted-endpoints DM channel: a→b and b→a hash to the same string so
// both sides land in one thread. Lowercased so case variants do not
// fork. Used by sendMessage when `to:` is set without an explicit
// channel.
export function deriveDmChannel(from: string, to: string): string {
return `dm:${[from, to].map(s => s.toLowerCase().trim()).sort().join('_')}`
}

function importMessages(db: Database.Database, records: unknown[]): number {
const byId = new Map<string, AgentMessage>()

Expand Down Expand Up @@ -408,7 +416,12 @@ class ChatManager {
}

async sendMessage(message: Omit<AgentMessage, 'id' | 'timestamp' | 'replyCount'>): Promise<AgentMessage> {
const channel = message.channel || 'general'
// Honor `to:` end-to-end. When the caller sets `to` without an
// explicit channel, route to a stable `dm:<sorted endpoints>`
// channel so both sides land in the same thread (a→b and b→a hash
// identically). Without this, every DM fell through to #general.
const channel = message.channel
|| (message.to ? deriveDmChannel(message.from, message.to) : 'general')

// ── Noise Budget Checks ──
// Skip budget checks for direct messages (has `to` field) and metadata.bypass_budget
Expand Down
7 changes: 6 additions & 1 deletion src/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,10 +1189,15 @@ async function syncChat(): Promise<void> {
oldestFirst: true,
}).filter(m => (m.metadata as any)?.source !== 'cloud-relay')

// Send to cloud and get pending outbound messages
// Send to cloud and get pending outbound messages.
// Preserve `to:` over the wire so the cloud receives the recipient
// signal alongside the (already-derived) `dm:<sorted>` channel —
// belt-and-suspenders for the DM routing seam. Cloud chat_messages
// schema is unchanged; the cloud handler ignores `to` for now.
const payload = recentMessages.map(m => ({
id: m.id,
from: m.from,
...(m.to ? { to: m.to } : {}),
content: m.channel === 'github' || m.from === 'github'
? remapGitHubMentions(m.content)
: m.content,
Expand Down
75 changes: 75 additions & 0 deletions tests/chat-dm-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { createServer } from '../src/server.js'
import { deriveDmChannel } from '../src/chat.js'
import type { FastifyInstance } from 'fastify'

let app: FastifyInstance

beforeAll(async () => {
process.env.REFLECTT_DATA_DIR = `/tmp/reflectt-test-dm-routing-${Date.now()}`
app = await createServer()
await app.ready()
})

afterAll(async () => {
await app.close()
})

async function postChat(body: Record<string, unknown>) {
const res = await app.inject({
method: 'POST',
url: '/chat/messages',
payload: body,
})
expect(res.statusCode).toBe(200)
return JSON.parse(res.body).message as {
id: string
from: string
to?: string | null
channel: string
content: string
}
}

describe('deriveDmChannel', () => {
it('sorts endpoints so a→b and b→a hash identically', () => {
expect(deriveDmChannel('claude', 'kai')).toBe('dm:claude_kai')
expect(deriveDmChannel('kai', 'claude')).toBe('dm:claude_kai')
})

it('lowercases endpoints so case variants do not fork', () => {
expect(deriveDmChannel('KAI', 'Claude')).toBe('dm:claude_kai')
})

it('trims whitespace from endpoints', () => {
expect(deriveDmChannel(' claude ', 'kai')).toBe('dm:claude_kai')
})
})

describe('POST /chat/messages — to: routing seam', () => {
it('derives dm:<sorted> channel when to: is set and channel is absent', async () => {
const msg = await postChat({ from: 'kai', to: 'claude', content: 'hey, sync on lane?' })
expect(msg.channel).toBe('dm:claude_kai')
expect(msg.to).toBe('claude')
})

it('does NOT land DM in #general anymore', async () => {
const msg = await postChat({ from: 'compass', to: 'orbit', content: 'rolling to b67fd7d' })
expect(msg.channel).not.toBe('general')
expect(msg.channel).toBe('dm:compass_orbit')
})

it('explicit channel still wins over to: derivation', async () => {
const msg = await postChat({
from: 'claude', to: 'kai', channel: 'general',
content: '@kai routing question for the room',
})
expect(msg.channel).toBe('general')
expect(msg.to).toBe('kai')
})

it('no to: still goes to #general by default', async () => {
const msg = await postChat({ from: 'claude', content: 'team broadcast' })
expect(msg.channel).toBe('general')
})
})
Loading