mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(chat): rename default thread label to general (#3319)
This commit is contained in:
@@ -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': 'العمال',
|
||||
|
||||
@@ -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': 'ওয়ার্কার',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'वर्कर्स',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '워커',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Воркеры',
|
||||
|
||||
@@ -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': '工作线程',
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [isPlayingReply, setIsPlayingReply] = useState(false);
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>('all');
|
||||
const [selectedLabel, setSelectedLabel] = useState<string>(GENERAL_TAB_VALUE);
|
||||
const [inlineSuggestionValue, setInlineSuggestionValue] = useState('');
|
||||
const [sendError, setSendError] = useState<ChatSendError | null>(null);
|
||||
const [attachError, setAttachError] = useState<ChatSendError | null>(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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
@@ -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)}
|
||||
</p>
|
||||
) : (
|
||||
sortedThreads.map(thread => (
|
||||
|
||||
@@ -181,7 +181,7 @@ function makeThread(overrides: Partial<Thread> = {}): 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)?;
|
||||
|
||||
@@ -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<String> {
|
||||
} 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<String>) -> Vec<String> {
|
||||
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<T>(path: &Path) -> Result<Vec<T>, String>
|
||||
where
|
||||
T: for<'de> serde::Deserialize<'de>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<_>>(),
|
||||
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<_>>(),
|
||||
vec!["work", "briefing"],
|
||||
vec!["general", "briefing"],
|
||||
"threads_list must reflect the updated labels"
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user