diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx index cd9a8f0c2e..d20734ffa2 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.test.tsx @@ -25,6 +25,7 @@ vi.mock('../../lib/orchestration/orchestrationClient', async importOriginal => { ...actual, orchestrationClient: { sessionsList: vi.fn(), + sessionsCreate: vi.fn(), messagesList: vi.fn(), sendMasterMessage: vi.fn(), markRead: vi.fn(), @@ -40,6 +41,7 @@ vi.mock('../../services/socketService', () => { vi.mock('../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) })); const sessionsListMock = vi.mocked(orchestrationClient.sessionsList); +const sessionsCreateMock = vi.mocked(orchestrationClient.sessionsCreate); const messagesListMock = vi.mocked(orchestrationClient.messagesList); const sendMasterMock = vi.mocked(orchestrationClient.sendMasterMessage); const markReadMock = vi.mocked(orchestrationClient.markRead); @@ -80,6 +82,18 @@ describe('TinyPlaceOrchestrationTab', () => { beforeEach(() => { vi.clearAllMocks(); sessionsListMock.mockResolvedValue({ sessions: [...PINNED_SESSIONS] }); + sessionsCreateMock.mockResolvedValue({ + session: { + sessionId: 'new-sess', + agentId: '@peer', + source: 'user_created', + chatKind: 'session', + lastMessageAt: '2026-07-04T00:00:00.000Z', + unread: 0, + active: true, + pinned: false, + }, + }); messagesListMock.mockResolvedValue({ messages: [] }); sendMasterMock.mockResolvedValue({ ok: true, messageId: 'm-1' }); markReadMock.mockResolvedValue({ ok: true }); @@ -321,6 +335,72 @@ describe('TinyPlaceOrchestrationTab', () => { await waitFor(() => expect(pairingAcceptMock).toHaveBeenCalledWith('@worker-pending')); }); + it('falls back to the contact requester address when agentId is absent', async () => { + // The relay's /contacts/requests payload does not always populate the + // top-level agentId; the counterpart address lives in contact.requester. + const rawAddress = '3icjiLXhn6BMv43MsHjpKKxm7hEYBk7R5rvNXB1HUk7g'; + pairingListMock.mockResolvedValue({ + records: [], + contacts: { contacts: [] }, + requests: { + incoming: [ + { + agentId: '', + status: 'pending', + direction: 'incoming', + contact: { + requester: rawAddress, + addressee: '@openhuman', + status: 'pending', + createdAt: '2026-07-01T12:00:00.000Z', + updatedAt: '2026-07-01T12:00:00.000Z', + }, + }, + ], + outgoing: [], + }, + stats: { agentId: '@openhuman', contactCount: 0, pendingIncoming: 1, pendingOutgoing: 0 }, + }); + + render(); + + expect(await screen.findByText(rawAddress)).toBeInTheDocument(); + fireEvent.click(screen.getByText('tinyplaceOrchestration.pairing.accept')); + + await waitFor(() => expect(pairingAcceptMock).toHaveBeenCalledWith(rawAddress)); + }); + + it('lists accepted contacts (address resolved from the contact record)', async () => { + const rawAddress = '3icjiLXhn6BMv43MsHjpKKxm7hEYBk7R5rvNXB1HUk7g'; + pairingListMock.mockResolvedValue({ + records: [], + contacts: { + contacts: [ + { + agentId: '', + status: 'accepted', + direction: 'incoming', + contact: { + requester: rawAddress, + addressee: '@openhuman', + status: 'accepted', + createdAt: '2026-07-01T12:00:00.000Z', + updatedAt: '2026-07-01T12:00:00.000Z', + }, + }, + ], + }, + requests: { incoming: [], outgoing: [] }, + stats: { agentId: '@openhuman', contactCount: 1, pendingIncoming: 0, pendingOutgoing: 0 }, + }); + + render(); + + // The accepted contact appears in the Contacts list, not just the count. + expect(await screen.findByText(rawAddress)).toBeInTheDocument(); + expect(screen.getByText('tinyplaceOrchestration.contacts')).toBeInTheDocument(); + }); + it('surfaces load errors and retries', async () => { sessionsListMock.mockRejectedValueOnce(new Error('rpc failed')); @@ -334,4 +414,84 @@ describe('TinyPlaceOrchestrationTab', () => { await waitFor(() => expect(sessionsListMock).toHaveBeenCalledTimes(2)); expect(await screen.findByText('tinyplaceOrchestration.noMessages')).toBeInTheDocument(); }); + + const ACCEPTED_CONTACT_ADDRESS = '3icjiLXhn6BMv43MsHjpKKxm7hEYBk7R5rvNXB1HUk7g'; + const acceptedContactSnapshot = () => ({ + records: [], + contacts: { + contacts: [ + { + agentId: ACCEPTED_CONTACT_ADDRESS, + status: 'accepted' as const, + direction: 'incoming' as const, + contact: { + requester: ACCEPTED_CONTACT_ADDRESS, + addressee: '@openhuman', + status: 'accepted' as const, + createdAt: '2026-07-01T12:00:00.000Z', + updatedAt: '2026-07-01T12:00:00.000Z', + }, + }, + ], + }, + requests: { incoming: [], outgoing: [] }, + stats: { agentId: '@openhuman', contactCount: 1, pendingIncoming: 0, pendingOutgoing: 0 }, + }); + + it('creates a new session under an expanded contact', async () => { + pairingListMock.mockResolvedValue(acceptedContactSnapshot()); + + render(); + + // Expand the contact row (exposes state to assistive tech), then create. + const contactToggle = await screen.findByTestId( + `tinyplace-contact-${ACCEPTED_CONTACT_ADDRESS}` + ); + expect(contactToggle).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(contactToggle); + expect(contactToggle).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(await screen.findByTestId(`tinyplace-new-session-${ACCEPTED_CONTACT_ADDRESS}`)); + + await waitFor(() => + expect(sessionsCreateMock).toHaveBeenCalledWith({ agentId: ACCEPTED_CONTACT_ADDRESS }) + ); + }); + + it('threads a composed message under the selected session', async () => { + pairingListMock.mockResolvedValue(acceptedContactSnapshot()); + sessionsListMock.mockResolvedValue({ + sessions: [ + ...PINNED_SESSIONS, + { + sessionId: 'sess-x', + agentId: ACCEPTED_CONTACT_ADDRESS, + source: 'user_created', + label: 'Design review', + chatKind: 'session', + lastMessageAt: '2026-07-01T12:02:00.000Z', + unread: 0, + active: true, + pinned: false, + }, + ], + }); + + render(); + + // Expand the contact, open its nested session, then send. + fireEvent.click(await screen.findByTestId(`tinyplace-contact-${ACCEPTED_CONTACT_ADDRESS}`)); + fireEvent.click(await screen.findByTestId('tinyplace-chat-sess-x')); + + const input = await screen.findByTestId('tinyplace-master-composer-input'); + fireEvent.change(input, { target: { value: 'ping under session' } }); + fireEvent.click(screen.getByTestId('tinyplace-master-composer-send')); + + await waitFor(() => + expect(sendMasterMock).toHaveBeenCalledWith({ + body: 'ping under session', + recipient: ACCEPTED_CONTACT_ADDRESS, + sessionId: 'sess-x', + }) + ); + }); }); diff --git a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx index af6d94c135..af9380bff4 100644 --- a/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx +++ b/app/src/components/intelligence/TinyPlaceOrchestrationTab.tsx @@ -40,7 +40,7 @@ function acceptedContactIds(contacts: ContactView[]): Set { return new Set( contacts .filter(contact => contact.status === 'accepted') - .map(contact => contact.agentId) + .map(contactAddress) .filter(Boolean) ); } @@ -49,7 +49,7 @@ function pendingContactIds(requests: ContactRequestsResponse): Set { return new Set( [...requests.incoming, ...requests.outgoing] .filter(contact => contact.status === 'pending') - .map(contact => contact.agentId) + .map(contactAddress) .filter(Boolean) ); } @@ -156,6 +156,33 @@ type PairingState = | { status: 'payment_required' } | { status: 'ok'; snapshot: PairingSnapshot }; +/** Best-effort `@handle` for a tiny.place agent id (cryptoId) from a directory + * reverse lookup — the registered username if any, else null. The raw address + * is always shown; the handle is additive. */ +function extractHandle(res: { + agents?: Array<{ username?: string }>; + identities?: unknown[]; +}): string | null { + const fromAgent = res.agents?.find(a => a.username)?.username; + const fromIdentity = (res.identities as Array<{ username?: string }> | undefined)?.find( + identity => identity?.username + )?.username; + const username = fromAgent ?? fromIdentity; + return username ? username.replace(/^@+/, '') : null; +} + +// The counterpart agent address for a contact view (request or accepted +// contact). The relay's `/contacts` and `/contacts/requests` payloads do not +// always populate the top-level `agentId`, so fall back to the underlying +// contact record: when we are the addressee the counterpart is the +// `requester`, otherwise it is the `addressee`. +function contactAddress(view: ContactView): string { + if (view.agentId) return view.agentId; + const contact = view.contact; + if (!contact) return ''; + return view.direction === 'outgoing' ? (contact.addressee ?? '') : (contact.requester ?? ''); +} + export default function TinyPlaceOrchestrationTab() { const { t } = useT(); const { @@ -168,7 +195,8 @@ export default function TinyPlaceOrchestrationTab() { masterError, selectChat, refresh, - sendMaster, + sendMessage, + createSession, } = useOrchestrationChats(t); const [pairingState, setPairingState] = useState({ status: 'loading' }); @@ -177,8 +205,29 @@ export default function TinyPlaceOrchestrationTab() { const [pairingError, setPairingError] = useState(null); const [composerBody, setComposerBody] = useState(''); const [sending, setSending] = useState(false); + // Resolved `@handle`s for agent ids seen in the pairing UI (address always shown). + const [agentHandles, setAgentHandles] = useState>({}); + // Which contact rows are expanded to reveal their nested sessions. + const [expandedContacts, setExpandedContacts] = useState>({}); + const [creatingSession, setCreatingSession] = useState(null); const mountedRef = useRef(true); + const toggleContact = useCallback((address: string) => { + setExpandedContacts(prev => ({ ...prev, [address]: !prev[address] })); + }, []); + + const handleCreateSession = useCallback( + (address: string) => { + if (!address || creatingSession) return; + setCreatingSession(address); + setExpandedContacts(prev => ({ ...prev, [address]: true })); + void createSession(address).finally(() => { + if (mountedRef.current) setCreatingSession(null); + }); + }, + [createSession, creatingSession] + ); + const loadPairing = useCallback(async () => { debug('[tinyplace-orchestration] pairing load entry'); setPairingState({ status: 'loading' }); @@ -253,13 +302,13 @@ export default function TinyPlaceOrchestrationTab() { const body = composerBody.trim(); if (!body || sending) return; setSending(true); - void sendMaster(body).then(ok => { + void sendMessage(selected, body).then(ok => { if (!mountedRef.current) return; if (ok) setComposerBody(''); setSending(false); }); }, - [composerBody, sending, sendMaster] + [composerBody, sending, sendMessage, selected] ); useEffect(() => { @@ -286,10 +335,66 @@ export default function TinyPlaceOrchestrationTab() { [pairingSnapshot?.requests] ); const incomingRequests = pairingSnapshot?.requests.incoming ?? []; + const acceptedContactList = useMemo( + () => + (pairingSnapshot?.contacts.contacts ?? []).filter(contact => contact.status === 'accepted'), + [pairingSnapshot?.contacts.contacts] + ); const contactStats = pairingSnapshot?.stats ?? null; + // Group session chats under their peer contact for the nested sidebar tree. + const sessionsByContact = new Map(); + for (const chat of sessions) { + if (!chat.peerAgentId) continue; + const list = sessionsByContact.get(chat.peerAgentId) ?? []; + list.push(chat); + sessionsByContact.set(chat.peerAgentId, list); + } + const contactAddressSet = new Set(acceptedContactList.map(contactAddress).filter(Boolean)); + // Sessions whose peer is not a known accepted contact still need a home. + const ungroupedSessions = sessions.filter( + chat => !chat.peerAgentId || !contactAddressSet.has(chat.peerAgentId) + ); + + // Resolve @handles for the agent ids seen in the pairing UI (incoming + // requests + accepted contacts) via the directory reverse lookup + // (best-effort; the raw address is always rendered). + const directoryIdsKey = [...incomingRequests, ...acceptedContactList] + .map(contactAddress) + .filter(Boolean) + .join(','); + useEffect(() => { + const ids = directoryIdsKey ? Array.from(new Set(directoryIdsKey.split(','))) : []; + if (ids.length === 0) return; + let cancelled = false; + void Promise.all( + ids.map(async id => { + try { + return [id, extractHandle(await apiClient.directory.reverse(id))] as const; + } catch { + return [id, null] as const; + } + }) + ).then(entries => { + if (cancelled) return; + setAgentHandles(prev => { + const next = { ...prev }; + for (const [id, handle] of entries) { + if (!(id in next)) next[id] = handle; + } + return next; + }); + }); + return () => { + cancelled = true; + }; + }, [directoryIdsKey]); + const steeringText = status?.steering?.text?.trim() || null; const isMasterSelected = selected?.id === MASTER_CHAT_KEY; + // The composer is available for the Master chat and for any per-contact + // session (session sends thread under that session id). + const canCompose = isMasterSelected || selected?.kind === 'session'; return (
@@ -374,48 +479,57 @@ export default function TinyPlaceOrchestrationTab() {

{t('tinyplaceOrchestration.pairing.requests')}

- {incomingRequests.map(request => ( -
-
{request.agentId}
-
- - - + {incomingRequests.map((request, index) => { + const address = contactAddress(request); + const handle = address ? agentHandles[address] : null; + return ( +
+ {handle ? ( +
@{handle}
+ ) : null} +
+ {address} +
+
+ + + +
-
- ))} + ); + })}
) : null} @@ -442,15 +556,90 @@ export default function TinyPlaceOrchestrationTab() {

- {t('tinyplaceOrchestration.sessions')} + {t('tinyplaceOrchestration.contacts')}

- {sessions.length === 0 ? ( + {acceptedContactList.length === 0 ? (
- {t('tinyplaceOrchestration.noSessions')} + {t('tinyplaceOrchestration.noContacts')}
) : ( +
+ {acceptedContactList.map((contact, index) => { + const address = contactAddress(contact); + const handle = address ? agentHandles[address] : null; + const isOpen = !!expandedContacts[address]; + const contactSessions = address ? (sessionsByContact.get(address) ?? []) : []; + return ( +
+ + {isOpen ? ( +
+ {contactSessions.map(chat => ( + { + debug('[tinyplace-orchestration] open session id=%s', chat.id); + selectChat(chat.id); + }} + /> + ))} + +
+ ) : null} +
+ ); + })} +
+ )} +
+ + {ungroupedSessions.length > 0 ? ( +
+

+ {t('tinyplaceOrchestration.otherSessions')} +

- {sessions.map(chat => ( + {ungroupedSessions.map(chat => ( ))}
- )} -
+ + ) : null}
@@ -532,7 +721,7 @@ export default function TinyPlaceOrchestrationTab() { )} - {isMasterSelected && sessionsState.status === 'ok' ? ( + {canCompose && sessionsState.status === 'ok' ? (
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 215e833b66..a75efcd4f9 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -245,6 +245,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'تحديث', 'tinyplaceOrchestration.pinned': 'مثبتة', 'tinyplaceOrchestration.sessions': 'الجلسات', + 'tinyplaceOrchestration.contacts': 'جهات الاتصال', + 'tinyplaceOrchestration.noContacts': 'لا توجد جهات اتصال بعد.', + 'tinyplaceOrchestration.newSession': 'جلسة جديدة', + 'tinyplaceOrchestration.otherSessions': 'جلسات أخرى', 'tinyplaceOrchestration.loading': 'جارٍ تحميل دردشات TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'يتطلب الوصول إلى TinyPlace دفعًا.', 'tinyplaceOrchestration.failedToLoad': 'تعذّر تحميل دردشات TinyPlace', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 1c819181be..cf2f10fba4 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -252,6 +252,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'রিফ্রেশ', 'tinyplaceOrchestration.pinned': 'পিন করা', 'tinyplaceOrchestration.sessions': 'সেশন', + 'tinyplaceOrchestration.contacts': 'পরিচিতি', + 'tinyplaceOrchestration.noContacts': 'এখনও কোনো পরিচিতি নেই।', + 'tinyplaceOrchestration.newSession': 'নতুন সেশন', + 'tinyplaceOrchestration.otherSessions': 'অন্যান্য সেশন', 'tinyplaceOrchestration.loading': 'TinyPlace চ্যাট লোড হচ্ছে…', 'tinyplaceOrchestration.paymentRequired': 'TinyPlace অ্যাক্সেসের জন্য পেমেন্ট দরকার।', 'tinyplaceOrchestration.failedToLoad': 'TinyPlace চ্যাট লোড করা যায়নি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5b477a3d5b..c42eaaaa8b 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -257,6 +257,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Aktualisieren', 'tinyplaceOrchestration.pinned': 'Angepinnt', 'tinyplaceOrchestration.sessions': 'Sitzungen', + 'tinyplaceOrchestration.contacts': 'Kontakte', + 'tinyplaceOrchestration.noContacts': 'Noch keine Kontakte.', + 'tinyplaceOrchestration.newSession': 'Neue Sitzung', + 'tinyplaceOrchestration.otherSessions': 'Andere Sitzungen', 'tinyplaceOrchestration.loading': 'TinyPlace-Chats werden geladen…', 'tinyplaceOrchestration.paymentRequired': 'TinyPlace-Zugriff erfordert eine Zahlung.', 'tinyplaceOrchestration.failedToLoad': 'TinyPlace-Chats konnten nicht geladen werden', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 0d2608ae12..326630c0ad 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4162,6 +4162,10 @@ const en: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Refresh', 'tinyplaceOrchestration.pinned': 'Pinned', 'tinyplaceOrchestration.sessions': 'Sessions', + 'tinyplaceOrchestration.contacts': 'Contacts', + 'tinyplaceOrchestration.noContacts': 'No contacts yet.', + 'tinyplaceOrchestration.newSession': 'New session', + 'tinyplaceOrchestration.otherSessions': 'Other sessions', 'tinyplaceOrchestration.loading': 'Loading TinyPlace chats…', 'tinyplaceOrchestration.paymentRequired': 'TinyPlace access requires payment.', 'tinyplaceOrchestration.failedToLoad': 'Failed to load TinyPlace chats', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 87236bf461..5d917e7568 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -254,6 +254,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Actualizar', 'tinyplaceOrchestration.pinned': 'Fijados', 'tinyplaceOrchestration.sessions': 'Sesiones', + 'tinyplaceOrchestration.contacts': 'Contactos', + 'tinyplaceOrchestration.noContacts': 'Aún no hay contactos.', + 'tinyplaceOrchestration.newSession': 'Nueva sesión', + 'tinyplaceOrchestration.otherSessions': 'Otras sesiones', 'tinyplaceOrchestration.loading': 'Cargando chats de TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'El acceso a TinyPlace requiere pago.', 'tinyplaceOrchestration.failedToLoad': 'No se pudieron cargar los chats de TinyPlace', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 5234cf7226..9a645489e4 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -254,6 +254,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Actualiser', 'tinyplaceOrchestration.pinned': 'Épinglés', 'tinyplaceOrchestration.sessions': 'Sessions', + 'tinyplaceOrchestration.contacts': 'Contacts', + 'tinyplaceOrchestration.noContacts': 'Aucun contact pour le moment.', + 'tinyplaceOrchestration.newSession': 'Nouvelle session', + 'tinyplaceOrchestration.otherSessions': 'Autres sessions', 'tinyplaceOrchestration.loading': 'Chargement des chats TinyPlace…', 'tinyplaceOrchestration.paymentRequired': "L'accès à TinyPlace nécessite un paiement.", 'tinyplaceOrchestration.failedToLoad': 'Échec du chargement des chats TinyPlace', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7b96c3d95d..7a29722b84 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -251,6 +251,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'रीफ़्रेश', 'tinyplaceOrchestration.pinned': 'पिन किए गए', 'tinyplaceOrchestration.sessions': 'सत्र', + 'tinyplaceOrchestration.contacts': 'संपर्क', + 'tinyplaceOrchestration.noContacts': 'अभी तक कोई संपर्क नहीं।', + 'tinyplaceOrchestration.newSession': 'नया सत्र', + 'tinyplaceOrchestration.otherSessions': 'अन्य सत्र', 'tinyplaceOrchestration.loading': 'TinyPlace चैट लोड हो रहे हैं…', 'tinyplaceOrchestration.paymentRequired': 'TinyPlace एक्सेस के लिए भुगतान आवश्यक है।', 'tinyplaceOrchestration.failedToLoad': 'TinyPlace चैट लोड नहीं हो सके', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index fe02181f47..c506aeab87 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -252,6 +252,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Segarkan', 'tinyplaceOrchestration.pinned': 'Tersemat', 'tinyplaceOrchestration.sessions': 'Sesi', + 'tinyplaceOrchestration.contacts': 'Kontak', + 'tinyplaceOrchestration.noContacts': 'Belum ada kontak.', + 'tinyplaceOrchestration.newSession': 'Sesi baru', + 'tinyplaceOrchestration.otherSessions': 'Sesi lainnya', 'tinyplaceOrchestration.loading': 'Memuat chat TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'Akses TinyPlace memerlukan pembayaran.', 'tinyplaceOrchestration.failedToLoad': 'Gagal memuat chat TinyPlace', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 68397f391d..0f02fd2ab8 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -254,6 +254,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Aggiorna', 'tinyplaceOrchestration.pinned': 'Fissati', 'tinyplaceOrchestration.sessions': 'Sessioni', + 'tinyplaceOrchestration.contacts': 'Contatti', + 'tinyplaceOrchestration.noContacts': 'Ancora nessun contatto.', + 'tinyplaceOrchestration.newSession': 'Nuova sessione', + 'tinyplaceOrchestration.otherSessions': 'Altre sessioni', 'tinyplaceOrchestration.loading': 'Caricamento chat TinyPlace…', 'tinyplaceOrchestration.paymentRequired': "L'accesso a TinyPlace richiede un pagamento.", 'tinyplaceOrchestration.failedToLoad': 'Impossibile caricare le chat TinyPlace', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index bb76b39743..bc5157f2a4 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -248,6 +248,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': '새로 고침', 'tinyplaceOrchestration.pinned': '고정됨', 'tinyplaceOrchestration.sessions': '세션', + 'tinyplaceOrchestration.contacts': '연락처', + 'tinyplaceOrchestration.noContacts': '아직 연락처가 없습니다.', + 'tinyplaceOrchestration.newSession': '새 세션', + 'tinyplaceOrchestration.otherSessions': '기타 세션', 'tinyplaceOrchestration.loading': 'TinyPlace 채팅을 불러오는 중…', 'tinyplaceOrchestration.paymentRequired': 'TinyPlace 접근에는 결제가 필요합니다.', 'tinyplaceOrchestration.failedToLoad': 'TinyPlace 채팅을 불러오지 못했습니다', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9714802c73..0ad901757e 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -256,6 +256,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Odśwież', 'tinyplaceOrchestration.pinned': 'Przypięte', 'tinyplaceOrchestration.sessions': 'Sesje', + 'tinyplaceOrchestration.contacts': 'Kontakty', + 'tinyplaceOrchestration.noContacts': 'Brak kontaktów.', + 'tinyplaceOrchestration.newSession': 'Nowa sesja', + 'tinyplaceOrchestration.otherSessions': 'Inne sesje', 'tinyplaceOrchestration.loading': 'Ładowanie czatów TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'Dostęp do TinyPlace wymaga płatności.', 'tinyplaceOrchestration.failedToLoad': 'Nie udało się załadować czatów TinyPlace', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 7dc4097c4c..9896ddac74 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -253,6 +253,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Atualizar', 'tinyplaceOrchestration.pinned': 'Fixados', 'tinyplaceOrchestration.sessions': 'Sessões', + 'tinyplaceOrchestration.contacts': 'Contactos', + 'tinyplaceOrchestration.noContacts': 'Ainda sem contactos.', + 'tinyplaceOrchestration.newSession': 'Nova sessão', + 'tinyplaceOrchestration.otherSessions': 'Outras sessões', 'tinyplaceOrchestration.loading': 'Carregando chats do TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'O acesso ao TinyPlace requer pagamento.', 'tinyplaceOrchestration.failedToLoad': 'Falha ao carregar chats do TinyPlace', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 29192dceac..facf3881ce 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -256,6 +256,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': 'Обновить', 'tinyplaceOrchestration.pinned': 'Закрепленные', 'tinyplaceOrchestration.sessions': 'Сессии', + 'tinyplaceOrchestration.contacts': 'Контакты', + 'tinyplaceOrchestration.noContacts': 'Пока нет контактов.', + 'tinyplaceOrchestration.newSession': 'Новая сессия', + 'tinyplaceOrchestration.otherSessions': 'Другие сессии', 'tinyplaceOrchestration.loading': 'Загрузка чатов TinyPlace…', 'tinyplaceOrchestration.paymentRequired': 'Доступ к TinyPlace требует оплаты.', 'tinyplaceOrchestration.failedToLoad': 'Не удалось загрузить чаты TinyPlace', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index fa0fa31927..76f99baa4f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -234,6 +234,10 @@ const messages: TranslationMap = { 'tinyplaceOrchestration.refresh': '刷新', 'tinyplaceOrchestration.pinned': '固定', 'tinyplaceOrchestration.sessions': '会话', + 'tinyplaceOrchestration.contacts': '联系人', + 'tinyplaceOrchestration.noContacts': '暂无联系人。', + 'tinyplaceOrchestration.newSession': '新会话', + 'tinyplaceOrchestration.otherSessions': '其他会话', 'tinyplaceOrchestration.loading': '正在加载 TinyPlace 聊天…', 'tinyplaceOrchestration.paymentRequired': '访问 TinyPlace 需要付款。', 'tinyplaceOrchestration.failedToLoad': '无法加载 TinyPlace 聊天', diff --git a/app/src/lib/orchestration/orchestrationClient.ts b/app/src/lib/orchestration/orchestrationClient.ts index b95eefa691..38ed4f7a29 100644 --- a/app/src/lib/orchestration/orchestrationClient.ts +++ b/app/src/lib/orchestration/orchestrationClient.ts @@ -60,6 +60,10 @@ export interface SessionsListResponse { sessions: SessionSummary[]; } +export interface SessionCreateResponse { + session: SessionSummary; +} + export interface MessagesListResponse { messages: OrchestrationMessage[]; } @@ -117,6 +121,13 @@ export const orchestrationClient = { /** List all orchestration chats (pinned master + subconscious, plus sessions). */ sessionsList: () => call('openhuman.orchestration_sessions_list', {}), + /** Create a new empty session for a contact; returns the created summary. */ + sessionsCreate: (params: { agentId: string; label?: string }) => + call('openhuman.orchestration_sessions_create', { + agentId: params.agentId, + ...(params.label !== undefined ? { label: params.label } : {}), + }), + /** * List messages for a chat. `chat` is `"master"`, `"subconscious"`, or a * session's `sessionId`. @@ -128,11 +139,15 @@ export const orchestrationClient = { ...(params.before !== undefined ? { before: params.before } : {}), }), - /** Send a message from the human master into the pinned master chat. */ - sendMasterMessage: (params: { body: string; recipient?: string }) => + /** + * Send a message from the human master. With `sessionId` the message threads + * under that session (session envelope); otherwise it goes to the Master chat. + */ + sendMasterMessage: (params: { body: string; recipient?: string; sessionId?: string }) => call('openhuman.orchestration_send_master_message', { body: params.body, ...(params.recipient !== undefined ? { recipient: params.recipient } : {}), + ...(params.sessionId !== undefined ? { sessionId: params.sessionId } : {}), }), /** Mark a chat as read (clears the server-side unread count). */ diff --git a/app/src/lib/orchestration/useOrchestrationChats.ts b/app/src/lib/orchestration/useOrchestrationChats.ts index 6383c299f9..4719450ef3 100644 --- a/app/src/lib/orchestration/useOrchestrationChats.ts +++ b/app/src/lib/orchestration/useOrchestrationChats.ts @@ -158,7 +158,8 @@ export interface UseOrchestrationChatsResult { masterError: string | null; selectChat: (chatKey: string) => void; refresh: () => Promise; - sendMaster: (body: string) => Promise; + sendMessage: (chat: ChatWindow | undefined, body: string) => Promise; + createSession: (agentId: string, label?: string) => Promise; } export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult { @@ -256,10 +257,14 @@ export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult [loadMessages, markRead] ); - const sendMaster = useCallback( - async (rawBody: string): Promise => { + // Send into a chat. Master/subconscious go to the Master send path; a session + // chat threads under its own session id (routed to its peer contact). + const sendMessage = useCallback( + async (chat: ChatWindow | undefined, rawBody: string): Promise => { const body = rawBody.trim(); - if (!body) return false; + if (!body || !chat) return false; + const chatKey = chat.id; + const isSession = chat.kind === 'session' && !!chat.peerAgentId; setMasterError(null); const optimistic: ChatMessage = { id: `optimistic:${Date.now()}`, @@ -270,13 +275,15 @@ export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult }; setMessagesByChat(prev => ({ ...prev, - [MASTER_CHAT_KEY]: sortMessages([...(prev[MASTER_CHAT_KEY] ?? []), optimistic]), + [chatKey]: sortMessages([...(prev[chatKey] ?? []), optimistic]), })); try { - await orchestrationClient.sendMasterMessage({ body }); + await orchestrationClient.sendMasterMessage( + isSession ? { body, recipient: chat.peerAgentId as string, sessionId: chatKey } : { body } + ); if (!mountedRef.current) return true; // Reconcile against the authoritative server state. - void loadMessages(MASTER_CHAT_KEY); + void loadMessages(chatKey); void loadSessions(); return true; } catch (error) { @@ -284,7 +291,7 @@ export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult // Roll the optimistic message back out. setMessagesByChat(prev => ({ ...prev, - [MASTER_CHAT_KEY]: (prev[MASTER_CHAT_KEY] ?? []).filter(m => m.id !== optimistic.id), + [chatKey]: (prev[chatKey] ?? []).filter(m => m.id !== optimistic.id), })); const message = error instanceof Error ? error.message : String(error); setMasterError(message); @@ -294,6 +301,31 @@ export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult [loadMessages, loadSessions, t] ); + // Create a new empty session for a contact and select it. + const createSession = useCallback( + async (agentId: string, label?: string): Promise => { + setMasterError(null); + try { + const { session } = await orchestrationClient.sessionsCreate({ + agentId, + ...(label ? { label } : {}), + }); + if (!mountedRef.current) return null; + await loadSessions(); + const key = session.sessionId; + setSelectedId(key); + void loadMessages(key); + return key; + } catch (error) { + if (!mountedRef.current) return null; + const message = error instanceof Error ? error.message : String(error); + setMasterError(message); + return null; + } + }, + [loadSessions, loadMessages] + ); + // Initial load + mark the default (master) chat read. useEffect(() => { mountedRef.current = true; @@ -353,6 +385,7 @@ export function useOrchestrationChats(t: Translate): UseOrchestrationChatsResult masterError, selectChat, refresh, - sendMaster, + sendMessage, + createSession, }; } diff --git a/src/openhuman/orchestration/ops.rs b/src/openhuman/orchestration/ops.rs index ae3b3a6f1a..85e3fba188 100644 --- a/src/openhuman/orchestration/ops.rs +++ b/src/openhuman/orchestration/ops.rs @@ -29,7 +29,7 @@ use super::steering::{ build_steering_prompt, is_explicit_none, parse_steering_output, ParsedSteering, }; use super::store; -use super::types::{ChatKind, OrchestrationMessage, OrchestrationSession}; +use super::types::{ChatKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1}; /// Assumed model context window (tokens) for the `context_guard` utilization /// estimate until per-model resolution is wired. Sized to the reasoning tier. @@ -728,9 +728,13 @@ impl OrchestrationRuntime for ProductionRuntime { } async fn send_dm(&self, counterpart_agent_id: &str, body: &str) -> anyhow::Result<()> { + // A reply into a real harness session is stamped with a v1 session + // envelope so the peer threads it under the same session id; Master and + // subconscious replies stay plain. + let plaintext = session_send_plaintext(&self.session_id, body)?; let mut params = Map::new(); params.insert("recipient".to_string(), Value::from(counterpart_agent_id)); - params.insert("plaintext".to_string(), Value::from(body)); + params.insert("plaintext".to_string(), Value::from(plaintext)); crate::openhuman::tinyplace::handle_tinyplace_signal_send_message(params) .await .map(|_| ()) @@ -738,6 +742,24 @@ impl OrchestrationRuntime for ProductionRuntime { } } +/// Wire body for an agent reply into `session_id`: a v1 session envelope for a +/// real harness session (so the peer threads its reply under the same id), or +/// the plain body for the pinned Master / subconscious windows. +fn session_send_plaintext(session_id: &str, body: &str) -> anyhow::Result { + if session_id == "master" || session_id == "subconscious" { + return Ok(body.to_string()); + } + let message_id = format!("session-out:{}", uuid::Uuid::new_v4()); + let now = chrono::Utc::now().to_rfc3339(); + serde_json::to_string(&SessionEnvelopeV1::outgoing( + session_id, + body, + &message_id, + &now, + )) + .map_err(|e| anyhow::anyhow!("envelope encode: {e}")) +} + #[cfg(test)] mod tests { use super::*; @@ -753,6 +775,18 @@ mod tests { } } + #[test] + fn session_reply_is_wrapped_but_master_reply_stays_plain() { + // A real session id → v1 envelope threaded under that id. + let wire = session_send_plaintext("h-42", "on it").expect("encode"); + let env = SessionEnvelopeV1::parse(&wire).expect("valid v1 envelope"); + assert_eq!(env.scope.harness_session_id, "h-42"); + assert_eq!(env.message.text, "on it"); + // The pinned windows stay plain (no envelope). + assert_eq!(session_send_plaintext("master", "hi").unwrap(), "hi"); + assert_eq!(session_send_plaintext("subconscious", "hi").unwrap(), "hi"); + } + fn msg(session: &str, seq: i64) -> OrchestrationMessage { OrchestrationMessage { id: format!("m{seq}"), diff --git a/src/openhuman/orchestration/schemas.rs b/src/openhuman/orchestration/schemas.rs index 99e01737ae..a852670835 100644 --- a/src/openhuman/orchestration/schemas.rs +++ b/src/openhuman/orchestration/schemas.rs @@ -14,7 +14,7 @@ use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::{rpc as config_rpc, Config}; use super::store; -use super::types::{ChatKind, OrchestrationMessage, OrchestrationSession}; +use super::types::{ChatKind, OrchestrationMessage, OrchestrationSession, SessionEnvelopeV1}; /// Active-window: a session is "active" if it saw traffic within this many ms. const ACTIVE_WINDOW_MS: i64 = 45 * 60 * 1000; @@ -23,6 +23,7 @@ const LOG: &str = "orchestration_rpc"; pub fn all_controller_schemas() -> Vec { vec![ schema_for("orchestration_sessions_list"), + schema_for("orchestration_sessions_create"), schema_for("orchestration_messages_list"), schema_for("orchestration_send_master_message"), schema_for("orchestration_mark_read"), @@ -36,6 +37,10 @@ pub fn all_registered_controllers() -> Vec { schema: schema_for("orchestration_sessions_list"), handler: handle_sessions_list, }, + RegisteredController { + schema: schema_for("orchestration_sessions_create"), + handler: handle_sessions_create, + }, RegisteredController { schema: schema_for("orchestration_messages_list"), handler: handle_messages_list, @@ -64,6 +69,16 @@ fn schema_for(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("result", "{ sessions: SessionSummary[] }.")], }, + "orchestration_sessions_create" => ControllerSchema { + namespace: "orchestration", + function: "sessions_create", + description: "Create a new empty orchestration session for a contact (mints a fresh harness session id). Idempotent per (agentId, sessionId).", + inputs: vec![ + required_str("agentId", "Contact agent id (address) the new session belongs to."), + optional_str("label", "Optional human-friendly label for the session."), + ], + outputs: vec![json_output("result", "{ session: SessionSummary }.")], + }, "orchestration_messages_list" => ControllerSchema { namespace: "orchestration", function: "messages_list", @@ -71,16 +86,23 @@ fn schema_for(function: &str) -> ControllerSchema { inputs: vec![ required_str("chat", "Chat key: \"master\" | \"subconscious\" | ."), optional_str("before", "Exclusive ISO timestamp to page backwards from."), + FieldSchema { + name: "limit", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max messages to return (default 100, capped at 500).", + required: false, + }, ], outputs: vec![json_output("result", "{ messages: OrchestrationMessage[] }.")], }, "orchestration_send_master_message" => ControllerSchema { namespace: "orchestration", function: "send_master_message", - description: "Send a Master steering DM (owner → front-end agent) over the signal-send op.", + description: "Send a Master steering DM (owner → front-end agent) over the signal-send op. With a sessionId, sends a session-scoped envelope instead and threads it under that session window.", inputs: vec![ required_str("body", "Message body to send to the Master counterpart."), optional_str("recipient", "Recipient agent id; defaults to the latest Master peer."), + optional_str("sessionId", "Session id to send under; when set the body is wrapped in a v1 session envelope and mirrored into that session window instead of Master."), ], outputs: vec![json_output("result", "{ ok: bool, messageId?: string }.")], }, @@ -225,6 +247,41 @@ fn handle_sessions_list(_params: Map) -> ControllerFuture { }) } +fn handle_sessions_create(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = load_config("sessions_create").await?; + let agent_id = required_param(¶ms, "agentId")?.to_string(); + let label = params + .get("label") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let session_id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + log::debug!( + target: LOG, + "[orchestration_rpc] sessions_create agent_id={agent_id} session_id={session_id}" + ); + let session = OrchestrationSession { + session_id: session_id.clone(), + agent_id: agent_id.clone(), + source: "user_created".to_string(), + label, + workspace: None, + last_seq: 0, + created_at: now.clone(), + last_message_at: now.clone(), + }; + store::with_connection(&config.workspace_dir, |conn| { + store::upsert_session(conn, &session) + }) + .map_err(|e| format!("sessions_create: {e}"))?; + super::bus::notify_orchestration_message(&agent_id, &session_id, "session"); + to_json(serde_json::json!({ "session": summarize(session, 0, false) })) + }) +} + fn pinned_placeholder(session_id: &str) -> SessionSummary { SessionSummary { session_id: session_id.to_string(), @@ -263,6 +320,20 @@ fn handle_messages_list(params: Map) -> ControllerFuture { }) } +/// Build the v1 session-envelope wire body for an outgoing session message so a +/// compliant peer harness threads the reply under the same `session_id`. +fn session_envelope_plaintext( + session_id: &str, + body: &str, + message_id: &str, + now: &str, +) -> Result { + serde_json::to_string(&SessionEnvelopeV1::outgoing( + session_id, body, message_id, now, + )) + .map_err(|e| format!("envelope encode: {e}")) +} + fn handle_send_master_message(params: Map) -> ControllerFuture { Box::pin(async move { let config = load_config("send_master_message").await?; @@ -272,33 +343,71 @@ fn handle_send_master_message(params: Map) -> ControllerFuture { .and_then(Value::as_str) .filter(|s| !s.trim().is_empty()) .map(str::to_string); + // When present, the message threads under this session (envelope) rather + // than the Master window. + let session_id = params + .get("sessionId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty() && *s != "master" && *s != "subconscious") + .map(str::to_string); + + // Resolve the recipient: explicit wins; otherwise the session's contact + // (session mode) or the latest Master peer (master mode). + let recipient = match (explicit, session_id.as_deref()) { + (Some(r), _) => r, + (None, Some(sid)) => { + let sid = sid.to_string(); + store::with_connection(&config.workspace_dir, move |conn| { + store::session_agent_id(conn, &sid) + }) + .map_err(|e| format!("resolve session recipient: {e}"))? + .ok_or_else(|| "unknown session — specify a recipient".to_string())? + } + (None, None) => { + store::with_connection(&config.workspace_dir, store::latest_master_peer) + .map_err(|e| format!("resolve recipient: {e}"))? + .ok_or_else(|| "no Master counterpart yet — specify a recipient".to_string())? + } + }; + + let now = chrono::Utc::now().to_rfc3339(); + let (window, chat_kind, message_id) = match &session_id { + Some(sid) => (sid.clone(), ChatKind::Session, format!("session-out:{now}")), + None => ( + "master".to_string(), + ChatKind::Master, + format!("master-out:{now}"), + ), + }; - let recipient = match explicit { - Some(r) => r, - None => store::with_connection(&config.workspace_dir, store::latest_master_peer) - .map_err(|e| format!("resolve recipient: {e}"))? - .ok_or_else(|| "no Master counterpart yet — specify a recipient".to_string())?, + // Session sends go over the wire as a v1 envelope; Master sends stay plain. + let plaintext = match &session_id { + Some(sid) => session_envelope_plaintext(sid, &body, &message_id, &now)?, + None => body.clone(), }; // Send the E2E DM to the front-end agent (human steering the front end). let mut send_params = Map::new(); send_params.insert("recipient".to_string(), Value::from(recipient.clone())); - send_params.insert("plaintext".to_string(), Value::from(body.clone())); + send_params.insert("plaintext".to_string(), Value::from(plaintext)); crate::openhuman::tinyplace::handle_tinyplace_signal_send_message(send_params) .await .map_err(|e| format!("signal send: {e}"))?; - // Mirror it into the Master window so the composer's message is visible, - // and notify the renderer. - let now = chrono::Utc::now().to_rfc3339(); - let message_id = format!("master-out:{}", now); + // Mirror it into the target window so the composer's message is visible, + // and notify the renderer. `upsert_session` never clobbers an existing + // session's `source`, so a user-created session keeps its origin. let persisted = store::with_connection(&config.workspace_dir, |conn| { store::upsert_session( conn, &OrchestrationSession { - session_id: "master".to_string(), + session_id: window.clone(), agent_id: recipient.clone(), - source: "master".to_string(), + source: match &session_id { + Some(_) => "user_created".to_string(), + None => "master".to_string(), + }, label: None, workspace: None, last_seq: 0, @@ -311,8 +420,8 @@ fn handle_send_master_message(params: Map) -> ControllerFuture { &OrchestrationMessage { id: message_id.clone(), agent_id: recipient.clone(), - session_id: "master".to_string(), - chat_kind: ChatKind::Master, + session_id: window.clone(), + chat_kind, role: "owner".to_string(), body: body.clone(), timestamp: now.clone(), @@ -323,7 +432,7 @@ fn handle_send_master_message(params: Map) -> ControllerFuture { if let Err(e) = persisted { log::warn!(target: LOG, "[orchestration_rpc] send_master.mirror_failed: {e}"); } - super::bus::notify_orchestration_message(&recipient, "master", "master"); + super::bus::notify_orchestration_message(&recipient, &window, chat_kind.as_str()); to_json(serde_json::json!({ "ok": true, "messageId": message_id })) }) @@ -450,12 +559,57 @@ mod tests { #[test] fn schemas_use_orchestration_namespace() { let schemas = all_controller_schemas(); - assert_eq!(schemas.len(), 5); + assert_eq!(schemas.len(), 6); assert!(schemas.iter().all(|s| s.namespace == "orchestration")); assert_eq!( schema_for("orchestration_messages_list").function, "messages_list" ); + assert_eq!( + schema_for("orchestration_sessions_create").function, + "sessions_create" + ); + } + + #[test] + fn session_envelope_plaintext_roundtrips_as_v1() { + let wire = + session_envelope_plaintext("sess-1", "hello world", "msg-1", "2026-07-04T00:00:00Z") + .expect("encode"); + let parsed = SessionEnvelopeV1::parse(&wire).expect("valid v1 envelope"); + assert_eq!(parsed.scope.harness_session_id, "sess-1"); + assert_eq!(parsed.message.text, "hello world"); + assert_eq!(parsed.message.role, "owner"); + } + + #[tokio::test] + async fn created_session_persists_and_resolves_its_agent() { + let tmp = tempfile::tempdir().unwrap(); + let config = Config { + workspace_dir: tmp.path().to_path_buf(), + ..Config::default() + }; + let now = "2026-07-04T00:00:00Z".to_string(); + let session = OrchestrationSession { + session_id: "sess-42".to_string(), + agent_id: "@peer".to_string(), + source: "user_created".to_string(), + label: Some("Design review".to_string()), + workspace: None, + last_seq: 0, + created_at: now.clone(), + last_message_at: now, + }; + let resolved = store::with_connection(&config.workspace_dir, |conn| { + store::upsert_session(conn, &session)?; + let rows = store::list_sessions(conn)?; + assert!(rows.iter().any(|s| s.session_id == "sess-42" + && s.source == "user_created" + && s.agent_id == "@peer")); + store::session_agent_id(conn, "sess-42") + }) + .unwrap(); + assert_eq!(resolved.as_deref(), Some("@peer")); } #[test] diff --git a/src/openhuman/orchestration/store.rs b/src/openhuman/orchestration/store.rs index e836219fe7..42d680ae51 100644 --- a/src/openhuman/orchestration/store.rs +++ b/src/openhuman/orchestration/store.rs @@ -327,6 +327,17 @@ pub fn latest_master_peer(conn: &Connection) -> Result> { .map_err(Into::into) } +/// The contact (`agent_id`) that owns a given session id, if the session exists. +pub fn session_agent_id(conn: &Connection, session_id: &str) -> Result> { + conn.query_row( + "SELECT agent_id FROM sessions WHERE session_id = ?1 LIMIT 1", + params![session_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) +} + fn read_cursor_key(session_id: &str) -> String { format!("read:{session_id}") } diff --git a/src/openhuman/orchestration/types.rs b/src/openhuman/orchestration/types.rs index 659c60fe11..806e143f7a 100644 --- a/src/openhuman/orchestration/types.rs +++ b/src/openhuman/orchestration/types.rs @@ -108,6 +108,29 @@ impl SessionEnvelopeV1 { let envelope: Self = serde_json::from_str(body).ok()?; envelope.is_valid_v1().then_some(envelope) } + + /// Build an outgoing v1 session envelope carrying `body` under `session_id`, + /// so a compliant peer harness threads its reply under the same session. + pub fn outgoing(session_id: &str, body: &str, message_id: &str, timestamp: &str) -> Self { + SessionEnvelopeV1 { + envelope_version: SESSION_ENVELOPE_VERSION_V1.to_string(), + version: 1, + scope: HarnessScope { + scope_type: "session".to_string(), + wrapper_session_id: session_id.to_string(), + harness_session_id: session_id.to_string(), + ..Default::default() + }, + message: HarnessEnvelopeMessage { + id: message_id.to_string(), + role: "owner".to_string(), + text: body.to_string(), + timestamp: timestamp.to_string(), + ..Default::default() + }, + ..Default::default() + } + } } /// Which pinned/session window a persisted message belongs to. @@ -194,6 +217,17 @@ mod tests { assert_eq!(env.harness.provider, "claude"); } + #[test] + fn outgoing_builds_a_parseable_v1_envelope() { + let env = SessionEnvelopeV1::outgoing("h9", "reply body", "m9", "2026-07-04T00:00:00Z"); + let wire = serde_json::to_string(&env).expect("encode"); + let parsed = SessionEnvelopeV1::parse(&wire).expect("valid v1"); + assert_eq!(parsed.scope.harness_session_id, "h9"); + assert_eq!(parsed.scope.wrapper_session_id, "h9"); + assert_eq!(parsed.message.text, "reply body"); + assert_eq!(parsed.message.role, "owner"); + } + #[test] fn rejects_non_envelope_and_bad_version() { assert!(SessionEnvelopeV1::parse("a plain message").is_none());