From d0a2d9999eafdf6ee9d74e8f30ec5b734e18f71e Mon Sep 17 00:00:00 2001 From: Jacob Cole Date: Tue, 8 Sep 2026 16:45:00 -0600 Subject: [PATCH 1/2] fix(assistant): message people by name, and repair feedback filing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three failures from Jacob's 2026-09-08 Assistant session, all traced in prod data: 1. "Feedback service is down right now" — WIT_API_BASE defaulted to the `sthqnyjniclvnflfkyio` Supabase project, which is PAUSED. Requests fail at connect, so every submit_feedback and POST /api/feedback died. Point both at the live `qmzopiburflputowkuhu` base (verified: create-issue returns success). Also updated .env.example and the prod compose default. 2. "I don't have access to that conversation" when sending to Robert — the confirmation gate is a two-turn handshake, but each assistant turn rebuilds context from persisted chat messages only; tool results are never stored. So the conversationId found in turn A was gone by the time the user said "yes" in turn B, and the model guessed. Remember the unconfirmed send server-side (15 min TTL, one slot per user) and re-inject it into the next turn's system prompt so the handshake can complete. 3. No way to message a person — the only send tool took a conversationId, forcing the model to dig one out of list_conversations and carry it across turns. Add find_person and send_message_to_person, which work in the terms users actually speak (a name), resolve the DM via the shared ensureDirectConversation path, and refuse to guess when a name is ambiguous or unreachable. Discovery scope mirrors GET /api/chat/contacts, so the Assistant can never surface someone the user couldn't find themselves: existing contacts by name, anyone by complete email, plus substring search for trusted directory users. Confirmation and rate limiting apply to the new path exactly as before. Co-Authored-By: Claude Opus 5 --- apps/server/.env.example | 2 +- apps/server/src/routes/feedback.ts | 6 +- apps/server/src/services/assistant.ts | 251 +++++++++++++++++- .../assistantSendToPerson.integration.test.ts | 167 ++++++++++++ infra/docker-compose.prod.yml | 2 +- 5 files changed, 420 insertions(+), 8 deletions(-) create mode 100644 apps/server/test/assistantSendToPerson.integration.test.ts diff --git a/apps/server/.env.example b/apps/server/.env.example index 81c75f9..56c3fc4 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -53,5 +53,5 @@ GOOGLE_CLIENT_SECRET= # Mint via the WIT UI (worldissuetracker.com) or reuse ~/.config/wit/agent-key. WIT_AGENT_KEY= # Optional overrides (defaults are correct for prod): -# WIT_API_BASE=https://sthqnyjniclvnflfkyio.supabase.co/functions/v1 +# WIT_API_BASE=https://qmzopiburflputowkuhu.supabase.co/functions/v1 # WIT_SITE_URL=https://worldissuetracker.com diff --git a/apps/server/src/routes/feedback.ts b/apps/server/src/routes/feedback.ts index d494b76..0d3e622 100644 --- a/apps/server/src/routes/feedback.ts +++ b/apps/server/src/routes/feedback.ts @@ -16,9 +16,13 @@ import { resolveActor } from '../middleware/resolveActor.js'; const router = Router(); +// WIT's Supabase project moved. The old `sthqnyjniclvnflfkyio` project is +// PAUSED — requests to it fail at connect, which surfaced to users as +// "Feedback service is down right now". `worldissuetracker.com/llms.txt` is the +// authoritative source for this base if it ever moves again. const WIT_BASE = process.env.WIT_API_BASE || - 'https://sthqnyjniclvnflfkyio.supabase.co/functions/v1'; + 'https://qmzopiburflputowkuhu.supabase.co/functions/v1'; const WIT_SITE = process.env.WIT_SITE_URL || 'https://worldissuetracker.com'; // File feedback onto the OpenChat board/tracker by default. Previously omitted, // so every feedback issue was created ORPHAN (tracker_id null). Override via env. diff --git a/apps/server/src/services/assistant.ts b/apps/server/src/services/assistant.ts index 4cb0d8b..c065e22 100644 --- a/apps/server/src/services/assistant.ts +++ b/apps/server/src/services/assistant.ts @@ -410,6 +410,51 @@ function sendRateLimited(userId: string): boolean { return false; } +// ─── Pending outbound send (cross-turn confirmation memory) ────────────────── +// The confirmation gate below is a TWO-TURN handshake: turn A returns +// needsConfirmation, the user replies "yes", and turn B is supposed to re-call +// send_message with the same conversationId + content and confirm:true. +// +// But each assistant turn rebuilds its context from the PERSISTED CHAT MESSAGES +// only (see loadConversationContext) — tool calls and tool results are not +// stored. So by turn B the conversationId the model discovered in turn A is +// simply gone, and the model has to guess. That is exactly how Jacob's +// "Say 'test'" → "Yes" → "I don't have access to that conversation" failure +// happened on 2026-09-08. +// +// Fix: remember the pending send server-side, keyed by user, and re-inject it +// into the next turn's system prompt so turn B can complete the handshake with +// the real ids. Single-slot per user: a new proposal replaces the old one. +const PENDING_SEND_TTL_MS = 15 * 60_000; + +interface PendingSend { + conversationId: string; + content: string; + conversationName: string; + recipients: string[]; + at: number; +} + +const pendingSends = new Map(); + +function rememberPendingSend(userId: string, pending: Omit): void { + pendingSends.set(userId, { ...pending, at: Date.now() }); +} + +export function getPendingSend(userId: string): PendingSend | null { + const p = pendingSends.get(userId); + if (!p) return null; + if (Date.now() - p.at > PENDING_SEND_TTL_MS) { + pendingSends.delete(userId); + return null; + } + return p; +} + +function clearPendingSend(userId: string): void { + pendingSends.delete(userId); +} + async function toolSendMessage( io: IOServer | undefined, userId: string, @@ -457,6 +502,14 @@ async function toolSendMessage( // model must surface the send to the user and re-call with confirm:true. A // self/Assistant-only DM sends freely. if (hadOtherHumans && !confirm) { + // Stash it so the NEXT turn (after the user says "yes") can still resolve + // the real conversationId — see the PendingSend note above. + rememberPendingSend(userId, { + conversationId, + content: content.trim(), + conversationName, + recipients: otherHumans, + }); return { needsConfirmation: true, conversationName, @@ -472,6 +525,7 @@ async function toolSendMessage( const persisted = await persistMessage(io, userId, conversationId, content, { viaAssistant: true }); if (!persisted) return { error: 'Failed to send message' }; + clearPendingSend(userId); // AUDIT (openchat-bfn.4): log every assistant-initiated send. console.log( @@ -482,6 +536,135 @@ async function toolSendMessage( return { ok: true, messageId: persisted.message.id, conversationId }; } +// ─── Person resolution + send-to-person (openchat-0xjt follow-on) ──────────── +// Until now the only way to message somebody was send_message(conversationId), +// which forced the model to first dig a conversation id out of +// list_conversations and carry it across turns. That is the indirection that +// broke Jacob's "send Robert a text" request. These two tools let the model +// work in the terms the user actually speaks: a person's name. +// +// Discovery scope mirrors GET /api/chat/contacts so the Assistant can never +// surface someone the user couldn't already find themselves: +// 1. people the user already shares a conversation with -> name/email substring +// 2. anyone -> complete, case-insensitive email match +// 3. trusted directory users (canBrowseUserDirectory) -> substring anywhere +interface ResolvedPerson { + id: string; + name: string | null; + email: string | null; + known: boolean; +} + +export async function resolvePeople(userId: string, query: string): Promise { + const q = query.trim().toLowerCase(); + if (!q) return []; + const s = getDriver().session(); + try { + const result = await s.run( + ` + MATCH (actor:User {id: $userId}) + WITH coalesce(actor.canBrowseUserDirectory, false) AS directoryAccess + MATCH (u:User) + WHERE u.id <> $userId AND coalesce(u.isBot, false) = false + OPTIONAL MATCH (me:User {id: $userId})-[:PARTICIPATES_IN]->(shared:Conversation)<-[:PARTICIPATES_IN]-(u) + WITH u, directoryAccess, count(shared) > 0 AS known + WHERE toLower(coalesce(u.email, '')) = $q + OR ((known OR directoryAccess) AND ( + toLower(coalesce(u.name, '')) CONTAINS $q + OR toLower(coalesce(u.email, '')) CONTAINS $q + )) + RETURN u { .id, .name, .email } AS user, known + ORDER BY known DESC, u.name + LIMIT 10 + `, + { userId, q } + ); + return result.records.map((r) => { + const u = toJS(r.get('user')) as { id: string; name: string | null; email: string | null }; + return { ...u, known: r.get('known') as boolean }; + }); + } finally { + await s.close(); + } +} + +async function toolFindPerson(userId: string, query: string): Promise { + const people = await resolvePeople(userId, query); + if (people.length === 0) { + return { + people: [], + note: `No one matching "${query}" is reachable. You can only find people you already share a conversation with, or anyone by their complete email address.`, + }; + } + return { people }; +} + +export async function toolSendMessageToPerson( + io: IOServer | undefined, + userId: string, + person: string, + content: string, + confirm: boolean +): Promise { + if (!person || !person.trim()) return { error: 'person is required' }; + if (!content || !content.trim()) return { error: 'content is required' }; + + const people = await resolvePeople(userId, person); + if (people.length === 0) { + return { + error: `No one matching "${person}" is reachable. You can only message people you already share a conversation with, or anyone by their complete email address. Ask the user for their email.`, + }; + } + if (people.length > 1) { + // Don't guess between people — a misdirected message is unrecoverable. + return { + ambiguous: true, + candidates: people.map((p) => ({ name: p.name, email: p.email })), + note: 'Ask the user which person they mean, then call again with a more specific person value (their full name or email).', + }; + } + + const target = people[0]!; + const displayName = target.name?.trim() || target.email || 'them'; + + // Confirmation gate — same policy as send_message: never send to another + // human without an explicit OK. Re-uses the pending-send memory so the + // "yes" on the NEXT turn can complete the handshake. + if (!confirm) { + // Resolve (or create) the DM up front so the remembered pending send points + // at a real conversation the user participates in. + const { conversation } = await ensureDirectConversation(userId, target.id, io); + rememberPendingSend(userId, { + conversationId: conversation.id as string, + content: content.trim(), + conversationName: displayName, + recipients: [displayName], + }); + return { + needsConfirmation: true, + recipient: displayName, + preview: content.trim().slice(0, 300), + }; + } + + if (sendRateLimited(userId)) { + return { error: 'Send rate limit reached — please try again shortly.' }; + } + + const { conversation } = await ensureDirectConversation(userId, target.id, io); + const conversationId = conversation.id as string; + const persisted = await persistMessage(io, userId, conversationId, content, { viaAssistant: true }); + if (!persisted) return { error: 'Failed to send message' }; + clearPendingSend(userId); + + console.log( + '[assistant] send_message_to_person', + JSON.stringify({ userId, recipientId: target.id, conversationId, confirmed: true }) + ); + + return { ok: true, sentTo: displayName, messageId: persisted.message.id, conversationId }; +} + export async function createConversationForAssistant( io: IOServer | undefined, userId: string, @@ -536,7 +719,9 @@ export async function createConversationForAssistant( // ─── Feedback → WorldIssueTracker (openchat-1ny) ────────────────────────────── // Lets the user file feedback by just telling the Assistant. Mirrors the // /api/feedback route (same WIT_AGENT_KEY server env). -const WIT_BASE = process.env.WIT_API_BASE || 'https://sthqnyjniclvnflfkyio.supabase.co/functions/v1'; +// Keep in sync with routes/feedback.ts. The old `sthqnyjniclvnflfkyio` project +// is PAUSED; pointing at it made every submit_feedback call fail at connect. +const WIT_BASE = process.env.WIT_API_BASE || 'https://qmzopiburflputowkuhu.supabase.co/functions/v1'; const WIT_SITE = process.env.WIT_SITE_URL || 'https://worldissuetracker.com'; const WIT_TRACKER_SLUG = process.env.WIT_FEEDBACK_TRACKER_SLUG || 'openchat'; // file on the OpenChat board, not orphan const FEEDBACK_MAX_MESSAGE = 5000; // match POST /api/feedback @@ -660,6 +845,36 @@ function buildTools(): AnthropicType.Tool[] { required: ['conversationId', 'content'], }, }, + { + name: 'find_person', + description: + "Look up a person the user can message, by name or email. Returns matching people with their name and email. Use this when the user names someone (\"message Robert\") and you need to know who they mean. Only returns people the user already shares a conversation with, plus anyone matched by a complete email address.", + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: "The person's name or email as the user said it" }, + }, + required: ['query'], + }, + }, + { + name: 'send_message_to_person', + description: + "Send a message AS THE USER to a person, by name or email — this is the PREFERRED way to message somebody. It finds the person and reuses (or creates) the direct conversation for you, so you do NOT need a conversationId. The first call returns { needsConfirmation: true, recipient, preview } and does NOT send: tell the user exactly what you'll send and to whom, wait for their explicit yes, then call again with the SAME person and content plus confirm:true. If it returns { ambiguous: true, candidates }, ask the user which person they mean instead of guessing.", + input_schema: { + type: 'object', + properties: { + person: { type: 'string', description: "The recipient's name or email" }, + content: { type: 'string', description: 'The message text to send' }, + confirm: { + type: 'boolean', + description: + 'Set true ONLY after the user has explicitly approved sending this exact message to this person. Leave false/omitted on the first attempt.', + }, + }, + required: ['person', 'content'], + }, + }, { name: 'create_conversation', description: 'Create a new conversation with the given participant user ids (the user is added automatically).', @@ -762,6 +977,17 @@ async function executeTool( if (!conversationId) return { error: 'conversationId is required' }; return await toolSendMessage(io, userId, conversationId, content, confirm); } + case 'find_person': { + const query = typeof input.query === 'string' ? input.query : ''; + if (!query.trim()) return { error: 'query is required' }; + return await toolFindPerson(userId, query); + } + case 'send_message_to_person': { + const person = typeof input.person === 'string' ? input.person : ''; + const content = typeof input.content === 'string' ? input.content : ''; + const confirm = input.confirm === true; + return await toolSendMessageToPerson(io, userId, person, content, confirm); + } case 'create_conversation': { const participantIds = Array.isArray(input.participantIds) ? (input.participantIds.filter((x) => typeof x === 'string') as string[]) @@ -860,16 +1086,18 @@ async function loadConversationContext( } const SYSTEM_PROMPT = `You are Assistant, an in-app helper inside OpenChat (a chat application). -You are talking with a user inside a direct-message conversation. You can search the user's messages, list and read their conversations, send messages on their behalf, create conversations, manage quiet-match asks/offers, and file feedback about OpenChat — all via tools. All tools act on behalf of THIS user only. +You are talking with a user inside a direct-message conversation. You can search the user's messages, list and read their conversations, look up people, send messages on their behalf, create conversations, manage quiet-match asks/offers, and file feedback about OpenChat — all via tools. All tools act on behalf of THIS user only. Guidelines: - Be concise and conversational; this is a chat, not an essay. - Use tools to ground your answers in the user's actual messages/conversations rather than guessing. -- Only use send_message / create_conversation when the user clearly asks you to act. +- Only use send_message / send_message_to_person / create_conversation when the user clearly asks you to act. +- To message a PERSON ("text Robert", "tell Sam I'm running late"), use send_message_to_person with their name — it resolves the person and their DM for you. Do NOT hunt for a conversationId in list_conversations and do NOT invent one; conversation ids are opaque and you will get them wrong. Reserve send_message for when you are already working with a specific conversation you just read. +- Never guess who someone is. If find_person or send_message_to_person reports the name is ambiguous or unreachable, ask the user — for someone new, ask for their complete email address. - Quiet matching uses anonymous asks and offers. Publishing an intent is explicit discovery opt-in. Before calling publish_intent, echo the exact anonymous terms back to the user and wait for explicit confirmation. Explain that only kind and terms are shown before mutual approval; private details are never shown to the other person. Never publish silently. - Matches are double opt-in. A user's plain-language “yes, connect us” can authorize respond_match approval. Before declining, confirm that choice too. Never reveal or speculate about the other side's response. A closed match does not reveal who declined. - Mutual approval creates or reuses a normal DM between the two humans with a neutral context card. It never sends an opener on either person's behalf; tell the user they choose whether and what to write. -- send_message to OTHER people requires confirmation: the first send_message call to a conversation that includes anyone besides the user returns { needsConfirmation: true, conversationName, recipients, preview } instead of sending. When you get that, DO NOT retry blindly — tell the user exactly what you'll send and to whom, wait for their explicit yes, then call send_message again with the SAME content and confirm:true. If they decline or change the wording, do not send. Messages to the user's own Assistant DM go through immediately with no confirmation. +- Sending to OTHER people requires confirmation: the first send_message / send_message_to_person call returns { needsConfirmation: true, ... } instead of sending. When you get that, DO NOT retry blindly — tell the user exactly what you'll send and to whom, wait for their explicit yes, then call the SAME tool again with the SAME content and confirm:true. If they decline or change the wording, do not send. Messages to the user's own Assistant DM go through immediately with no confirmation. - If the user wants to report a bug, give feedback, or request a feature about OpenChat (the app), use submit_feedback — it files a tracked issue for the OpenChat team. Confirm what you'll send, then share the resulting link. This is how feedback reaches us, so offer it when the user seems stuck or frustrated with the app. - Your final response (plain text, no tool call) is delivered to the user as a chat message.`; @@ -935,11 +1163,24 @@ export async function runAssistantTurn(opts: { const model = process.env.ASSISTANT_MODEL || 'claude-haiku-4-5'; let finalText = ''; + // Carry an unconfirmed send across the turn boundary. Tool results are not + // persisted, so without this the model cannot complete the confirm → + // "yes" → send handshake (see the PendingSend note above). + const pending = getPendingSend(userId); + const system = pending + ? `${SYSTEM_PROMPT} + +PENDING CONFIRMATION: on a previous turn you asked this user to confirm sending the following message, and it has NOT been sent yet: + to: ${pending.recipients.join(', ')} (conversationId: ${pending.conversationId}) + content: ${JSON.stringify(pending.content)} +If the user's latest message approves it, call send_message with exactly that conversationId and content and confirm:true. If they changed the wording, send the new wording. If they declined or moved on, ignore this and do not send.` + : SYSTEM_PROMPT; + for (let i = 0; i < MAX_ITERATIONS; i++) { const response = await client.messages.create({ model, max_tokens: 1024, - system: SYSTEM_PROMPT, + system, tools, messages, }); diff --git a/apps/server/test/assistantSendToPerson.integration.test.ts b/apps/server/test/assistantSendToPerson.integration.test.ts new file mode 100644 index 0000000..e0cad5c --- /dev/null +++ b/apps/server/test/assistantSendToPerson.integration.test.ts @@ -0,0 +1,167 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import neo4j, { type Driver } from 'neo4j-driver'; + +// Regression cover for the 2026-09-08 failure: Jacob asked the Assistant to +// text Robert, the Assistant found him, asked to confirm, Jacob said "yes", and +// the send died with "You do not have access to that conversation." Two causes: +// the model had to carry an opaque conversationId across a turn boundary (tool +// results are never persisted), and there was no person-shaped send tool at all. +const uri = process.env.NEO4J_TEST_URI; +const user = process.env.NEO4J_TEST_USER; +const password = process.env.NEO4J_TEST_PASSWORD; +const integration = uri && user && password ? describe.sequential : describe.skip; + +integration('assistant send_message_to_person', () => { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const senderId = `sender-${suffix}`; + const knownId = `known-${suffix}`; + const strangerId = `stranger-${suffix}`; + const twinAId = `twin-a-${suffix}`; + const twinBId = `twin-b-${suffix}`; + const userIds = [senderId, knownId, strangerId, twinAId, twinBId]; + + let driver: Driver; + let database: typeof import('../src/db.js'); + let assistant: typeof import('../src/services/assistant.js'); + let directService: typeof import('../src/services/directConversation.js'); + + beforeAll(async () => { + process.env.NEO4J_URI = uri!; + process.env.NEO4J_USER = user!; + process.env.NEO4J_PASSWORD = password!; + database = await import('../src/db.js'); + assistant = await import('../src/services/assistant.js'); + directService = await import('../src/services/directConversation.js'); + await database.initDatabase(); + + driver = neo4j.driver(uri!, neo4j.auth.basic(user!, password!)); + const session = driver.session(); + try { + await session.run( + `UNWIND $people AS person + CREATE (:User {id: person.id, name: person.name, email: person.email})`, + { + people: [ + { id: senderId, name: 'Sender Person', email: `${senderId}@example.test` }, + { id: knownId, name: 'Robert Nowell', email: `${knownId}@example.test` }, + { id: strangerId, name: 'Robert Stranger', email: `${strangerId}@example.test` }, + { id: twinAId, name: 'Sam Twin', email: `${twinAId}@example.test` }, + { id: twinBId, name: 'Sam Twin', email: `${twinBId}@example.test` }, + ], + }, + ); + } finally { + await session.close(); + } + + // The sender already shares a DM with Robert Nowell and both Sam Twins. + // Robert Stranger is a non-contact, so name lookup must not reach him. + await directService.ensureDirectConversation(senderId, knownId); + await directService.ensureDirectConversation(senderId, twinAId); + await directService.ensureDirectConversation(senderId, twinBId); + }); + + afterAll(async () => { + if (!driver) return; + const session = driver.session(); + try { + await session.run( + ` + MATCH (u:User)-[:PARTICIPATES_IN]->(conversation:Conversation) + WHERE u.id IN $userIds + WITH collect(DISTINCT conversation) AS conversations + UNWIND conversations AS conversation + OPTIONAL MATCH (message:Message {conversationId: conversation.id}) + DETACH DELETE message, conversation + `, + { userIds }, + ); + await session.run(`MATCH (u:User) WHERE u.id IN $userIds DETACH DELETE u`, { userIds }); + } finally { + await session.close(); + await driver.close(); + await database.closeDatabase(); + } + }); + + it('resolves a person the user already shares a conversation with, by name', async () => { + const people = await assistant.resolvePeople(senderId, 'robert'); + expect(people.map(p => p.id)).toEqual([knownId]); + expect(people[0]?.known).toBe(true); + }); + + it('does not expose a non-contact by name, but does by complete email', async () => { + const byName = await assistant.resolvePeople(senderId, 'Robert Stranger'); + expect(byName).toEqual([]); + + const byEmail = await assistant.resolvePeople(senderId, `${strangerId}@example.test`); + expect(byEmail.map(p => p.id)).toEqual([strangerId]); + }); + + it('never resolves the user themselves', async () => { + const people = await assistant.resolvePeople(senderId, 'Sender'); + expect(people).toEqual([]); + }); + + it('confirms before sending, remembers the pending send, then delivers on confirm', async () => { + const content = 'test'; + + const first = await assistant.toolSendMessageToPerson( + undefined, senderId, 'Robert', content, false, + ) as { needsConfirmation?: boolean; recipient?: string }; + expect(first.needsConfirmation).toBe(true); + expect(first.recipient).toBe('Robert Nowell'); + + // The whole point: after the confirm-gated turn ends, the conversationId is + // still recoverable server-side, so the next turn's "yes" can complete. + const pending = assistant.getPendingSend(senderId); + expect(pending?.content).toBe(content); + expect(pending?.conversationId).toBeTruthy(); + + const sent = await assistant.toolSendMessageToPerson( + undefined, senderId, 'Robert', content, true, + ) as { ok?: boolean; sentTo?: string; conversationId?: string }; + expect(sent.ok).toBe(true); + expect(sent.sentTo).toBe('Robert Nowell'); + expect(sent.conversationId).toBe(pending?.conversationId); + + // Sending clears the pending slot so a later unrelated "yes" can't resend. + expect(assistant.getPendingSend(senderId)).toBeNull(); + + // It landed in the DM the two of them already shared — no duplicate thread. + const { conversation, created } = await directService.ensureDirectConversation(senderId, knownId); + expect(created).toBe(false); + expect(conversation.id).toBe(sent.conversationId); + + const session = driver.session(); + try { + const result = await session.run( + `MATCH (m:Message {conversationId: $conversationId}) + RETURN m.content AS content, m.senderId AS senderId, m.viaAssistant AS viaAssistant`, + { conversationId: sent.conversationId }, + ); + expect(result.records).toHaveLength(1); + expect(result.records[0]!.get('content')).toBe(content); + expect(result.records[0]!.get('senderId')).toBe(senderId); + expect(result.records[0]!.get('viaAssistant')).toBe(true); + } finally { + await session.close(); + } + }); + + it('refuses to guess between two people with the same name', async () => { + const result = await assistant.toolSendMessageToPerson( + undefined, senderId, 'Sam Twin', 'hello', false, + ) as { ambiguous?: boolean; candidates?: unknown[] }; + expect(result.ambiguous).toBe(true); + expect(result.candidates).toHaveLength(2); + }); + + it('reports an unreachable person instead of sending somewhere wrong', async () => { + const result = await assistant.toolSendMessageToPerson( + undefined, senderId, 'Nobody At All', 'hello', true, + ) as { error?: string; ok?: boolean }; + expect(result.ok).toBeUndefined(); + expect(result.error).toContain('Nobody At All'); + }); +}); diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index c6d9e37..03572da 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -50,7 +50,7 @@ services: # WIT issues via this agent key. Unset = /api/feedback returns 503. # Set: echo 'WIT_AGENT_KEY=wit_...' >> /opt/openchat/.env - WIT_AGENT_KEY=${WIT_AGENT_KEY:-} - - WIT_API_BASE=${WIT_API_BASE:-https://sthqnyjniclvnflfkyio.supabase.co/functions/v1} + - WIT_API_BASE=${WIT_API_BASE:-https://qmzopiburflputowkuhu.supabase.co/functions/v1} - WIT_SITE_URL=${WIT_SITE_URL:-https://worldissuetracker.com} # AI: message transforms (/api/ai), the in-app Assistant bot (bfn.3), # and semantic search embeddings (bfn.2). docker-compose only forwards From 2b5cc484f6290c9baea64d8c05c6416c51582440 Mon Sep 17 00:00:00 2001 From: Jacob Cole Date: Tue, 8 Sep 2026 16:46:38 -0600 Subject: [PATCH 2/2] fix(chat): send push notifications on the REST message path too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jacob's fourth report from the 2026-09-08 session: "the first message from Robert to Jacob didn't work and it only worked on the second message." The message itself was never lost — Robert's "hi" is in the graph at 22:01:12, four seconds after the conversation was created. What was missing was the notification. fanoutPushForMessage was private to chatHandler.ts and only called from the socket send path; POST /conversations/:id/messages did everything else (broadcast, webhooks, previews, embeddings, assistant trigger) but never notified anyone. A message sent over REST — which is what happens when the sender's socket isn't up yet, exactly the case for the first message in a brand-new conversation — arrived silently. Guarded on wasCreated so a client retrying the same message id doesn't notify twice, and fire-and-forget so push latency never blocks the send. Co-Authored-By: Claude Opus 5 --- apps/server/src/routes/chat.ts | 14 +++++++++++++- apps/server/src/websocket/chatHandler.ts | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/src/routes/chat.ts b/apps/server/src/routes/chat.ts index da901c3..55beebf 100644 --- a/apps/server/src/routes/chat.ts +++ b/apps/server/src/routes/chat.ts @@ -7,7 +7,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { getDriver } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; import { resolveActor } from '../middleware/resolveActor.js'; -import { joinUserSocketsToConversation, leaveUserSocketsFromConversation, isUserOnline, broadcastMessageToParticipants } from '../websocket/chatHandler.js'; +import { joinUserSocketsToConversation, leaveUserSocketsFromConversation, isUserOnline, broadcastMessageToParticipants, fanoutPushForMessage } from '../websocket/chatHandler.js'; import { processLinkPreviews, loadPreviewsForMessages } from '../services/linkPreview.js'; import { createThoughtsFromMessageTags } from '../services/extractThoughtsFromMessage.js'; import { maybeTriggerAssistant } from '../services/assistantTrigger.js'; @@ -1036,6 +1036,18 @@ router.post('/conversations/:id/messages', resolveActor, async (req: Request, re // persisted but never delivered live. Clients dedupe by message.id, so the // sender receiving its own broadcast is harmless. See OpenChat-5q1 / -60y. if (io) broadcastMessageToParticipants(io, participantIds, message); + // Push notifications. The socket send path has always fanned these out; this + // REST path did not, so a message sent over REST — which is exactly what + // happens when the sender's socket isn't up yet, e.g. the first message in a + // freshly created conversation — reached the recipient silently, with no + // notification. That is the "Robert's first message didn't come through, the + // second one did" report. Guarded on wasCreated so a client retry of the + // same message id doesn't notify twice. + if (wasCreated) { + void fanoutPushForMessage(conversationId as string, userId, message).catch((err) => + console.warn('[push] REST fanout error:', err) + ); + } // Outbound webhooks (openchat bot-channel): push the message to any external // subscriber (e.g. groupbrain). Fire-and-forget, no-ops when no subscription. if (wasCreated) dispatchMessageEvent(message, participantIds); diff --git a/apps/server/src/websocket/chatHandler.ts b/apps/server/src/websocket/chatHandler.ts index 1baf1b1..b8036d7 100644 --- a/apps/server/src/websocket/chatHandler.ts +++ b/apps/server/src/websocket/chatHandler.ts @@ -496,7 +496,7 @@ async function broadcastPresenceToContacts(io: Server, userId: string, status: s * Fire-and-forget — logs but never throws to the caller. * OpenChat-0jy: added mentionedUserIds param. */ -async function fanoutPushForMessage( +export async function fanoutPushForMessage( conversationId: string, senderId: string, message: unknown,