Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion apps/server/src/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/routes/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
251 changes: 246 additions & 5 deletions apps/server/src/services/assistant.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion apps/server/src/websocket/chatHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
167 changes: 167 additions & 0 deletions apps/server/test/assistantSendToPerson.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion infra/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading