Merge pull request #52756 from NousResearch/bb/delegate-bg-resume-ux

feat(delegation): calm "will resume" affordance for background delegate_task
This commit is contained in:
brooklyn! 2026-06-25 20:08:06 -05:00 committed by GitHub
commit 41f4dce828
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 301 additions and 2 deletions

View file

@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { $backgroundResume } from '@/store/background-delegation'
import { $compactionActive } from '@/store/compaction'
import type { ComposerAttachment } from '@/store/composer'
import { notifyError } from '@/store/notifications'
@ -243,7 +244,7 @@ export const Thread: FC<{
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : <BackgroundResumeNotice />}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
@ -422,6 +423,36 @@ const ResponseLoadingIndicator: FC = () => {
)
}
// Parked-background affordance: a top-level delegate_task runs in the
// background, so the parent turn ends and the app goes idle while the subagent
// keeps working and its result re-enters as a fresh turn later. Instead of a
// spinner (reads as "stuck"), reuse the same compact, centered system-note
// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
// the thread like every other meta line. Idle-only (gated upstream). Null when
// nothing is parked.
const BackgroundResumeNotice: FC = () => {
const { t } = useI18n()
const resume = useStore($backgroundResume)
if (!resume) {
return null
}
const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
return (
<div
aria-live="polite"
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55"
data-slot="aui_background-resume"
role="status"
>
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
<span className="shimmer min-w-0 truncate">{label}</span>
</div>
)
}
// Seconds of no visible output (text or part count) before a still-running turn
// is treated as stalled and the thinking indicator returns at the tail.
const STREAM_STALL_S = 2

View file

@ -1965,6 +1965,10 @@ export const en: Translations = {
loadingSession: 'Loading session',
showEarlier: 'Show earlier messages',
loadingResponse: 'Hermes is loading a response',
resumeWhenBackgroundDone: count =>
count === 1
? 'Will resume when the background task finishes'
: `Will resume when ${count} background tasks finish`,
thinking: 'Thinking',
today: time => `Today, ${time}`,
yesterday: time => `Yesterday, ${time}`,

View file

@ -2090,6 +2090,10 @@ export const ja = defineLocale({
loadingSession: 'セッションを読み込み中',
showEarlier: '以前のメッセージを表示',
loadingResponse: 'Hermes が応答を読み込み中',
resumeWhenBackgroundDone: count =>
count === 1
? 'バックグラウンドタスクの完了後に再開します'
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
thinking: '考え中',
today: time => `今日 ${time}`,
yesterday: time => `昨日 ${time}`,

View file

@ -1622,6 +1622,7 @@ export interface Translations {
loadingSession: string
showEarlier: string
loadingResponse: string
resumeWhenBackgroundDone: (count: number) => string
thinking: string
today: (time: string) => string
yesterday: (time: string) => string

View file

@ -2027,6 +2027,8 @@ export const zhHant = defineLocale({
loadingSession: '正在載入工作階段',
showEarlier: '顯示較早的訊息',
loadingResponse: 'Hermes 正在載入回覆',
resumeWhenBackgroundDone: count =>
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
thinking: '思考中',
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,

View file

@ -2139,6 +2139,8 @@ export const zh: Translations = {
loadingSession: '正在加载会话',
showEarlier: '显示更早的消息',
loadingResponse: 'Hermes 正在加载回复',
resumeWhenBackgroundDone: count =>
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
thinking: '思考中',
today: time => `今天,${time}`,
yesterday: time => `昨天,${time}`,

View file

@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $backgroundResume } from './background-delegation'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents'
const sub = (over: Partial<SubagentProgress> = {}): SubagentProgress => ({
id: over.id ?? 'deleg:1',
parentId: null,
goal: 'do the thing',
status: 'running',
taskCount: 1,
taskIndex: 0,
startedAt: 0,
updatedAt: 0,
filesRead: [],
filesWritten: [],
stream: [],
...over
})
const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text })
describe('$backgroundResume', () => {
beforeEach(() => {
$busy.set(false)
$activeSessionId.set('s1')
$subagentsBySession.set({})
})
it('counts running/queued children for the active session while idle', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] })
expect($backgroundResume.get()?.count).toBe(2)
})
it('surfaces the primary child latest stream line as live activity', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] })
expect($backgroundResume.get()?.activity).toBe('Searching the web…')
})
it('activity is null when no stream line has arrived (UI uses generic copy)', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
expect($backgroundResume.get()?.activity).toBeNull()
})
it('is null while a turn is busy (the turn owns the main loader)', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
$busy.set(true)
expect($backgroundResume.get()).toBeNull()
})
it('is null when only terminal children or other sessions have work', () => {
$subagentsBySession.set({
s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })],
s2: [sub({ id: 'c' })]
})
expect($backgroundResume.get()).toBeNull()
})
it('is null when there is no active session', () => {
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
$activeSessionId.set(null)
expect($backgroundResume.get()).toBeNull()
})
})

View file

@ -0,0 +1,48 @@
import { computed } from 'nanostores'
import { $activeSessionId, $busy } from './session'
import { $subagentsBySession, type SubagentProgress } from './subagents'
export interface BackgroundResume {
/** Latest live activity from the primary child (its newest stream line), or
* null when nothing readable has arrived yet the UI then falls back to the
* generic "will resume" copy. */
activity: string | null
/** Running/queued background children for the active session. */
count: number
}
const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued'
/**
* "Parked" background-delegation signal for the active session.
*
* A top-level `delegate_task` always runs in the background: the parent turn
* ends (`$busy` -> false) while the subagent keeps running, and its result
* re-enters the conversation as a fresh turn when it finishes. During that
* window the app is genuinely idle but work is still happening elsewhere, so we
* surface a calm, shimmering status line (its latest activity, or a generic
* "will resume" fallback) instead of a spinner that reads as "stuck."
*
* Null while `$busy`: an active turn already owns the main loader, and subagents
* spawned inside a running turn (synchronous orchestrator children) are part of
* that turn, not parked background work the user is waiting on.
*/
export const $backgroundResume = computed(
[$subagentsBySession, $activeSessionId, $busy],
(bySession, sid, busy): BackgroundResume | null => {
if (busy || !sid) {
return null
}
const running = (bySession[sid] ?? []).filter(RUNNING)
if (running.length === 0) {
return null
}
const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null
return { activity, count: running.length }
}
)

View file

@ -334,7 +334,7 @@
/* Paragraph spacing vertical gap between prose paragraphs, both inside a
markdown block and between consecutive prose parts. Single knob; tweak
freely. */
--paragraph-gap: 0.45rem;
--paragraph-gap: 0.7rem;
--sticky-human-top: 0.23rem;
--file-tree-row-height: 1.375rem;
@ -890,6 +890,14 @@ code {
margin-block: var(--paragraph-gap) 0;
}
/* Headings are section breaks, so they own a larger top gap than prose a
leading `# title` (common in re-entered/delegated turns) no longer butts
against the block above. Top-owned + bottom snug; the first-child reset
below still flushes a leading heading. */
[data-slot='aui_assistant-message-content'] .aui-md :where(h1, h2, h3, h4) {
margin-block: 1rem 0.25rem;
}
/* First rendered element of a prose block is flush the block-level gap above
(tool / paragraph) already provides the separation. Reach one level deep too:
Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the

15
cli.py
View file

@ -10499,6 +10499,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _on_tool_complete(self, tool_call_id: str, function_name: str, function_args: dict, function_result: str):
"""Render file edits with inline diff after write-capable tools complete."""
# A top-level delegate_task dispatches in the background and re-enters as
# a fresh turn when done. Say so once — no spinner, nothing to poll — so
# the idle prompt doesn't read as "nothing happened" (⛓ tracks the work).
if function_name == "delegate_task":
try:
parsed = json.loads(function_result) if isinstance(function_result, str) else (function_result or {})
except Exception:
parsed = {}
if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background":
n = parsed.get("count") or 1
noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish")
try:
_cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m")
except Exception:
pass
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
try:
from agent.display import render_edit_diff_with_delta

View file

@ -0,0 +1,69 @@
"""The CLI spells out auto-resume when a delegate_task goes to the background.
A top-level ``delegate_task`` returns a handle immediately and runs the subagent
in the background; the result re-enters the conversation as a fresh turn when it
finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
dispatch so the idle prompt doesn't read as "nothing happened".
"""
import json
import cli
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj._pending_edit_snapshots = {}
return cli_obj
def _capture(monkeypatch):
printed: list[str] = []
monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
return printed
def test_background_dispatch_prints_resume_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
joined = "\n".join(printed)
assert "resume" in joined.lower()
assert "it finishes" in joined
def test_background_batch_dispatch_pluralizes(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
joined = "\n".join(printed)
assert "3 tasks" in joined
assert "they finish" in joined
def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
"""A non-background result (e.g. the stateless sync fallback) must not claim
a background dispatch."""
cli_obj = _make_cli()
printed = _capture(monkeypatch)
result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
assert not any("resume" in p.lower() for p in printed)
def test_non_delegate_tool_prints_no_notice(monkeypatch):
cli_obj = _make_cli()
printed = _capture(monkeypatch)
cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
assert not any("resume" in p.lower() for p in printed)

View file

@ -129,6 +129,41 @@ describe('StatusRule background-subagent indicator', () => {
expect(textContent(element)).not.toContain('⛓')
})
it('spells out the auto-resume hint when idle with subagents in flight', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 1 }
})
expect(textContent(element)).toContain('resumes when subagent finishes')
})
it('pluralizes the resume hint for multiple in-flight subagents', () => {
const element = StatusRule({
...baseProps,
usage: { ...baseProps.usage, active_subagents: 3 }
})
expect(textContent(element)).toContain('resumes when 3 subagents finish')
})
it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
const element = StatusRule({
...baseProps,
busy: true,
turnStartedAt: Date.now(),
usage: { ...baseProps.usage, active_subagents: 2 }
})
expect(textContent(element)).not.toContain('resumes when')
})
it('omits the resume hint when no subagents are running', () => {
const element = StatusRule({ ...baseProps })
expect(textContent(element)).not.toContain('resumes when')
})
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
// (88) too — both gone. Assert the lower-priority subagent indicator is

View file

@ -512,6 +512,15 @@ export function StatusRule({
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`${subagentCount}`))
// Parked-background reassurance: a top-level delegate_task runs in the
// background, so the turn ends (idle) while the subagent keeps working and its
// result re-enters as a fresh turn later. When idle with work still in flight,
// spell out that the agent resumes on its own — no spinner, nothing to poll.
// Width-budgeted like every tail segment, so it drops first on a tight
// terminal where ⛓ already carries the signal.
const resumeHintText =
subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`
const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText))
// Dev-gated readout (HERMES_DEV_CREDITS), lowest priority,
// so it consumes tail budget LAST and drops first on a narrow terminal.
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
@ -624,6 +633,12 @@ export function StatusRule({
{subagentCount}
</Text>
) : null}
{showResumeHint ? (
<Text color={t.color.muted} dim wrap="truncate-end">
{' │ '}
{resumeHintText}
</Text>
) : null}
{showDevCredits ? (
<Text color={t.color.accent} wrap="truncate-end">
{' │ '}