From 0fa0c3a1a073570856c0c45025856953ca219330 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:23:46 -0400 Subject: [PATCH] fix(chat): rename default thread label to general (#3319) --- app/src/lib/i18n/ar.ts | 2 +- app/src/lib/i18n/bn.ts | 2 +- app/src/lib/i18n/de.ts | 2 +- app/src/lib/i18n/en.ts | 2 +- app/src/lib/i18n/es.ts | 2 +- app/src/lib/i18n/fr.ts | 2 +- app/src/lib/i18n/hi.ts | 2 +- app/src/lib/i18n/id.ts | 2 +- app/src/lib/i18n/it.ts | 2 +- app/src/lib/i18n/ko.ts | 2 +- app/src/lib/i18n/pl.ts | 2 +- app/src/lib/i18n/pt.ts | 2 +- app/src/lib/i18n/ru.ts | 2 +- app/src/lib/i18n/zh-CN.ts | 2 +- app/src/pages/Conversations.tsx | 24 ++++++++------ .../__tests__/Conversations.render.test.tsx | 25 ++++++++------ .../conversations/utils/threadFilter.test.ts | 19 +++++++---- .../pages/conversations/utils/threadFilter.ts | 10 +++++- .../specs/chat-conversation-history.spec.ts | 12 +++---- src/openhuman/memory_conversations/bus.rs | 2 +- src/openhuman/memory_conversations/store.rs | 33 +++++++++++++++---- .../memory_conversations/store_tests.rs | 31 ++++++++++++++--- src/openhuman/threads/ops_tests.rs | 4 +-- ...ntials_threads_round24_raw_coverage_e2e.rs | 2 +- tests/json_rpc_e2e.rs | 9 ++--- 25 files changed, 132 insertions(+), 67 deletions(-) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 44989ac63..a72de77a9 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1678,7 +1678,7 @@ const messages: TranslationMap = { 'common.enable': 'تفعيل', 'chat.safetyTimeout': 'لا استجابة من الوكيل بعد دقيقتين. حاول مرة أخرى أو تحقق من اتصالك.', 'chat.filter.all': 'الكل', - 'chat.filter.work': 'العمل', + 'chat.filter.general': 'عام', 'chat.filter.briefing': 'الإحاطة', 'chat.filter.notification': 'الإشعار', 'chat.filter.workers': 'العمال', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 202d00b2a..4ed95143d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1712,7 +1712,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': '২ মিনিট পরেও এজেন্টের কোনো সাড়া নেই। আবার চেষ্টা করুন বা সংযোগ পরীক্ষা করুন।', 'chat.filter.all': 'সব', - 'chat.filter.work': 'কাজ', + 'chat.filter.general': 'সাধারণ', 'chat.filter.briefing': 'ব্রিফিং', 'chat.filter.notification': 'বিজ্ঞপ্তি', 'chat.filter.workers': 'ওয়ার্কার', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 11b2f93f5..54a95a0aa 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1757,7 +1757,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': 'Keine Antwort vom Agenten nach 2 Minuten. Versuche es erneut oder prüfe deine Verbindung.', 'chat.filter.all': 'Alle', - 'chat.filter.work': 'Arbeit', + 'chat.filter.general': 'Allgemein', 'chat.filter.briefing': 'Briefing', 'chat.filter.notification': 'Benachrichtigung', 'chat.filter.workers': 'Arbeiter', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7ca28bc37..a81f3330d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1960,7 +1960,7 @@ const en: TranslationMap = { 'chat.safetyTimeout': 'No response from the agent after 2 minutes. Try again or check your connection.', 'chat.filter.all': 'All', - 'chat.filter.work': 'Work', + 'chat.filter.general': 'General', 'chat.filter.briefing': 'Briefing', 'chat.filter.notification': 'Notification', 'chat.filter.workers': 'Workers', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 208b45225..0c25df57d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1751,7 +1751,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': 'Sin respuesta del agente después de 2 minutos. Intenta de nuevo o verifica tu conexión.', 'chat.filter.all': 'Todos', - 'chat.filter.work': 'Trabajo', + 'chat.filter.general': 'General', 'chat.filter.briefing': 'Resumen', 'chat.filter.notification': 'Notificación', 'chat.filter.workers': 'Trabajadores', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 579d3e9b9..30144596b 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1755,7 +1755,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': "Aucune réponse de l'agent après 2 minutes. Réessaie ou vérifie ta connexion.", 'chat.filter.all': 'Tous', - 'chat.filter.work': 'Travail', + 'chat.filter.general': 'Général', 'chat.filter.briefing': 'Briefing', 'chat.filter.notification': 'Notification', 'chat.filter.workers': 'Travailleurs', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index da112d2a0..c4eeba170 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1713,7 +1713,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': '2 मिनट बाद भी एजेंट से कोई जवाब नहीं मिला। दोबारा कोशिश करें या अपना कनेक्शन चेक करें।', 'chat.filter.all': 'सभी', - 'chat.filter.work': 'वर्क', + 'chat.filter.general': 'सामान्य', 'chat.filter.briefing': 'ब्रीफिंग', 'chat.filter.notification': 'नोटिफिकेशन', 'chat.filter.workers': 'वर्कर्स', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c630b7627..63b41a8eb 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1715,7 +1715,7 @@ const messages: TranslationMap = { 'common.enable': 'Aktifkan', 'chat.safetyTimeout': 'Tidak ada respons dari agen setelah 2 menit. Coba lagi atau cek koneksi.', 'chat.filter.all': 'Semua', - 'chat.filter.work': 'Kerja', + 'chat.filter.general': 'Umum', 'chat.filter.briefing': 'Ringkasan', 'chat.filter.notification': 'Notifikasi', 'chat.filter.workers': 'Worker', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c554b9c1d..6c2a57d30 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1741,7 +1741,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': "Nessuna risposta dall'agente dopo 2 minuti. Riprova o controlla la connessione.", 'chat.filter.all': 'Tutti', - 'chat.filter.work': 'Lavoro', + 'chat.filter.general': 'Generale', 'chat.filter.briefing': 'Briefing', 'chat.filter.notification': 'Notifica', 'chat.filter.workers': 'Worker', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index ccdc037bc..19f14a498 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1693,7 +1693,7 @@ const messages: TranslationMap = { 'common.enable': '활성화', 'chat.safetyTimeout': '2분 후에도 에이전트의 응답이 없습니다. 다시 시도하거나 연결을 확인하세요.', 'chat.filter.all': '전체', - 'chat.filter.work': '업무', + 'chat.filter.general': '일반', 'chat.filter.briefing': '브리핑', 'chat.filter.notification': '알림', 'chat.filter.workers': '워커', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 670cd37a6..5628f9800 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1734,7 +1734,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': 'Brak odpowiedzi agenta po 2 minutach. Spróbuj ponownie lub sprawdź połączenie.', 'chat.filter.all': 'Wszystkie', - 'chat.filter.work': 'Praca', + 'chat.filter.general': 'Ogólne', 'chat.filter.briefing': 'Briefing', 'chat.filter.notification': 'Powiadomienia', 'chat.filter.workers': 'Workery', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f9e536447..3a75512c7 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1749,7 +1749,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': 'Nenhuma resposta do agente após 2 minutos. Tente novamente ou verifique sua conexão.', 'chat.filter.all': 'Todos', - 'chat.filter.work': 'Trabalho', + 'chat.filter.general': 'Geral', 'chat.filter.briefing': 'Resumo', 'chat.filter.notification': 'Notificação', 'chat.filter.workers': 'Trabalhadores', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b9c81001b..ca4c06e59 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1726,7 +1726,7 @@ const messages: TranslationMap = { 'chat.safetyTimeout': 'Агент не ответил в течение 2 минут. Попробуй снова или проверь соединение.', 'chat.filter.all': 'Все', - 'chat.filter.work': 'Работа', + 'chat.filter.general': 'Общее', 'chat.filter.briefing': 'Брифинг', 'chat.filter.notification': 'Уведомление', 'chat.filter.workers': 'Воркеры', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e7e435574..65d202f23 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1619,7 +1619,7 @@ const messages: TranslationMap = { 'common.enable': '启用', 'chat.safetyTimeout': '助手 2 分钟内未响应。请重试或检查你的连接。', 'chat.filter.all': '全部', - 'chat.filter.work': '工作', + 'chat.filter.general': '常规', 'chat.filter.briefing': '简报', 'chat.filter.notification': '通知', 'chat.filter.workers': '工作线程', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index ee085957e..2fbf528c6 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -96,7 +96,11 @@ import { formatResetTime, getInlineCompletionSuffix, } from './conversations/utils/format'; -import { isThreadVisibleInTab, WORKERS_TAB_VALUE } from './conversations/utils/threadFilter'; +import { + GENERAL_TAB_VALUE, + isThreadVisibleInTab, + WORKERS_TAB_VALUE, +} from './conversations/utils/threadFilter'; // Chat uses the reasoning model; `agentic-v1` is reserved for sub-agents // that execute tool calls, not the primary user-facing conversation. @@ -213,7 +217,7 @@ const Conversations = ({ const [isTranscribing, setIsTranscribing] = useState(false); const [voiceStatus, setVoiceStatus] = useState(null); const [isPlayingReply, setIsPlayingReply] = useState(false); - const [selectedLabel, setSelectedLabel] = useState('all'); + const [selectedLabel, setSelectedLabel] = useState(GENERAL_TAB_VALUE); const [inlineSuggestionValue, setInlineSuggestionValue] = useState(''); const [sendError, setSendError] = useState(null); const [attachError, setAttachError] = useState(null); @@ -399,11 +403,9 @@ const Conversations = ({ .then(data => { if (cancelled) return; const threadStateForSelect = store.getState().thread; - // Worker/subagent threads are hidden from the conversation list - // (see tinyhumansai/openhuman#1624). Match the sidebar filter here so - // initial/resume selection can't auto-pick a hidden thread and leave - // the UI showing a thread that isn't in the list. - const visibleThreads = data.threads.filter(t => !t.parentThreadId); + // Match the sidebar's default General filter here so initial/resume + // selection can't auto-pick a thread hidden by the selected tab. + const visibleThreads = data.threads.filter(t => isThreadVisibleInTab(t, GENERAL_TAB_VALUE)); if (visibleThreads.length > 0) { // Prefer the thread the user was last viewing (persisted across // reloads via redux-persist on the `thread` slice). Only fall @@ -1248,11 +1250,13 @@ const Conversations = ({ // transcript is the inline `WorkerThreadRefCard` inside the parent. const labelTabs = [ { label: t('chat.filter.all'), value: 'all' }, - { label: t('chat.filter.work'), value: 'work' }, + { label: t('chat.filter.general'), value: GENERAL_TAB_VALUE }, { label: t('chat.filter.briefing'), value: 'briefing' }, { label: t('chat.filter.notification'), value: 'notification' }, { label: t('chat.filter.workers'), value: WORKERS_TAB_VALUE }, ]; + const selectedLabelDisplay = + labelTabs.find(tab => tab.value === selectedLabel)?.label ?? selectedLabel; const isSidebar = variant === 'sidebar'; const effectiveShowSidebar = showSidebar; @@ -1321,7 +1325,7 @@ const Conversations = ({ items={labelTabs} selected={selectedLabel} onChange={setSelectedLabel} - containerClassName="flex gap-1 overflow-x-auto py-1 scrollbar-hide" + containerClassName="flex flex-wrap gap-1 py-1" />
@@ -1331,7 +1335,7 @@ const Conversations = ({ ? t('chat.noThreads') : selectedLabel === WORKERS_TAB_VALUE ? t('chat.noWorkerThreads') - : t('chat.noLabelThreads').replace('{label}', selectedLabel)} + : t('chat.noLabelThreads').replace('{label}', selectedLabelDisplay)}

) : ( sortedThreads.map(thread => ( diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index c1c0e2e54..f8816999c 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -181,7 +181,7 @@ function makeThread(overrides: Partial = {}): Thread { messageCount: 0, lastMessageAt: '2026-01-01T00:00:00.000Z', createdAt: '2026-01-01T00:00:00.000Z', - labels: [], + labels: ['general'], ...overrides, }; } @@ -322,13 +322,14 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { }); // Covers line 941 empty branch - it('shows "No threads yet" when thread list is empty', async () => { + it('shows "No threads yet" when All is selected and the thread list is empty', async () => { await act(async () => { await renderConversations({ thread: emptyThreadState }); }); // Sidebar is hidden by default — open it first. await openSidebar(); + fireEvent.click(screen.getByRole('tab', { name: 'All' })); expect(screen.getByText('No threads yet')).toBeInTheDocument(); }); @@ -1266,7 +1267,7 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // // The tab set is fixed so categories do not disappear when the thread list // is empty, and the active-filter state remains unambiguous. - it('renders all four fixed category tabs with stable labels', async () => { + it('renders all fixed category tabs with stable labels', async () => { await act(async () => { await renderConversations({ thread: emptyThreadState }); }); @@ -1274,14 +1275,16 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // Sidebar is hidden by default — open it first. await openSidebar(); - // All four tabs must be present regardless of thread count. + // All tabs must be present regardless of thread count. expect(screen.getByRole('tab', { name: 'All' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Work' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'General' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Briefing' })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Notification' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Workers' })).toBeInTheDocument(); + expect(screen.getByRole('tablist')).toHaveClass('flex-wrap'); }); - it('starts with the "All" tab selected', async () => { + it('starts with the "General" tab selected', async () => { await act(async () => { await renderConversations({ thread: emptyThreadState }); }); @@ -1289,8 +1292,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // Sidebar is hidden by default — open it first. await openSidebar(); - expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.getByRole('tab', { name: 'Work' })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'All' })).toHaveAttribute('aria-selected', 'false'); }); it('shows "No threads yet" placeholder when All tab is active and list is empty', async () => { @@ -1301,6 +1304,8 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // Sidebar is hidden by default — open it first. await openSidebar(); + fireEvent.click(screen.getByRole('tab', { name: 'All' })); + expect(screen.getByText('No threads yet')).toBeInTheDocument(); }); @@ -1312,10 +1317,10 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { // Sidebar is hidden by default — open it first. await openSidebar(); - fireEvent.click(screen.getByRole('tab', { name: 'Work' })); + fireEvent.click(screen.getByRole('tab', { name: 'General' })); await waitFor(() => { - expect(screen.getByText(/"work" threads/i)).toBeInTheDocument(); + expect(screen.getByText(/"General" threads/i)).toBeInTheDocument(); }); }); diff --git a/app/src/pages/conversations/utils/threadFilter.test.ts b/app/src/pages/conversations/utils/threadFilter.test.ts index 9df87ab19..11b7898d4 100644 --- a/app/src/pages/conversations/utils/threadFilter.test.ts +++ b/app/src/pages/conversations/utils/threadFilter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Thread } from '../../../types/thread'; -import { isThreadVisibleInTab, WORKERS_TAB_VALUE } from './threadFilter'; +import { GENERAL_TAB_VALUE, isThreadVisibleInTab, WORKERS_TAB_VALUE } from './threadFilter'; // Issue #1624: this is the pure rule that backs the sidebar // `filteredThreads` memo + the `Workers` tab. The tests pin both halves @@ -35,20 +35,25 @@ describe('isThreadVisibleInTab', () => { }); }); - describe('label-scoped tabs (work, briefing, notification, …)', () => { + describe('label-scoped tabs (general, briefing, notification, ...)', () => { it('keeps a non-worker thread that carries the matching label', () => { - const t = thread({ id: 'a', labels: ['work', 'urgent'] }); - expect(isThreadVisibleInTab(t, 'work')).toBe(true); + const t = thread({ id: 'a', labels: [GENERAL_TAB_VALUE, 'urgent'] }); + expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(true); + }); + + it('keeps legacy work-labeled threads in the General tab', () => { + const t = thread({ id: 'legacy', labels: ['work', 'urgent'] }); + expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(true); }); it('drops a non-worker thread that does not carry the matching label', () => { const t = thread({ id: 'a', labels: ['briefing'] }); - expect(isThreadVisibleInTab(t, 'work')).toBe(false); + expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(false); }); it('still hides worker threads even when the label would otherwise match', () => { - const t = thread({ id: 'w', parentThreadId: 'p', labels: ['work'] }); - expect(isThreadVisibleInTab(t, 'work')).toBe(false); + const t = thread({ id: 'w', parentThreadId: 'p', labels: [GENERAL_TAB_VALUE] }); + expect(isThreadVisibleInTab(t, GENERAL_TAB_VALUE)).toBe(false); }); it('treats threads with no labels array as not matching', () => { diff --git a/app/src/pages/conversations/utils/threadFilter.ts b/app/src/pages/conversations/utils/threadFilter.ts index 3ebace0ce..bb8ff869d 100644 --- a/app/src/pages/conversations/utils/threadFilter.ts +++ b/app/src/pages/conversations/utils/threadFilter.ts @@ -13,6 +13,8 @@ import type { Thread } from '../../../types/thread'; * future rename can never silently desync the three. */ export const WORKERS_TAB_VALUE = 'workers'; +export const GENERAL_TAB_VALUE = 'general'; +export const LEGACY_GENERAL_LABEL = 'work'; /** * Pure, side-effect-free thread filter shared between @@ -30,12 +32,18 @@ export const WORKERS_TAB_VALUE = 'workers'; * orchestrator-spawned background work. * - Within the non-Workers tabs, `selectedLabel === 'all'` keeps every * non-worker thread; any other value scopes by the existing thread - * `labels[]` array (`work`, `briefing`, `notification`, …). + * `labels[]` array (`general`, `briefing`, `notification`, …). The + * General tab also accepts legacy `work` labels from older cores. */ export function isThreadVisibleInTab(thread: Thread, selectedLabel: string): boolean { const isWorker = Boolean(thread.parentThreadId); if (selectedLabel === WORKERS_TAB_VALUE) return isWorker; if (isWorker) return false; if (selectedLabel === 'all') return true; + if (selectedLabel === GENERAL_TAB_VALUE) { + return Boolean( + thread.labels?.includes(GENERAL_TAB_VALUE) || thread.labels?.includes(LEGACY_GENERAL_LABEL) + ); + } return Boolean(thread.labels?.includes(selectedLabel)); } diff --git a/app/test/playwright/specs/chat-conversation-history.spec.ts b/app/test/playwright/specs/chat-conversation-history.spec.ts index 8a31a1f7d..149e3048e 100644 --- a/app/test/playwright/specs/chat-conversation-history.spec.ts +++ b/app/test/playwright/specs/chat-conversation-history.spec.ts @@ -13,6 +13,7 @@ const SECRET_WORD = 'XYZZY'; const FIRST_PROMPT = `Remember: the secret word is ${SECRET_WORD}`; const SECOND_PROMPT = 'What was the secret word?'; const TURN_TWO_CANARY = `canary-memory-m1n2o3-${SECRET_WORD}`; +const FIRST_RESPONSE = `Got it! I will remember that the secret word is ${SECRET_WORD}.`; interface MockRequest { method: string; @@ -126,19 +127,16 @@ test.describe('Chat Conversation History', () => { page, }) => { await resetMock(); - await setMockBehavior( - 'llmForcedResponses', - JSON.stringify([ - { content: `Got it! I will remember that the secret word is ${SECRET_WORD}.` }, - ]) - ); + await setMockBehavior('llmForcedResponses', JSON.stringify([{ content: FIRST_RESPONSE }])); await setMockBehavior('llmStreamChunkDelayMs', '10'); await openChat(page); await createNewThread(page); await sendMessage(page, FIRST_PROMPT); - await expect(page.getByText('Got it!')).toBeVisible({ timeout: 20_000 }); + await expect( + page.locator('div.bg-stone-200').filter({ hasText: FIRST_RESPONSE }).first() + ).toBeVisible({ timeout: 20_000 }); await resetMock(); await setMockBehavior( diff --git a/src/openhuman/memory_conversations/bus.rs b/src/openhuman/memory_conversations/bus.rs index 5d91aa232..9ad114714 100644 --- a/src/openhuman/memory_conversations/bus.rs +++ b/src/openhuman/memory_conversations/bus.rs @@ -233,7 +233,7 @@ fn persist_channel_turn( title, created_at: created_at.clone(), parent_thread_id: None, - labels: Some(vec!["work".to_string()]), + labels: Some(vec!["general".to_string()]), personality_id: None, }, )?; diff --git a/src/openhuman/memory_conversations/store.rs b/src/openhuman/memory_conversations/store.rs index 3721135bd..5230e566b 100644 --- a/src/openhuman/memory_conversations/store.rs +++ b/src/openhuman/memory_conversations/store.rs @@ -137,6 +137,7 @@ impl ConversationStore { let root = self.ensure_root()?; let threads_path = root.join(THREADS_FILENAME); let now = request.created_at.clone(); + let labels = request.labels.clone().map(normalize_labels); append_jsonl( &threads_path, &ThreadLogEntry::Upsert { @@ -145,7 +146,7 @@ impl ConversationStore { created_at: request.created_at.clone(), updated_at: now, parent_thread_id: request.parent_thread_id.clone(), - labels: request.labels.clone(), + labels, personality_id: request.personality_id.clone(), }, )?; @@ -479,6 +480,7 @@ impl ConversationStore { .get(thread_id) .ok_or_else(|| format!("thread {} not found", thread_id))?; let threads_path = self.ensure_root()?.join(THREADS_FILENAME); + let labels = normalize_labels(labels); append_jsonl( &threads_path, &ThreadLogEntry::Upsert { @@ -689,7 +691,7 @@ impl ConversationStore { last_message_at, created_at: entry.created_at.clone(), parent_thread_id: entry.parent_thread_id.clone(), - labels: entry.labels.clone(), + labels: normalize_labels(entry.labels.clone()), personality_id: entry.personality_id.clone(), } }) @@ -749,7 +751,7 @@ impl ConversationStore { last_message_at, created_at: entry.created_at.clone(), parent_thread_id: entry.parent_thread_id.clone(), - labels: entry.labels.clone(), + labels: normalize_labels(entry.labels.clone()), personality_id: entry.personality_id.clone(), })) } @@ -784,13 +786,17 @@ impl ConversationStore { Some(existing) => ( existing.created_at.clone(), parent_thread_id.or_else(|| existing.parent_thread_id.clone()), - labels.unwrap_or_else(|| existing.labels.clone()), + labels + .map(normalize_labels) + .unwrap_or_else(|| existing.labels.clone()), existing.message_count, existing.last_message_at.clone(), personality_id.or_else(|| existing.personality_id.clone()), ), None => { - let inferred = labels.unwrap_or_else(|| infer_labels(&thread_id)); + let inferred = labels + .map(normalize_labels) + .unwrap_or_else(|| infer_labels(&thread_id)); ( created_at, parent_thread_id, @@ -880,10 +886,25 @@ fn infer_labels(thread_id: &str) -> Vec { } else if thread_id.starts_with("proactive:") { vec!["notification".to_string()] } else { - vec!["work".to_string()] + vec!["general".to_string()] } } +fn normalize_labels(labels: Vec) -> Vec { + let mut normalized = Vec::with_capacity(labels.len()); + for label in labels { + let next = if label == "work" { + "general".to_string() + } else { + label + }; + if !normalized.contains(&next) { + normalized.push(next); + } + } + normalized +} + fn read_jsonl(path: &Path) -> Result, String> where T: for<'de> serde::Deserialize<'de>, diff --git a/src/openhuman/memory_conversations/store_tests.rs b/src/openhuman/memory_conversations/store_tests.rs index f3f5b174f..7fba6fef2 100644 --- a/src/openhuman/memory_conversations/store_tests.rs +++ b/src/openhuman/memory_conversations/store_tests.rs @@ -353,7 +353,7 @@ fn store_handles_labels_and_inference() { }) .unwrap(); - // 4. Default inferred labels (work) + // 4. Default inferred labels (general) store .ensure_thread(CreateConversationThread { parent_thread_id: None, @@ -365,6 +365,22 @@ fn store_handles_labels_and_inference() { }) .unwrap(); + // 5. Legacy explicit "work" labels normalize into General. + store + .ensure_thread(CreateConversationThread { + parent_thread_id: None, + id: "legacy-work-thread".to_string(), + title: "Legacy Work Chat".to_string(), + created_at: "2026-04-10T12:00:00Z".to_string(), + labels: Some(vec![ + "work".to_string(), + "urgent".to_string(), + "work".to_string(), + ]), + personality_id: None, + }) + .unwrap(); + let threads = store.list_threads().unwrap(); { let t1 = threads.iter().find(|t| t.id == "t1").unwrap(); @@ -383,10 +399,17 @@ fn store_handles_labels_and_inference() { } { let user = threads.iter().find(|t| t.id == "user-thread").unwrap(); - assert_eq!(user.labels, vec!["work"]); + assert_eq!(user.labels, vec!["general"]); + } + { + let legacy = threads + .iter() + .find(|t| t.id == "legacy-work-thread") + .unwrap(); + assert_eq!(legacy.labels, vec!["general", "urgent"]); } - // 5. Update labels + // 6. Update labels store .update_thread_labels("t1", vec!["updated".to_string()], "2026-04-10T12:05:00Z") .unwrap(); @@ -396,7 +419,7 @@ fn store_handles_labels_and_inference() { assert_eq!(t1.labels, vec!["updated"]); } - // 6. Title update preserves labels + // 7. Title update preserves labels store .update_thread_title("t1", "New Title", "2026-04-10T12:06:00Z") .unwrap(); diff --git a/src/openhuman/threads/ops_tests.rs b/src/openhuman/threads/ops_tests.rs index 503372818..facb0666a 100644 --- a/src/openhuman/threads/ops_tests.rs +++ b/src/openhuman/threads/ops_tests.rs @@ -342,7 +342,7 @@ fn sample_thread() -> ConversationThread { last_message_at: "2026-01-01T00:00:00Z".into(), created_at: "2026-01-01T00:00:00Z".into(), parent_thread_id: None, - labels: vec!["work".to_string()], + labels: vec!["general".to_string()], personality_id: None, } } @@ -368,7 +368,7 @@ fn thread_to_summary_preserves_all_fields() { assert_eq!(summary.message_count, 5); assert_eq!(summary.last_message_at, "2026-01-01T00:00:00Z"); assert_eq!(summary.created_at, "2026-01-01T00:00:00Z"); - assert_eq!(summary.labels, vec!["work".to_string()]); + assert_eq!(summary.labels, vec!["general".to_string()]); } #[test] diff --git a/tests/app_credentials_threads_round24_raw_coverage_e2e.rs b/tests/app_credentials_threads_round24_raw_coverage_e2e.rs index e60f13dc9..72b50e4a3 100644 --- a/tests/app_credentials_threads_round24_raw_coverage_e2e.rs +++ b/tests/app_credentials_threads_round24_raw_coverage_e2e.rs @@ -333,7 +333,7 @@ async fn round24_threads_public_ops_cover_crud_and_error_branches() { .value .data .expect("labels data"); - assert_eq!(relabeled.labels, vec!["work"]); + assert_eq!(relabeled.labels, vec!["general"]); let empty_title = thread_update_title(UpdateConversationThreadTitleRequest { thread_id: created.id.clone(), diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index afb49fe8a..786e4d666 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1825,7 +1825,8 @@ async fn json_rpc_thread_labels_create_and_update() { "created thread should have labels=[\"custom\"]" ); - // 2. Update labels on the thread. + // 2. Update labels on the thread. Legacy "work" input normalizes to + // "general" for backward compatibility with older callers. let update = post_json_rpc( &rpc_base, 9002, @@ -1846,8 +1847,8 @@ async fn json_rpc_thread_labels_create_and_update() { .iter() .map(|v| v.as_str().unwrap_or("")) .collect::>(), - vec!["work", "briefing"], - "updated thread should have labels=[\"work\", \"briefing\"]" + vec!["general", "briefing"], + "updated thread should normalize legacy work label to general" ); // 3. Verify the updated labels are reflected in threads_list. @@ -1873,7 +1874,7 @@ async fn json_rpc_thread_labels_create_and_update() { .iter() .map(|v| v.as_str().unwrap_or("")) .collect::>(), - vec!["work", "briefing"], + vec!["general", "briefing"], "threads_list must reflect the updated labels" );