mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(conversations): background tasks panel + idle-gated batched delivery for detached sub-agents (#3735)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
eaad68ecaa
commit
d50317e521
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Arabic (العربية) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'الرئيسية',
|
||||
'nav.human': 'إنسان',
|
||||
'nav.chat': 'المحادثة',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Bengali (বাংলা) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'হোম',
|
||||
'nav.human': 'হিউম্যান',
|
||||
'nav.chat': 'চ্যাট',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// German (Deutsch) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Start',
|
||||
'nav.human': 'Mensch',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -3396,6 +3396,22 @@ const en: TranslationMap = {
|
||||
'conversations.subagent.working': 'Working…',
|
||||
'conversations.subagent.noOutputYet': 'No output yet',
|
||||
'conversations.subagent.close': 'Close',
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'conversations.subagent.statusRunning': 'running',
|
||||
'conversations.subagent.statusCompleted': 'completed',
|
||||
'conversations.subagent.statusFailed': 'failed',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Spanish (Español) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Inicio',
|
||||
'nav.human': 'Humano',
|
||||
'nav.chat': 'Charla',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// French (Français) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Accueil',
|
||||
'nav.human': 'Humain',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Hindi (हिन्दी) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'होम',
|
||||
'nav.human': 'मानव',
|
||||
'nav.chat': 'चैट',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Indonesian (Bahasa Indonesia) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Beranda',
|
||||
'nav.human': 'Manusia',
|
||||
'nav.chat': 'Obrolan',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Italian (Italiano) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Home',
|
||||
'nav.human': 'Umano',
|
||||
'nav.chat': 'Chat',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Korean (한국어) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': '홈',
|
||||
'nav.human': '휴먼',
|
||||
'nav.chat': '채팅',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Polish (Polski) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Start',
|
||||
'nav.human': 'Człowiek',
|
||||
'nav.chat': 'Czat',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Portuguese (Português) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Início',
|
||||
'nav.human': 'Humano',
|
||||
'nav.chat': 'Bate-papo',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Russian (Русский) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': 'Главная',
|
||||
'nav.human': 'Человек',
|
||||
'nav.chat': 'Чат',
|
||||
|
||||
@@ -3,6 +3,22 @@ import type { TranslationMap } from './types';
|
||||
// Simplified Chinese (简体中文) translations. Keys mirror en.ts; missing/
|
||||
// English-identical values fall back to English via I18nContext.resolveEn().
|
||||
const messages: TranslationMap = {
|
||||
'conversations.backgroundTasks.title': 'Background tasks',
|
||||
'conversations.backgroundTasks.titleWithCount': 'Background tasks ({count})',
|
||||
'conversations.backgroundTasks.running': '{count} running',
|
||||
'conversations.backgroundTasks.noneRunning': 'none running',
|
||||
'conversations.backgroundTasks.total': '{count} total',
|
||||
'conversations.backgroundTasks.empty':
|
||||
'No background tasks in this chat. Ask the assistant to do something in the background and it shows up here.',
|
||||
'conversations.backgroundTasks.statusRunning': 'Running',
|
||||
'conversations.backgroundTasks.statusDone': 'Done',
|
||||
'conversations.backgroundTasks.statusFailed': 'Failed',
|
||||
'conversations.backgroundTasks.statusNeedsYou': 'Needs you',
|
||||
'conversations.backgroundTasks.toolCallOne': '{count} tool call',
|
||||
'conversations.backgroundTasks.toolCallOther': '{count} tool calls',
|
||||
'conversations.backgroundTasks.steps': '{count} steps',
|
||||
'conversations.backgroundTasks.viewDetails': 'View details →',
|
||||
'conversations.backgroundTasks.close': 'Close',
|
||||
'nav.home': '首页',
|
||||
'nav.human': '助手',
|
||||
'nav.chat': '对话',
|
||||
|
||||
@@ -85,6 +85,10 @@ import {
|
||||
BubbleMarkdown,
|
||||
} from './conversations/components/AgentMessageBubble';
|
||||
import { AgentProcessSourcePanel } from './conversations/components/AgentProcessSourcePanel';
|
||||
import {
|
||||
BackgroundProcessesPanel,
|
||||
selectBackgroundProcesses,
|
||||
} from './conversations/components/BackgroundProcessesPanel';
|
||||
import { CitationChips, type MessageCitation } from './conversations/components/CitationChips';
|
||||
import { SubagentDrawer } from './conversations/components/SubagentDrawer';
|
||||
import { TaskKanbanBoard } from './conversations/components/TaskKanbanBoard';
|
||||
@@ -221,6 +225,8 @@ const Conversations = ({
|
||||
// Sub-agent whose full live transcript is open in the drawer, keyed by the
|
||||
// owning timeline row's spawn `taskId`. Null when the drawer is closed.
|
||||
const [openSubagentTaskId, setOpenSubagentTaskId] = useState<string | null>(null);
|
||||
// Detached background sub-agents (spawn_async_subagent) panel visibility.
|
||||
const [showBackgroundProcesses, setShowBackgroundProcesses] = useState(false);
|
||||
// Whether the consolidated "Agent Process Source" panel is open (the full
|
||||
// agent-run timeline + visited sources for the current thread).
|
||||
const [showProcessSource, setShowProcessSource] = useState(false);
|
||||
@@ -1276,6 +1282,12 @@ const Conversations = ({
|
||||
const selectedThreadToolTimeline = selectedThreadId
|
||||
? (toolTimelineByThread[selectedThreadId] ?? [])
|
||||
: [];
|
||||
// Detached background sub-agents (mode === 'async') spawned in this thread.
|
||||
const backgroundProcesses = useMemo(
|
||||
() => selectBackgroundProcesses(selectedThreadToolTimeline),
|
||||
[selectedThreadToolTimeline]
|
||||
);
|
||||
const runningBackgroundCount = backgroundProcesses.filter(p => p.status === 'running').length;
|
||||
// Re-derive the open subagent's live activity (and its row status) from the
|
||||
// timeline on every render so the drawer streams token-by-token as
|
||||
// subagent_text_delta / subagent_thinking_delta events land in Redux.
|
||||
@@ -1767,6 +1779,41 @@ const Conversations = ({
|
||||
{(selectedThreadId ?? firstActiveThreadId) && (
|
||||
<ChatFilesChip threadId={(selectedThreadId ?? firstActiveThreadId) as string} />
|
||||
)}
|
||||
{/* Gated on selectedThreadId alone (not the firstActiveThreadId
|
||||
fallback): the panel/badge derive from selectedThreadToolTimeline,
|
||||
which is [] unless a thread is actually selected, so showing the
|
||||
icon for the fallback would render an always-empty panel. */}
|
||||
{selectedThreadId && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="background-processes-toggle"
|
||||
data-analytics-id="chat-header-background-processes"
|
||||
onClick={() => setShowBackgroundProcesses(true)}
|
||||
aria-label={t('conversations.backgroundTasks.title')}
|
||||
title={
|
||||
backgroundProcesses.length > 0
|
||||
? t('conversations.backgroundTasks.titleWithCount').replace(
|
||||
'{count}',
|
||||
String(backgroundProcesses.length)
|
||||
)
|
||||
: t('conversations.backgroundTasks.title')
|
||||
}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-lg text-stone-500 hover:bg-stone-100 hover:text-stone-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200 transition-colors">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
||||
/>
|
||||
</svg>
|
||||
{runningBackgroundCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[9px] font-semibold leading-none text-white">
|
||||
{runningBackgroundCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="new-thread-button"
|
||||
@@ -2587,6 +2634,15 @@ const Conversations = ({
|
||||
modal={deleteModal}
|
||||
onClose={() => setDeleteModal(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
<BackgroundProcessesPanel
|
||||
open={showBackgroundProcesses}
|
||||
processes={backgroundProcesses}
|
||||
onClose={() => setShowBackgroundProcesses(false)}
|
||||
onOpenProcess={taskId => {
|
||||
setShowBackgroundProcesses(false);
|
||||
setOpenSubagentTaskId(taskId);
|
||||
}}
|
||||
/>
|
||||
<SubagentDrawer
|
||||
subagent={openSubagentEntry?.subagent ?? null}
|
||||
status={openSubagentEntry?.status}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type {
|
||||
SubagentActivity,
|
||||
ToolTimelineEntry,
|
||||
ToolTimelineEntryStatus,
|
||||
} from '../../../store/chatRuntimeSlice';
|
||||
|
||||
/**
|
||||
* A background process = a *detached* sub-agent spawned with
|
||||
* `spawn_async_subagent` (a fire-and-forget tokio task that keeps running after
|
||||
* the parent turn returns). The backend marks these with `mode: "async"` on the
|
||||
* `SubagentSpawned` event (every blocking spawn emits `mode: "typed"`), and the
|
||||
* frontend carries it through on {@link SubagentActivity.mode}. So the whole
|
||||
* "is this truly in the background?" question reduces to `mode === 'async'`.
|
||||
*/
|
||||
export interface BackgroundProcess {
|
||||
taskId: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
status: ToolTimelineEntryStatus;
|
||||
toolCount: number;
|
||||
iterations?: number;
|
||||
}
|
||||
|
||||
const subagentName = (s: SubagentActivity): string =>
|
||||
(s.displayName && s.displayName.trim()) || s.agentId || 'sub-agent';
|
||||
|
||||
/**
|
||||
* Pure selector: the detached background sub-agents spawned in a thread,
|
||||
* newest-relevant first, deduped by spawn `taskId`. Driven off the same tool
|
||||
* timeline the inline rows and the {@link SubagentDrawer} use, so a process
|
||||
* opened here resolves to the exact same drawer entry.
|
||||
*/
|
||||
export function selectBackgroundProcesses(timeline: ToolTimelineEntry[]): BackgroundProcess[] {
|
||||
const seen = new Set<string>();
|
||||
const out: BackgroundProcess[] = [];
|
||||
for (const entry of timeline) {
|
||||
const sub = entry.subagent;
|
||||
if (!sub || sub.mode !== 'async') continue;
|
||||
if (seen.has(sub.taskId)) continue;
|
||||
seen.add(sub.taskId);
|
||||
out.push({
|
||||
taskId: sub.taskId,
|
||||
name: subagentName(sub),
|
||||
goal: (sub.prompt ?? '').trim(),
|
||||
status: entry.status,
|
||||
toolCount: sub.toolCalls?.length ?? 0,
|
||||
iterations: sub.iterations,
|
||||
});
|
||||
}
|
||||
// Running first, so live work stays at the top of the list.
|
||||
return out.sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running'));
|
||||
}
|
||||
|
||||
type StatusLabelKey =
|
||||
| 'conversations.backgroundTasks.statusRunning'
|
||||
| 'conversations.backgroundTasks.statusDone'
|
||||
| 'conversations.backgroundTasks.statusFailed'
|
||||
| 'conversations.backgroundTasks.statusNeedsYou';
|
||||
|
||||
function statusStyle(status: ToolTimelineEntryStatus): {
|
||||
dot: string;
|
||||
labelKey: StatusLabelKey;
|
||||
pill: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return {
|
||||
dot: 'bg-amber-500 animate-pulse',
|
||||
labelKey: 'conversations.backgroundTasks.statusRunning',
|
||||
pill: 'text-amber-700 dark:text-amber-300',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
dot: 'bg-red-500',
|
||||
labelKey: 'conversations.backgroundTasks.statusFailed',
|
||||
pill: 'text-red-700 dark:text-red-300',
|
||||
};
|
||||
case 'awaiting_user':
|
||||
return {
|
||||
dot: 'bg-blue-500',
|
||||
labelKey: 'conversations.backgroundTasks.statusNeedsYou',
|
||||
pill: 'text-blue-700 dark:text-blue-300',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
dot: 'bg-sage-500',
|
||||
labelKey: 'conversations.backgroundTasks.statusDone',
|
||||
pill: 'text-sage-700 dark:text-sage-300',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface BackgroundProcessesPanelProps {
|
||||
open: boolean;
|
||||
processes: BackgroundProcess[];
|
||||
onClose: () => void;
|
||||
onOpenProcess: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right side-drawer listing the thread's detached background sub-agents. Each
|
||||
* row opens the existing {@link SubagentDrawer} (via `onOpenProcess`) for the
|
||||
* full live transcript — this panel is purely the launcher/overview.
|
||||
*/
|
||||
export function BackgroundProcessesPanel({
|
||||
open,
|
||||
processes,
|
||||
onClose,
|
||||
onOpenProcess,
|
||||
}: BackgroundProcessesPanelProps) {
|
||||
const { t } = useT();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const running = processes.filter(p => p.status === 'running').length;
|
||||
const runningLabel =
|
||||
running > 0
|
||||
? t('conversations.backgroundTasks.running').replace('{count}', String(running))
|
||||
: t('conversations.backgroundTasks.noneRunning');
|
||||
const totalLabel = t('conversations.backgroundTasks.total').replace(
|
||||
'{count}',
|
||||
String(processes.length)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end" data-testid="background-processes-panel">
|
||||
<div className="absolute inset-0 bg-stone-900/30 dark:bg-black/50" onClick={onClose} />
|
||||
<aside className="relative flex h-full w-full max-w-sm flex-col bg-white dark:bg-neutral-900 shadow-xl">
|
||||
<header className="flex items-center justify-between border-b border-stone-100 px-4 py-3 dark:border-neutral-800">
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
|
||||
{t('conversations.backgroundTasks.title')}
|
||||
</h2>
|
||||
<span className="text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{runningLabel} · {totalLabel}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('conversations.backgroundTasks.close')}
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-stone-400 hover:bg-stone-100 hover:text-stone-600 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300">
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('conversations.backgroundTasks.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{processes.map(p => {
|
||||
const s = statusStyle(p.status);
|
||||
const toolCallLabel = (
|
||||
p.toolCount === 1
|
||||
? t('conversations.backgroundTasks.toolCallOne')
|
||||
: t('conversations.backgroundTasks.toolCallOther')
|
||||
).replace('{count}', String(p.toolCount));
|
||||
return (
|
||||
<button
|
||||
key={p.taskId}
|
||||
type="button"
|
||||
data-testid="background-process-row"
|
||||
onClick={() => onOpenProcess(p.taskId)}
|
||||
className="mb-1 flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left hover:bg-stone-100 dark:hover:bg-neutral-800">
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-stone-800 dark:text-neutral-100">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className={`shrink-0 text-[11px] font-medium ${s.pill}`}>
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
</span>
|
||||
{p.goal ? (
|
||||
<span className="mt-0.5 line-clamp-2 block text-[12px] text-stone-500 dark:text-neutral-400">
|
||||
{p.goal}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="mt-0.5 block text-[11px] text-stone-400 dark:text-neutral-500">
|
||||
{toolCallLabel}
|
||||
{typeof p.iterations === 'number'
|
||||
? ` · ${t('conversations.backgroundTasks.steps').replace('{count}', String(p.iterations))}`
|
||||
: ''}{' '}
|
||||
· {t('conversations.backgroundTasks.viewDetails')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
SubagentActivity,
|
||||
ToolTimelineEntry,
|
||||
ToolTimelineEntryStatus,
|
||||
} from '../../../../store/chatRuntimeSlice';
|
||||
import {
|
||||
type BackgroundProcess,
|
||||
BackgroundProcessesPanel,
|
||||
selectBackgroundProcesses,
|
||||
} from '../BackgroundProcessesPanel';
|
||||
|
||||
function sub(partial: Partial<SubagentActivity> & { taskId: string }): SubagentActivity {
|
||||
return { agentId: 'researcher', toolCalls: [], ...partial };
|
||||
}
|
||||
|
||||
function entry(
|
||||
status: ToolTimelineEntryStatus,
|
||||
subagent?: SubagentActivity,
|
||||
name = 'subagent:researcher'
|
||||
): ToolTimelineEntry {
|
||||
return { id: `e-${subagent?.taskId ?? name}`, name, round: 0, status, subagent };
|
||||
}
|
||||
|
||||
describe('selectBackgroundProcesses', () => {
|
||||
it('keeps only detached (mode==="async") sub-agents', () => {
|
||||
const timeline: ToolTimelineEntry[] = [
|
||||
entry('running', sub({ taskId: 'sub-async', mode: 'async', prompt: 'research towers' })),
|
||||
entry('running', sub({ taskId: 'sub-typed', mode: 'typed', prompt: 'inline work' })),
|
||||
entry('success', undefined, 'web_search'), // non-subagent tool row
|
||||
];
|
||||
const out = selectBackgroundProcesses(timeline);
|
||||
expect(out.map(p => p.taskId)).toEqual(['sub-async']);
|
||||
expect(out[0].goal).toBe('research towers');
|
||||
});
|
||||
|
||||
it('dedupes by taskId and sorts running first', () => {
|
||||
const timeline: ToolTimelineEntry[] = [
|
||||
entry('success', sub({ taskId: 'sub-done', mode: 'async' })),
|
||||
entry('running', sub({ taskId: 'sub-live', mode: 'async' })),
|
||||
entry('running', sub({ taskId: 'sub-live', mode: 'async' })), // duplicate row
|
||||
];
|
||||
const out = selectBackgroundProcesses(timeline);
|
||||
expect(out.map(p => p.taskId)).toEqual(['sub-live', 'sub-done']); // running first, deduped
|
||||
});
|
||||
|
||||
it('derives name, tool count and steps', () => {
|
||||
const out = selectBackgroundProcesses([
|
||||
entry(
|
||||
'running',
|
||||
sub({
|
||||
taskId: 'sub-1',
|
||||
mode: 'async',
|
||||
displayName: 'Researcher',
|
||||
iterations: 3,
|
||||
toolCalls: [
|
||||
{ callId: 'a', toolName: 't', status: 'success' },
|
||||
{ callId: 'b', toolName: 't', status: 'success' },
|
||||
],
|
||||
})
|
||||
),
|
||||
]);
|
||||
expect(out[0]).toMatchObject({
|
||||
name: 'Researcher',
|
||||
toolCount: 2,
|
||||
iterations: 3,
|
||||
status: 'running',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundProcessesPanel', () => {
|
||||
const procs: BackgroundProcess[] = [
|
||||
{
|
||||
taskId: 'sub-1',
|
||||
name: 'Researcher',
|
||||
goal: 'research the Eiffel Tower',
|
||||
status: 'running',
|
||||
toolCount: 16,
|
||||
},
|
||||
{
|
||||
taskId: 'sub-2',
|
||||
name: 'Archivist',
|
||||
goal: 'summarize notes',
|
||||
status: 'success',
|
||||
toolCount: 4,
|
||||
},
|
||||
];
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<BackgroundProcessesPanel
|
||||
open={false}
|
||||
processes={procs}
|
||||
onClose={vi.fn()}
|
||||
onOpenProcess={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('lists processes and opens one on click', async () => {
|
||||
const onOpenProcess = vi.fn();
|
||||
render(
|
||||
<BackgroundProcessesPanel
|
||||
open
|
||||
processes={procs}
|
||||
onClose={vi.fn()}
|
||||
onOpenProcess={onOpenProcess}
|
||||
/>
|
||||
);
|
||||
const rows = screen.getAllByTestId('background-process-row');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(screen.getByText('Researcher')).toBeInTheDocument();
|
||||
expect(screen.getByText('research the Eiffel Tower')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(rows[0]);
|
||||
expect(onOpenProcess).toHaveBeenCalledWith('sub-1');
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no background tasks', () => {
|
||||
render(
|
||||
<BackgroundProcessesPanel open processes={[]} onClose={vi.fn()} onOpenProcess={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText(/No background tasks in this chat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders every status variant (running / done / failed / needs-you)', () => {
|
||||
const all: BackgroundProcess[] = [
|
||||
{ taskId: 'r', name: 'R', goal: 'g', status: 'running', toolCount: 2 },
|
||||
{ taskId: 'd', name: 'D', goal: 'g', status: 'success', toolCount: 2 },
|
||||
{ taskId: 'e', name: 'E', goal: 'g', status: 'error', toolCount: 2 },
|
||||
{ taskId: 'a', name: 'A', goal: 'g', status: 'awaiting_user', toolCount: 2 },
|
||||
];
|
||||
render(
|
||||
<BackgroundProcessesPanel open processes={all} onClose={vi.fn()} onOpenProcess={vi.fn()} />
|
||||
);
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Needs you')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders singular tool-call wording, step count, and suppresses an empty goal', () => {
|
||||
const rows: BackgroundProcess[] = [
|
||||
{
|
||||
taskId: 'g',
|
||||
name: 'WithGoal',
|
||||
goal: 'investigate the bridge',
|
||||
status: 'success',
|
||||
toolCount: 4,
|
||||
},
|
||||
{ taskId: 's1', name: 'NoGoal', goal: '', status: 'running', toolCount: 1, iterations: 3 },
|
||||
];
|
||||
const { container } = render(
|
||||
<BackgroundProcessesPanel open processes={rows} onClose={vi.fn()} onOpenProcess={vi.fn()} />
|
||||
);
|
||||
expect(container.textContent).toContain('1 tool call'); // singular branch (NoGoal row)
|
||||
expect(container.textContent).toContain('3 steps'); // iterations branch
|
||||
// The goal renders for the row that has one; the goal-less row adds no copy.
|
||||
expect(screen.getAllByText('investigate the bridge')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<BackgroundProcessesPanel open processes={procs} onClose={onClose} onOpenProcess={vi.fn()} />
|
||||
);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2245,6 +2245,11 @@ fn register_domain_subscribers(
|
||||
// calls instead of importing `run_tool_call_loop` directly.
|
||||
crate::openhuman::agent::bus::register_agent_handlers();
|
||||
|
||||
// Background-completion delivery: when a detached sub-agent
|
||||
// (spawn_async_subagent) finishes, surface its result back into the
|
||||
// originating chat as an idle-gated, batched, system-injected turn.
|
||||
crate::openhuman::agent_orchestration::background_delivery::register_background_delivery();
|
||||
|
||||
// MCP clients lifecycle subscriber: logs McpServer{Installed,Connected,
|
||||
// Disconnected} + McpClientToolExecuted for observability. The boot-time
|
||||
// spawn of installed servers (boot::spawn_installed_servers) runs later
|
||||
|
||||
@@ -36,5 +36,22 @@ pub use prompt::build_task_prompt;
|
||||
pub use registry::cancel_session;
|
||||
pub use types::DispatchOutcome;
|
||||
|
||||
/// Run a one-off **system** agent turn on an existing chat thread, streaming the
|
||||
/// result into it like a normal assistant turn (the same web-channel bridge
|
||||
/// cron / welcome agents use). Used by the background-completion delivery
|
||||
/// subsystem to surface a finished detached sub-agent's result back into the
|
||||
/// chat. Best-effort: returns the final response text or an error string.
|
||||
pub async fn run_system_turn_on_thread(
|
||||
thread_id: String,
|
||||
prompt: String,
|
||||
) -> Result<String, String> {
|
||||
let config = crate::openhuman::config::Config::load_or_init()
|
||||
.await
|
||||
.map_err(|e| format!("load config: {e:#}"))?;
|
||||
let executor = executor::resolve_executor(&config.workspace_dir, None);
|
||||
let run_id = format!("bgdeliver-{}", uuid::Uuid::new_v4());
|
||||
executor::run_autonomous(config, &executor, &prompt, &run_id, Some(thread_id)).await
|
||||
}
|
||||
|
||||
// `pub(crate)` for test drivers.
|
||||
pub(crate) use poller::poll_once;
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! Per-session queue of *finished* detached background sub-agents
|
||||
//! (`spawn_async_subagent`) awaiting delivery back into the chat.
|
||||
//!
|
||||
//! A detached sub-agent runs fire-and-forget; when it finishes, its result is
|
||||
//! recorded here keyed by `parent_session`. The delivery subsystem
|
||||
//! ([`super::background_delivery`]) drains the queue **when the session is
|
||||
//! idle** (never mid-turn) and runs a single *system* turn on the parent chat
|
||||
//! thread carrying every result ready at that moment — batched, with each one
|
||||
//! tagged by its sub-agent process id. This module owns only the queue + the
|
||||
//! notice formatting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// One finished background sub-agent's deliverable result.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CompletedBackgroundAgent {
|
||||
/// Spawn process id (`sub-…`) — the tag the agent uses to reference it.
|
||||
pub task_id: String,
|
||||
/// Sub-agent definition id (e.g. `researcher`).
|
||||
pub agent_id: String,
|
||||
/// The sub-agent's final output / summary.
|
||||
pub summary: String,
|
||||
/// Parent chat thread id to stream the delivery turn into (captured at
|
||||
/// spawn). `None` for a headless spawn with no originating thread.
|
||||
pub parent_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
static QUEUE: OnceLock<Mutex<HashMap<String, Vec<CompletedBackgroundAgent>>>> = OnceLock::new();
|
||||
|
||||
fn queue() -> &'static Mutex<HashMap<String, Vec<CompletedBackgroundAgent>>> {
|
||||
QUEUE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Record a finished background sub-agent for later idle delivery, keyed by
|
||||
/// `parent_session`. Idempotent on `task_id` within a session.
|
||||
pub fn record_completion(
|
||||
parent_session: impl Into<String>,
|
||||
task_id: impl Into<String>,
|
||||
agent_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
parent_thread_id: Option<String>,
|
||||
) {
|
||||
let parent_session = parent_session.into();
|
||||
let entry = CompletedBackgroundAgent {
|
||||
task_id: task_id.into(),
|
||||
agent_id: agent_id.into(),
|
||||
summary: summary.into(),
|
||||
parent_thread_id,
|
||||
};
|
||||
let mut map = queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned");
|
||||
let pending = map.entry(parent_session).or_default();
|
||||
if pending.iter().any(|c| c.task_id == entry.task_id) {
|
||||
return;
|
||||
}
|
||||
pending.push(entry);
|
||||
}
|
||||
|
||||
/// Is anything waiting to be delivered for this session? Cheap idle-loop check.
|
||||
pub fn has_pending(parent_session: &str) -> bool {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.get(parent_session)
|
||||
.is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// Number of results pending for a session.
|
||||
pub fn pending_count(parent_session: &str) -> usize {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.get(parent_session)
|
||||
.map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
/// Drain **all** results currently ready for this session — the "batch
|
||||
/// everything ready at that moment" step. Returns them in completion order and
|
||||
/// clears them so they're never re-delivered.
|
||||
pub fn take_pending(parent_session: &str) -> Vec<CompletedBackgroundAgent> {
|
||||
queue()
|
||||
.lock()
|
||||
.expect("background_completions queue poisoned")
|
||||
.remove(parent_session)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The thread id to deliver a batch into — the first record that carries one.
|
||||
pub fn batch_thread_id(completed: &[CompletedBackgroundAgent]) -> Option<String> {
|
||||
completed.iter().find_map(|c| c.parent_thread_id.clone())
|
||||
}
|
||||
|
||||
/// Build the single batched, system-injected notice for a set of finished
|
||||
/// background sub-agents. Each result is wrapped in a
|
||||
/// `<background_agent_result id="…">` tag carrying its sub-agent process id, so
|
||||
/// the agent can reference / present them individually. Returns `None` for an
|
||||
/// empty batch.
|
||||
pub fn build_batched_notice(completed: &[CompletedBackgroundAgent]) -> Option<String> {
|
||||
if completed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = completed.len();
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"[{n} background sub-agent{} finished while you were busy. Review each result \
|
||||
below and present what is relevant to the user. Each is tagged with its \
|
||||
sub-agent process id.]\n",
|
||||
if n == 1 { "" } else { "s" },
|
||||
));
|
||||
for c in completed {
|
||||
let summary = if c.summary.trim().is_empty() {
|
||||
"(no output reported)"
|
||||
} else {
|
||||
c.summary.trim()
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"\n<background_agent_result id=\"{}\" agent=\"{}\">\n{}\n</background_agent_result>\n",
|
||||
c.task_id, c.agent_id, summary,
|
||||
));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c(task: &str, agent: &str, summary: &str) -> CompletedBackgroundAgent {
|
||||
CompletedBackgroundAgent {
|
||||
task_id: task.into(),
|
||||
agent_id: agent.into(),
|
||||
summary: summary.into(),
|
||||
parent_thread_id: Some("thread-1".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_and_drain_is_session_scoped_and_batches() {
|
||||
let s = "sess-batch-A";
|
||||
record_completion(s, "sub-1", "researcher", "eiffel", Some("thread-A".into()));
|
||||
record_completion(s, "sub-2", "researcher", "liberty", Some("thread-A".into()));
|
||||
record_completion("sess-other", "sub-9", "researcher", "x", None);
|
||||
|
||||
assert_eq!(pending_count(s), 2);
|
||||
assert!(has_pending(s));
|
||||
|
||||
let drained = take_pending(s);
|
||||
assert_eq!(
|
||||
drained
|
||||
.iter()
|
||||
.map(|c| c.task_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["sub-1", "sub-2"]
|
||||
);
|
||||
assert_eq!(batch_thread_id(&drained).as_deref(), Some("thread-A"));
|
||||
assert!(!has_pending(s));
|
||||
assert_eq!(take_pending(s), vec![]);
|
||||
assert_eq!(pending_count("sess-other"), 1);
|
||||
take_pending("sess-other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_is_idempotent_on_task_id() {
|
||||
let s = "sess-dupe";
|
||||
record_completion(s, "sub-1", "researcher", "first", None);
|
||||
record_completion(s, "sub-1", "researcher", "second", None);
|
||||
assert_eq!(pending_count(s), 1);
|
||||
take_pending(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_notice_tags_each_with_process_id() {
|
||||
let notice = build_batched_notice(&[
|
||||
c("sub-abc", "researcher", "Eiffel Tower: built 1889 …"),
|
||||
c("sub-def", "researcher", "Colosseum: AD 70–80 …"),
|
||||
])
|
||||
.expect("non-empty batch");
|
||||
|
||||
assert!(notice.contains("2 background sub-agents finished"));
|
||||
assert!(notice.contains("<background_agent_result id=\"sub-abc\" agent=\"researcher\">"));
|
||||
assert!(notice.contains("Eiffel Tower: built 1889"));
|
||||
assert!(notice.contains("<background_agent_result id=\"sub-def\" agent=\"researcher\">"));
|
||||
assert!(notice.contains("</background_agent_result>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singular_wording_and_empty_summary_fallback() {
|
||||
let notice = build_batched_notice(&[c("sub-x", "researcher", " ")]).unwrap();
|
||||
assert!(notice.contains("1 background sub-agent finished"));
|
||||
assert!(notice.contains("(no output reported)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_batch_is_none() {
|
||||
assert_eq!(build_batched_notice(&[]), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Delivery subsystem for finished detached background sub-agents.
|
||||
//!
|
||||
//! Surfaces results recorded in [`super::background_completions`] back into the
|
||||
//! originating chat as a single **system-injected** turn:
|
||||
//! * **idle-gated** — never mid-turn; defers while a user turn is in flight,
|
||||
//! * **debounced** — a burst of completions batches into one turn,
|
||||
//! * **batched** — every result ready at delivery time goes in one turn,
|
||||
//! each tagged by its sub-agent process id.
|
||||
//!
|
||||
//! The turn is run via [`task_dispatcher::run_system_turn_on_thread`], which
|
||||
//! streams it into the thread exactly like a chat turn (the same bridge cron /
|
||||
//! welcome agents use), so it renders in the desktop UI.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
|
||||
|
||||
use super::background_completions;
|
||||
|
||||
/// Coalesce completions landing within this window into one delivery turn.
|
||||
const DEBOUNCE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Sessions with a user turn currently in flight — delivery defers while busy.
|
||||
fn busy() -> &'static Mutex<HashSet<String>> {
|
||||
static BUSY: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
BUSY.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
/// Sessions whose delivery turn is in flight — prevents two concurrent turns.
|
||||
fn delivering() -> &'static Mutex<HashSet<String>> {
|
||||
static D: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
|
||||
D.get_or_init(|| Mutex::new(HashSet::new()))
|
||||
}
|
||||
|
||||
fn is_busy(session: &str) -> bool {
|
||||
busy()
|
||||
.lock()
|
||||
.expect("background_delivery busy poisoned")
|
||||
.contains(session)
|
||||
}
|
||||
|
||||
struct BackgroundDeliveryHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for BackgroundDeliveryHandler {
|
||||
fn name(&self) -> &str {
|
||||
"agent_orchestration::background_delivery"
|
||||
}
|
||||
|
||||
async fn handle(&self, event: &DomainEvent) {
|
||||
match event {
|
||||
DomainEvent::AgentTurnStarted { session_id, .. } => {
|
||||
busy()
|
||||
.lock()
|
||||
.expect("busy poisoned")
|
||||
.insert(session_id.clone());
|
||||
}
|
||||
DomainEvent::AgentTurnCompleted { session_id, .. } => {
|
||||
busy().lock().expect("busy poisoned").remove(session_id);
|
||||
// A user turn just ended — drain anything that finished while it ran.
|
||||
schedule_delivery(session_id.clone(), Duration::from_millis(300));
|
||||
}
|
||||
DomainEvent::AgentError { session_id, .. } => {
|
||||
// A failed turn may not emit AgentTurnCompleted — clear busy so
|
||||
// delivery isn't stuck, then try to drain.
|
||||
busy().lock().expect("busy poisoned").remove(session_id);
|
||||
schedule_delivery(session_id.clone(), Duration::from_millis(300));
|
||||
}
|
||||
DomainEvent::SubagentCompleted { parent_session, .. } => {
|
||||
// Debounce so a burst of completions batches into a single turn.
|
||||
schedule_delivery(parent_session.clone(), DEBOUNCE);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule a debounced delivery attempt for a session.
|
||||
fn schedule_delivery(session: String, delay: Duration) {
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
try_deliver(session).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Snapshot the ready batch for a session **right now** (sync, testable): if the
|
||||
/// session is idle, drain all ready results. Returns `None` (queue untouched)
|
||||
/// when busy or nothing is pending. Headless filtering + delivery happen in the
|
||||
/// caller, which can requeue the batch if the turn fails.
|
||||
fn plan_delivery(session: &str) -> Option<Vec<background_completions::CompletedBackgroundAgent>> {
|
||||
if is_busy(session) {
|
||||
return None;
|
||||
}
|
||||
let batch = background_completions::take_pending(session);
|
||||
if batch.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(batch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-queue a drained batch (after a failed delivery) so it retries on the next
|
||||
/// idle drain rather than being lost.
|
||||
fn requeue(session: &str, batch: Vec<background_completions::CompletedBackgroundAgent>) {
|
||||
for c in batch {
|
||||
background_completions::record_completion(
|
||||
session,
|
||||
c.task_id,
|
||||
c.agent_id,
|
||||
c.summary,
|
||||
c.parent_thread_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain + deliver pending completions for a session — if idle and not already
|
||||
/// delivering. Batches everything ready at this instant into one system turn.
|
||||
async fn try_deliver(session: String) {
|
||||
if is_busy(&session) || !background_completions::has_pending(&session) {
|
||||
return;
|
||||
}
|
||||
// Claim the delivery slot — held for the WHOLE delivery (including the
|
||||
// awaited turn) so a concurrent completion can't start a second delivery
|
||||
// turn on the same thread. Skip if a delivery is already in flight.
|
||||
{
|
||||
let mut d = delivering().lock().expect("delivering poisoned");
|
||||
if !d.insert(session.clone()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(batch) = plan_delivery(&session) {
|
||||
// A user turn can start (AgentTurnStarted -> busy) between plan_delivery's
|
||||
// gate and the awaited turn below. Re-check here so we don't stream a
|
||||
// *system* turn concurrently with a freshly-started user turn on the same
|
||||
// thread — requeue the drained batch and let the next idle drain retry.
|
||||
// (Narrows the window; a turn starting mid-await is still possible, but
|
||||
// both append into the thread and delivery is keyed to its own run id.)
|
||||
if is_busy(&session) {
|
||||
requeue(&session, batch);
|
||||
delivering()
|
||||
.lock()
|
||||
.expect("delivering poisoned")
|
||||
.remove(&session);
|
||||
return;
|
||||
}
|
||||
match (
|
||||
background_completions::batch_thread_id(&batch),
|
||||
background_completions::build_batched_notice(&batch),
|
||||
) {
|
||||
(Some(thread_id), Some(notice)) => {
|
||||
log::info!(
|
||||
"[background_delivery] delivering {} batched background result(s) \
|
||||
session={session} thread_id={thread_id}",
|
||||
batch.len()
|
||||
);
|
||||
if let Err(e) = crate::openhuman::agent::task_dispatcher::run_system_turn_on_thread(
|
||||
thread_id, notice,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"[background_delivery] delivery turn failed session={session} error={e}"
|
||||
);
|
||||
requeue(&session, batch); // don't lose results on a failed turn
|
||||
}
|
||||
}
|
||||
// Headless (no originating thread to stream into) — drop the batch.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Release the slot only AFTER the turn settles.
|
||||
delivering()
|
||||
.lock()
|
||||
.expect("delivering poisoned")
|
||||
.remove(&session);
|
||||
}
|
||||
|
||||
/// Register the delivery subscriber on the global event bus. Keeps the
|
||||
/// subscription alive for the process lifetime. Idempotent.
|
||||
pub fn register_background_delivery() {
|
||||
static HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
|
||||
HANDLE.get_or_init(|| subscribe_global(Arc::new(BackgroundDeliveryHandler)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::openhuman::agent_orchestration::background_completions::record_completion;
|
||||
|
||||
#[test]
|
||||
fn plan_drains_ready_batch_when_idle() {
|
||||
let s = "bd-ready";
|
||||
record_completion(s, "sub-1", "researcher", "alpha", Some("thread-9".into()));
|
||||
record_completion(s, "sub-2", "researcher", "beta", Some("thread-9".into()));
|
||||
|
||||
let batch = plan_delivery(s).expect("plans a delivery");
|
||||
assert_eq!(batch.len(), 2);
|
||||
assert_eq!(
|
||||
background_completions::batch_thread_id(&batch).as_deref(),
|
||||
Some("thread-9")
|
||||
);
|
||||
let notice = background_completions::build_batched_notice(&batch).unwrap();
|
||||
assert!(notice.contains("sub-1") && notice.contains("sub-2"));
|
||||
assert!(!background_completions::has_pending(s)); // drained
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_skips_when_busy_and_leaves_queue_intact() {
|
||||
let s = "bd-busy";
|
||||
record_completion(s, "sub-1", "researcher", "x", Some("t".into()));
|
||||
busy().lock().expect("busy").insert(s.to_string());
|
||||
|
||||
assert!(plan_delivery(s).is_none());
|
||||
assert!(background_completions::has_pending(s)); // NOT drained while busy
|
||||
|
||||
busy().lock().expect("busy").remove(s);
|
||||
let _ = background_completions::take_pending(s); // cleanup
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_none_when_nothing_pending() {
|
||||
assert!(plan_delivery("bd-empty-unique").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headless_batch_has_no_thread_so_caller_drops_it() {
|
||||
let s = "bd-headless";
|
||||
record_completion(s, "sub-1", "researcher", "x", None);
|
||||
let batch = plan_delivery(s).expect("batch present");
|
||||
// No originating thread → batch_thread_id is None, so try_deliver drops it.
|
||||
assert!(background_completions::batch_thread_id(&batch).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requeue_restores_a_failed_batch() {
|
||||
let s = "bd-requeue";
|
||||
record_completion(s, "sub-1", "researcher", "alpha", Some("t".into()));
|
||||
let batch = plan_delivery(s).expect("batch");
|
||||
assert!(!background_completions::has_pending(s)); // drained
|
||||
requeue(s, batch);
|
||||
assert!(background_completions::has_pending(s)); // restored for retry
|
||||
let _ = background_completions::take_pending(s); // cleanup
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleave_recheck_requeues_when_user_turn_starts_after_drain() {
|
||||
// Mirrors try_deliver's M1 guard: a user turn can start between
|
||||
// plan_delivery draining the batch and the awaited system turn. The
|
||||
// re-check must requeue the drained batch rather than stream concurrently.
|
||||
let s = "bd-interleave";
|
||||
record_completion(s, "sub-1", "researcher", "alpha", Some("t".into()));
|
||||
|
||||
let batch = plan_delivery(s).expect("batch drained");
|
||||
assert!(!background_completions::has_pending(s)); // drained
|
||||
|
||||
// User turn starts after the drain, before the (would-be) await.
|
||||
busy().lock().expect("busy").insert(s.to_string());
|
||||
if is_busy(s) {
|
||||
requeue(s, batch); // the guard's action
|
||||
}
|
||||
assert!(background_completions::has_pending(s)); // preserved for next drain
|
||||
|
||||
busy().lock().expect("busy").remove(s);
|
||||
let _ = background_completions::take_pending(s); // cleanup
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_tracks_busy_across_turn_and_error_events() {
|
||||
let h = BackgroundDeliveryHandler;
|
||||
let sid = "bd-turn".to_string();
|
||||
|
||||
h.handle(&DomainEvent::AgentTurnStarted {
|
||||
session_id: sid.clone(),
|
||||
channel: "test".into(),
|
||||
})
|
||||
.await;
|
||||
assert!(is_busy(&sid));
|
||||
|
||||
h.handle(&DomainEvent::AgentTurnCompleted {
|
||||
session_id: sid.clone(),
|
||||
text_chars: 0,
|
||||
iterations: 0,
|
||||
})
|
||||
.await;
|
||||
assert!(!is_busy(&sid));
|
||||
|
||||
// A failed turn (AgentError) must also clear busy so delivery isn't stuck.
|
||||
busy().lock().expect("busy").insert(sid.clone());
|
||||
h.handle(&DomainEvent::AgentError {
|
||||
session_id: sid.clone(),
|
||||
message: "boom".into(),
|
||||
recoverable: true,
|
||||
})
|
||||
.await;
|
||||
assert!(!is_busy(&sid));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! construction, tool filtering, and the actual sub-agent run loop.
|
||||
|
||||
pub mod agent_teams;
|
||||
pub mod background_completions;
|
||||
pub mod background_delivery;
|
||||
pub mod command_center;
|
||||
mod ops;
|
||||
pub(crate) mod parent_context;
|
||||
|
||||
@@ -242,6 +242,10 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
let background_parent_session = parent_session.clone();
|
||||
let background_progress = progress_sink.clone();
|
||||
let background_worker_thread_id = worker_thread_id.clone();
|
||||
// Capture the parent chat thread NOW (the spawning turn's thread) so the
|
||||
// finished result can be delivered back into it as a system turn.
|
||||
let background_parent_thread_id =
|
||||
crate::openhuman::inference::provider::thread_context::current_thread_id();
|
||||
let background_prompt = add_background_contract(&prompt);
|
||||
|
||||
let join = tokio::spawn(async move {
|
||||
@@ -271,6 +275,16 @@ impl Tool for SpawnAsyncSubagentTool {
|
||||
output: outcome.output.clone(),
|
||||
iterations: outcome.iterations,
|
||||
});
|
||||
// Queue the finished result for idle-gated, batched
|
||||
// delivery back into the parent chat (the session
|
||||
// runtime drains this when the session is next idle).
|
||||
crate::openhuman::agent_orchestration::background_completions::record_completion(
|
||||
background_parent_session.clone(),
|
||||
outcome.task_id.clone(),
|
||||
outcome.agent_id.clone(),
|
||||
outcome.output.clone(),
|
||||
background_parent_thread_id.clone(),
|
||||
);
|
||||
publish_global(DomainEvent::SubagentCompleted {
|
||||
parent_session: background_parent_session,
|
||||
task_id: outcome.task_id.clone(),
|
||||
|
||||
Reference in New Issue
Block a user