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:
sanil-23
2026-06-17 15:10:17 +05:30
committed by GitHub
co-authored by Claude Opus 4.8
parent eaad68ecaa
commit d50317e521
23 changed files with 1212 additions and 0 deletions
+16
View File
@@ -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': 'المحادثة',
+16
View File
@@ -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': 'চ্যাট',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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': 'चैट',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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': '채팅',
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -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': 'Чат',
+16
View File
@@ -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': '对话',
+56
View File
@@ -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();
});
});
+5
View File
@@ -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 7080 …"),
])
.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));
}
}
+2
View File
@@ -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(),