diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 2c14a4632..8f83d1cf2 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'المحادثة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index f8746fd2e..7ccc57814 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'চ্যাট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4064ec303..2e8e44cb7 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 783e335c8..aa1d8d213 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f9d94e3c3..0fb1d0388 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8af1313f9..1a2ae620e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 000382bdf..696b0c133 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'चैट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 890b96aa7..9ae77229c 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 3f3396fa0..672da8cde 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6ab975d28..d713b07e7 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '채팅', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index fdd2110bd..f8de0c6bd 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index b424a61bd..4f2b4a94b 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 906e5ee67..5945dfdfb 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Чат', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 9a73e7483..1e32ffbbb 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '对话', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 640bce73a..8bb225668 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -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(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) && ( )} + {/* 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 && ( + + )} + + + {processes.length === 0 ? ( +
+ {t('conversations.backgroundTasks.empty')} +
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} + + + ); +} diff --git a/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx b/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx new file mode 100644 index 000000000..d7491ceb2 --- /dev/null +++ b/app/src/pages/conversations/components/__tests__/BackgroundProcessesPanel.test.tsx @@ -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 & { 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( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('lists processes and opens one on click', async () => { + const onOpenProcess = vi.fn(); + render( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 594b225ef..2804032e9 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -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 diff --git a/src/openhuman/agent/task_dispatcher/mod.rs b/src/openhuman/agent/task_dispatcher/mod.rs index fbef72323..e4b541438 100644 --- a/src/openhuman/agent/task_dispatcher/mod.rs +++ b/src/openhuman/agent/task_dispatcher/mod.rs @@ -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 { + 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; diff --git a/src/openhuman/agent_orchestration/background_completions.rs b/src/openhuman/agent_orchestration/background_completions.rs new file mode 100644 index 000000000..c31f54659 --- /dev/null +++ b/src/openhuman/agent_orchestration/background_completions.rs @@ -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, +} + +static QUEUE: OnceLock>>> = OnceLock::new(); + +fn queue() -> &'static Mutex>> { + 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, + task_id: impl Into, + agent_id: impl Into, + summary: impl Into, + parent_thread_id: Option, +) { + 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 { + 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 { + 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 +/// `` 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 { + 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\n{}\n\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::>(), + ["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("")); + assert!(notice.contains("Eiffel Tower: built 1889")); + assert!(notice.contains("")); + assert!(notice.contains("")); + } + + #[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); + } +} diff --git a/src/openhuman/agent_orchestration/background_delivery.rs b/src/openhuman/agent_orchestration/background_delivery.rs new file mode 100644 index 000000000..fa22ed80e --- /dev/null +++ b/src/openhuman/agent_orchestration/background_delivery.rs @@ -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> { + static BUSY: OnceLock>> = 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> { + static D: OnceLock>> = 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> { + 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) { + 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> = 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)); + } +} diff --git a/src/openhuman/agent_orchestration/mod.rs b/src/openhuman/agent_orchestration/mod.rs index f928db6d4..f2fcedc43 100644 --- a/src/openhuman/agent_orchestration/mod.rs +++ b/src/openhuman/agent_orchestration/mod.rs @@ -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; diff --git a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs index 18ee7a823..a0d9cb4bf 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_async_subagent.rs @@ -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(),