From a1a9ba9fb94748f34dd15c9b2c48eed0bcd3551d Mon Sep 17 00:00:00 2001 From: raullopez-sandbox Date: Fri, 4 Sep 2026 12:09:29 +0200 Subject: [PATCH 1/3] feat(core): report and render subagent activity per platform Parent agents can now report their active subagents with runtime state (POST /agents/{id}/runtime-state, active_subagents, max 50). The list is stored on agent_runtime_states with the same preserve-on-omit rule as control_capabilities, carried on AgentRuntimeStateEvent, and rendered under the working indicator by every collaboration adapter with platform-specific escaping and limits. Also stop tracking internal/ and .claude/worktrees. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tq1pDDfir5c96Lzzc3txk1 --- .gitignore | 4 + .../core/agent-hooks/agent-hook-service.ts | 7 + .../core/agent-hooks/event-enricher.test.ts | 73 ++++- .../main/core/agent-hooks/event-enricher.ts | 33 ++ .../core/switch-rooms/room-connection.test.ts | 111 +++++++ .../main/core/switch-rooms/room-connection.ts | 44 +++ .../switch-notification-poller.ts | 13 + .../core/switch-rooms/subagent-activity.ts | 22 ++ .../src/sidecar/sidecar-runtime.test.ts | 1 + .../src/sidecar/sidecar-runtime.ts | 7 + .../switch_core/bridges/agent/api/handlers.py | 5 + core/switch_core/bridges/agent/api/schemas.py | 12 +- .../bridges/agent/protocol/service.py | 5 + .../bridges/collaboration/adapter.py | 88 +++++- .../bridges/collaboration/bridge_core.py | 1 + .../bridges/collaboration/discord/adapter.py | 15 +- .../collaboration/mattermost/adapter.py | 20 +- .../bridges/collaboration/slack/adapter.py | 21 +- .../bridges/collaboration/teams/adapter.py | 17 +- .../bridges/collaboration/telegram/adapter.py | 10 +- core/switch_core/db/models.py | 1 + .../db/stores/agent_runtime_state_store.py | 6 + core/switch_core/events.py | 15 + .../f8a1b2c3d4e5_add_active_subagents.py | 29 ++ .../agent/api/test_runtime_state_request.py | 49 +++ .../test_bridge_runtime_state_thread.py | 1 + .../collaboration/test_subagent_indicator.py | 287 ++++++++++++++++++ .../stores/test_agent_runtime_state_store.py | 121 ++++++++ 28 files changed, 1005 insertions(+), 13 deletions(-) create mode 100644 console/apps/switch-console-desktop/src/shared/core/switch-rooms/subagent-activity.ts create mode 100644 core/switch_core/migrations/versions/f8a1b2c3d4e5_add_active_subagents.py create mode 100644 core/tests/switch_core/bridges/agent/api/test_runtime_state_request.py create mode 100644 core/tests/switch_core/bridges/collaboration/test_subagent_indicator.py create mode 100644 core/tests/switch_core/db/stores/test_agent_runtime_state_store.py diff --git a/.gitignore b/.gitignore index be828358b..fb2c89c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -243,3 +243,7 @@ download.xml # ships with a placeholder app id on purpose; a built package carries a real # tenant's, and this directory is where people naturally build one. docs/bridges/teams-app/*.zip + +# Local-only tooling and agent worktrees +internal/ +.claude/worktrees/ diff --git a/console/apps/switch-console-desktop/src/main/core/agent-hooks/agent-hook-service.ts b/console/apps/switch-console-desktop/src/main/core/agent-hooks/agent-hook-service.ts index be78b58be..050602e98 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-hooks/agent-hook-service.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-hooks/agent-hook-service.ts @@ -126,6 +126,13 @@ class AgentHookService implements IInitializable, IDisposable, Hookable ctx; @@ -238,6 +238,75 @@ describe('parseHookEvent', () => { ); }); + /** + * The Claude connector's SubagentStart / SubagentStop hooks. They are the only + * signal that a delegation started or finished — the Agent tool call itself + * names no target — so both the activity line and the live subagent list are + * derived from them. + */ + it('parses a SubagentStart hook into a subagent event', async () => { + const parsed = await parseHookEvent( + raw('subagent', { agent_id: 'sub-1', agent_type: 'Explore' }), + fixedResolver, + log + ); + + expect(parsed).toEqual({ + kind: 'subagent', + ctx, + agentId: 'sub-1', + agentName: 'Explore', + finished: false, + detail: '_Delegating to_ `Explore`', + }); + }); + + it('parses a SubagentStop hook as finished', async () => { + const parsed = await parseHookEvent( + raw('subagent-done', { agent_id: 'sub-1', agent_type: 'Explore' }), + fixedResolver, + log + ); + + expect(parsed).toEqual({ + kind: 'subagent', + ctx, + agentId: 'sub-1', + agentName: 'Explore', + finished: true, + detail: '_Subagent_ `Explore` _finished_', + }); + }); + + it('falls back to the agent type, then to a placeholder, for an unnamed subagent', async () => { + const typed = await parseHookEvent( + raw('subagent', { agent_type: 'Explore' }), + fixedResolver, + log + ); + expect(typed).toMatchObject({ kind: 'subagent', agentId: 'Explore', agentName: 'Explore' }); + + const bare = await parseHookEvent(raw('subagent', {}), fixedResolver, log); + expect(bare).toMatchObject({ + kind: 'subagent', + agentId: 'subagent', + agentName: 'subagent', + detail: '_Delegating to a subagent_', + }); + }); + + it('leaves an Agent tool call alone — it names no subagent', async () => { + // The Agent tool's input carries no id for the subagent it starts, so a + // tool-use hook must not be read as one. + const parsed = await parseHookEvent( + raw('tool-use', { tool_name: 'Task', tool_input: { subagent_type: 'Explore' } }), + fixedResolver, + log + ); + + expect(parsed).toEqual({ kind: 'ignore' }); + }); + it('throws when the context resolver cannot resolve the ptyId', async () => { const nullResolver: ContextResolver = async () => null; await expect(parseHookEvent(raw('Stop', {}), nullResolver, log)).rejects.toThrow( diff --git a/console/apps/switch-console-desktop/src/main/core/agent-hooks/event-enricher.ts b/console/apps/switch-console-desktop/src/main/core/agent-hooks/event-enricher.ts index 9c67c7244..e00f0d2e1 100644 --- a/console/apps/switch-console-desktop/src/main/core/agent-hooks/event-enricher.ts +++ b/console/apps/switch-console-desktop/src/main/core/agent-hooks/event-enricher.ts @@ -28,6 +28,14 @@ export type ParsedHookEvent = agentId: string; roomName: string | null; } + | { + kind: 'subagent'; + ctx: AgentHookContext; + agentId: string; + agentName: string; + finished: boolean; + detail: string; + } | { kind: 'ignore' }; /** @@ -57,6 +65,18 @@ export interface HookEventLogger { */ const SWITCH_ROOM_CONNECT_EVENT = 'switch_room_connect'; +/** + * Event types the Claude connector's `SubagentStart` / `SubagentStop` hooks + * report. Their body carries `agent_id` and `agent_type`; no other provider + * emits them. + */ +const SUBAGENT_START_EVENT = 'subagent'; +const SUBAGENT_DONE_EVENT = 'subagent-done'; + +function isSubagentEvent(type: string): boolean { + return type === SUBAGENT_START_EVENT || type === SUBAGENT_DONE_EVENT; +} + /** The value as a plain object, or null for anything else. */ function asRecord(value: unknown): Record | null { if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; @@ -197,6 +217,19 @@ export async function parseHookEvent( const parser = plugin?.behavior.hooks?.parseHookEvent ?? defaultHookEventParser; const canonical = parser(raw.type, body); + if (isSubagentEvent(raw.type) && canonical.kind === 'activity') { + const agentType = typeof body.agent_type === 'string' ? body.agent_type.trim() : ''; + const agentId = typeof body.agent_id === 'string' ? body.agent_id.trim() : ''; + return { + kind: 'subagent', + ctx, + agentId: agentId || agentType || 'subagent', + agentName: agentType || 'subagent', + finished: raw.type === SUBAGENT_DONE_EVENT, + detail: canonical.detail, + }; + } + if (canonical.kind === 'ignore') return { kind: 'ignore' }; if (canonical.kind === 'session') { diff --git a/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.test.ts index d40fd01df..4dd9b9a91 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.test.ts @@ -122,6 +122,13 @@ function runtimeDetails(fetchMock: ReturnType): (string | null .map((c) => JSON.parse((c[1] as RequestInit).body as string).detail); } +/** The `active_subagents` list carried on each runtime-state post. */ +function runtimeSubagents(fetchMock: ReturnType): unknown[][] { + return fetchMock.mock.calls + .filter((c) => String(c[0]).includes('/runtime-state')) + .map((c) => JSON.parse((c[1] as RequestInit).body as string).active_subagents); +} + function runtimeAnchors(fetchMock: ReturnType): (string | null)[] { return fetchMock.mock.calls .filter((c) => String(c[0]).includes('/runtime-state')) @@ -749,6 +756,110 @@ describe('RoomConnection', () => { conn.stop(); }); + /** + * Subagents a session delegates to (CHOO-2555). Claude Code's + * SubagentStart/SubagentStop hooks are the only signal that one is live, and + * each hook carries both surfaces — the activity line and the list — so it + * reports the turn once rather than twice. + */ + it('reports a spawned subagent in one post, and drops it in one more', async () => { + const target: InjectionTarget = { write: vi.fn() }; + const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]); + await flush(); + const before = runtimeSubagents(fetchMock).length; + + conn.reportSubagent({ + agentId: 'sub-1', + agentName: 'Explore', + finished: false, + detail: '_Delegating to_ `Explore`', + }); + await flush(); + + expect(runtimeSubagents(fetchMock).length).toBe(before + 1); + expect(runtimeSubagents(fetchMock).at(-1)).toEqual([ + { agent_id: 'sub-1', agent_name: 'Explore', state: 'working', detail: null }, + ]); + expect(runtimeDetails(fetchMock).at(-1)).toMatch(/^_Delegating to_ `Explore`/); + + conn.reportSubagent({ + agentId: 'sub-1', + agentName: 'Explore', + finished: true, + detail: '_Subagent_ `Explore` _finished_', + }); + await flush(); + + expect(runtimeSubagents(fetchMock).length).toBe(before + 2); + expect(runtimeSubagents(fetchMock).at(-1)).toEqual([]); + expect(runtimeDetails(fetchMock).at(-1)).toMatch(/^_Subagent_ `Explore` _finished_/); + conn.stop(); + }); + + it('reports every live subagent, keyed by id', async () => { + const target: InjectionTarget = { write: vi.fn() }; + const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]); + await flush(); + + conn.reportSubagent({ + agentId: 'sub-1', + agentName: 'Explore', + finished: false, + detail: '_Delegating to_ `Explore`', + }); + conn.reportSubagent({ + agentId: 'sub-2', + agentName: 'Plan', + finished: false, + detail: '_Delegating to_ `Plan`', + }); + await flush(); + + expect(runtimeSubagents(fetchMock).at(-1)).toEqual([ + { agent_id: 'sub-1', agent_name: 'Explore', state: 'working', detail: null }, + { agent_id: 'sub-2', agent_name: 'Plan', state: 'working', detail: null }, + ]); + conn.stop(); + }); + + it('clears the subagents when the turn ends', async () => { + const target: InjectionTarget = { write: vi.fn() }; + const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]); + await flush(); + conn.reportSubagent({ + agentId: 'sub-1', + agentName: 'Explore', + finished: false, + detail: '_Delegating to_ `Explore`', + }); + await flush(); + + conn.onAgentStatusChange('idle'); + await flush(); + + expect(runtimeSubagents(fetchMock).at(-1)).toEqual([]); + conn.stop(); + }); + + it('does not push a subagent outside a working room turn', async () => { + const target: InjectionTarget = { write: vi.fn() }; + // No addressed message → no active turn. + const { conn, fetchMock } = connect({ acquire: () => target }, []); + await flush(); + const before = runtimeSubagents(fetchMock).length; + + conn.reportSubagent({ + agentId: 'sub-1', + agentName: 'Explore', + finished: false, + detail: '_Delegating to_ `Explore`', + }); + await flush(); + + expect(runtimeSubagents(fetchMock).length).toBe(before); + conn.stop(); + }); + it('executes an interrupt command as a raw ESC keystroke, not injected text', async () => { const target: InjectionTarget = { write: vi.fn() }; const { conn } = connect({ acquire: () => target }, [commandEvent('interrupt')]); diff --git a/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.ts b/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.ts index f31f8ddd1..8870dff5c 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-rooms/room-connection.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { SwitchEventStream } from '@sandboxaq/switch-agent-runtime'; import type { AgentStatus, NotificationType } from '@shared/core/providers/agentEvents'; +import type { SubagentActivity } from '@shared/core/switch-rooms/subagent-activity'; import type { InjectionSink } from './injection-sink'; import type { SessionControl } from './session-control'; import { buildSessionDeeplink } from './session-deeplink'; @@ -59,6 +60,9 @@ const MAX_ACTIVITY_REPORT_FAILURES = 5; // work that finished long ago. Set well past any real turn, so reaching it is // evidence of a bug rather than of a slow agent. export const MAX_ROOM_TURN_MS = 4 * 60 * 60 * 1000; +// The server refuses a runtime-state report carrying more subagents than this, +// so a turn that delegates more widely reports the first of them. +const MAX_REPORTED_SUBAGENTS = 50; export type SwitchCredentials = { agentId: string; apiEndpoint: string; token: string }; @@ -300,6 +304,12 @@ export class RoomConnection { private currentAnchorId: string | null = null; /** Last activity line (without the elapsed suffix), to skip redundant refreshes. */ private lastActivityDetail: string | null = null; + /** + * Subagents the session has spawned and not yet finished, keyed by the id the + * hook reported. Per-turn: reported with runtime state while the turn works, + * and cleared when it ends. + */ + private readonly subagents = new Map(); /** Monotonic timestamp the current working turn began, for the elapsed suffix. */ private workingStartedAt = 0; /** Ticker that re-pushes the activity line with a refreshed elapsed suffix. */ @@ -558,6 +568,7 @@ export class RoomConnection { stop(): void { if (this.stopped) return; this.stopped = true; + this.subagents.clear(); // Clear any lingering runtime-state surface before aborting (the abort // signal would cancel the request, so fire it unsignalled and best-effort). // The server's heartbeat-expiry sweep is the backstop if this never lands. @@ -638,6 +649,7 @@ export class RoomConnection { threadId, detached: opts.detached ?? false, }); + const resp = await this.fetchWithTimeout( `${this.creds.apiEndpoint}/agents/${this.creds.agentId}/runtime-state`, { @@ -657,6 +669,7 @@ export class RoomConnection { deeplink_url: this.sessionDeeplink(), detail: detail ?? null, control_capabilities: this.control.capabilities, + active_subagents: [...this.subagents.values()].slice(0, MAX_REPORTED_SUBAGENTS), }), }, RUNTIME_STATE_REQUEST_TIMEOUT_MS, @@ -908,6 +921,7 @@ export class RoomConnection { // A fresh state clears the activity line: a new "working" turn starts from // the generic indicator, and idle/awaiting-input carry no activity. this.lastActivityDetail = null; + if (state !== 'working') this.subagents.clear(); if (state === 'working') { if (!wasWorking) { this.workingStartedAt = Date.now(); @@ -946,6 +960,36 @@ export class RoomConnection { this.pushActivity(); } + /** + * A subagent this session spawned started or finished. One hook carries both + * surfaces — the activity line naming the delegation and the list of the + * subagents still running — so both are set here and pushed together, rather + * than posting the same turn twice. Pushed straight away: a spawn and a + * finish are the moments worth showing, and the ticker would show them late. + */ + reportSubagent(ev: { + agentId: string; + agentName: string; + finished: boolean; + detail: string; + }): void { + if (this.stopped) return; + if (ev.finished) { + this.subagents.delete(ev.agentId); + } else { + this.subagents.set(ev.agentId, { + agent_id: ev.agentId, + agent_name: ev.agentName, + state: 'working', + detail: null, + }); + } + if (!this.roomTurnActive || this.runtimeState !== 'working') return; + const trimmed = ev.detail.trim(); + if (trimmed) this.lastActivityDetail = trimmed; + this.pushActivity(); + } + /** Compose the activity line with a live elapsed suffix, e.g. "…foo.py · 15s". * Before any per-turn activity is reported, falls back to the generic * "working on it…" phrase so the elapsed timer still ticks from the start of diff --git a/console/apps/switch-console-desktop/src/main/core/switch-rooms/switch-notification-poller.ts b/console/apps/switch-console-desktop/src/main/core/switch-rooms/switch-notification-poller.ts index 278e7ce92..a6b71e22a 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-rooms/switch-notification-poller.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-rooms/switch-notification-poller.ts @@ -352,6 +352,19 @@ class SwitchNotificationPoller { if (!connection) return; connection.reportActivity(detail); } + + /** + * A subagent of this session started or finished, so the room can name the + * ones still running alongside the activity line. + */ + onSubagent( + sessionId: string, + ev: { agentId: string; agentName: string; finished: boolean; detail: string } + ): void { + const connection = this.connections.get(sessionId); + if (!connection) return; + connection.reportSubagent(ev); + } } export const switchNotificationPoller = new SwitchNotificationPoller(); diff --git a/console/apps/switch-console-desktop/src/shared/core/switch-rooms/subagent-activity.ts b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/subagent-activity.ts new file mode 100644 index 000000000..fe75c9011 --- /dev/null +++ b/console/apps/switch-console-desktop/src/shared/core/switch-rooms/subagent-activity.ts @@ -0,0 +1,22 @@ +/** + * Subagent activity tracking types. + * + * These types match the backend schema for reporting active subagent state + * as part of agent runtime state updates. + */ + +/** + * The current state of a subagent spawned by the primary agent. + */ +export type SubagentState = 'working' | 'awaiting-input' | 'idle' | 'complete' | 'failed'; + +/** + * Activity information for a single subagent, reported to the backend + * as part of the agent's runtime state. + */ +export interface SubagentActivity { + agent_id: string; + agent_name: string; + state: SubagentState; + detail: string | null; +} diff --git a/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.test.ts b/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.test.ts index 0db590abe..d1d61f3b3 100644 --- a/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.test.ts +++ b/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.test.ts @@ -26,6 +26,7 @@ function fakeConnection(): ManagedConnection { stop: vi.fn(), onAgentStatusChange: vi.fn(), reportActivity: vi.fn(), + reportSubagent: vi.fn(), connection: 'conn-1', }; } diff --git a/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.ts b/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.ts index 64a81a780..d09438f05 100644 --- a/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.ts +++ b/console/apps/switch-console-desktop/src/sidecar/sidecar-runtime.ts @@ -28,6 +28,7 @@ export interface ManagedConnection { detail?: Parameters[2] ): void; reportActivity(detail: string): void; + reportSubagent(ev: Parameters[0]): void; /** The connection id this session's tool calls are expected to arrive on. */ readonly connection: string; } @@ -197,6 +198,12 @@ export class SidecarRuntime { session?.connection.reportActivity(parsed.detail); return; } + + if (parsed.kind === 'subagent') { + const session = this.sessions.get(parsed.ctx.sessionId); + session?.connection.reportSubagent(parsed); + return; + } // 'session' | 'ignore' → no-op: the VM persists no provider-session id and // has no database to update. } diff --git a/core/switch_core/bridges/agent/api/handlers.py b/core/switch_core/bridges/agent/api/handlers.py index da877182a..fe42334af 100644 --- a/core/switch_core/bridges/agent/api/handlers.py +++ b/core/switch_core/bridges/agent/api/handlers.py @@ -627,6 +627,11 @@ async def set_runtime_state( detail=req.detail, control_capabilities=req.control_capabilities, anchor_event_id=req.anchor_event_id, + active_subagents=( + [sa.model_dump() for sa in req.active_subagents] + if req.active_subagents is not None + else None + ), ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from e diff --git a/core/switch_core/bridges/agent/api/schemas.py b/core/switch_core/bridges/agent/api/schemas.py index 31343fe47..1acd43b42 100644 --- a/core/switch_core/bridges/agent/api/schemas.py +++ b/core/switch_core/bridges/agent/api/schemas.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field from switch_core.bridges.agent.protocol.types import ( AgentEvent, @@ -140,6 +140,13 @@ class StatusRequest(BaseModel): detail: str | None = None +class SubagentActivity(BaseModel): + agent_id: str + agent_name: str + state: Literal["working", "awaiting-input", "idle", "complete", "failed"] + detail: str | None = None + + class RuntimeStateRequest(BaseModel): room_id: str # The canonical runtime state. Connectors map provider-specific states onto @@ -170,6 +177,9 @@ class RuntimeStateRequest(BaseModel): # Switch Console for sessions it controls; null for connectors that can't be # controlled (a session_dependent command then resolves to unsupported). control_capabilities: dict[str, bool] | None = None + # Active subagents spawned by this agent, with their current state and + # activity. Null means "unchanged" — the last reported list is preserved. + active_subagents: list[SubagentActivity] | None = Field(default=None, max_length=50) # ── Resources ───────────────────────────────────────────────────────────────── diff --git a/core/switch_core/bridges/agent/protocol/service.py b/core/switch_core/bridges/agent/protocol/service.py index 8673462ca..7368c5953 100644 --- a/core/switch_core/bridges/agent/protocol/service.py +++ b/core/switch_core/bridges/agent/protocol/service.py @@ -1307,6 +1307,7 @@ async def set_runtime_state( detail: str | None = None, control_capabilities: dict[str, bool] | None = None, anchor_event_id: str | None = None, + active_subagents: list[dict[str, Any]] | None = None, ) -> None: """Record and broadcast an agent's runtime state in a room. @@ -1368,6 +1369,7 @@ async def set_runtime_state( state, deeplink_url=deeplink_url, control_capabilities=control_capabilities, + active_subagents=active_subagents, ) await session.commit() @@ -1384,6 +1386,7 @@ async def set_runtime_state( deeplink_url=deeplink_url, detail=detail, anchor_event_id=anchor_event_id, + active_subagents=active_subagents, ) async def _emit_runtime_state( @@ -1399,6 +1402,7 @@ async def _emit_runtime_state( deeplink_url: str | None = None, detail: str | None = None, anchor_event_id: str | None = None, + active_subagents: list[dict[str, Any]] | None = None, ) -> None: client = self.client_lifecycle.get_by_agent_id(agent_id) if client is None or client.nio_client is None: @@ -1419,6 +1423,7 @@ async def _emit_runtime_state( "deeplink_url": deeplink_url, "detail": detail, "anchor_event_id": anchor_event_id, + "active_subagents": active_subagents, }, ) diff --git a/core/switch_core/bridges/collaboration/adapter.py b/core/switch_core/bridges/collaboration/adapter.py index 47f2629f9..2ba61e509 100644 --- a/core/switch_core/bridges/collaboration/adapter.py +++ b/core/switch_core/bridges/collaboration/adapter.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import html import logging from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable @@ -20,6 +21,7 @@ InboundUserJoin, OutboundAttachment, ) +from switch_core.events import ActiveSubagent logger = logging.getLogger(__name__) @@ -406,6 +408,7 @@ async def apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Serialise against any other runtime-indicator work for this agent, then apply the state. Adapters override ``_apply_runtime_state``.""" @@ -420,6 +423,7 @@ async def apply_runtime_state( detail=detail, trigger_thread_root_id=trigger_thread_root_id, anchor_message_ref=anchor_message_ref, + active_subagents=active_subagents, ) async def reposition_runtime_state( @@ -443,6 +447,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Surface a Switch Console-managed agent's runtime state on the channel. @@ -568,16 +573,89 @@ def _deeplink_suffix(deeplink_url: str | None) -> str: return "" return f" ([Open in Switch Console]({deeplink_url}))" - def _working_body(self, detail: str | None, deeplink_url: str | None) -> str: + def _working_body( + self, + detail: str | None, + deeplink_url: str | None, + active_subagents: list[ActiveSubagent] | None = None, + ) -> str: """The "working on it…" status text, rendered for this platform. Uses the connector-supplied `detail` (e.g. "Editing foo.py") as the live activity line when present, falling back to the generic phrase. The - deeplink is appended as a trailing link either way.""" + deeplink is appended as a trailing link either way. Any active + subagents are listed underneath.""" activity = detail.strip() if detail and detail.strip() else "_Working on it…_" - return self.translate_outbound( - f"⚙️ {activity}" + self._deeplink_suffix(deeplink_url) - ) + body = f"⚙️ {activity}" + self._deeplink_suffix(deeplink_url) + + subagent_list = self._format_subagents(active_subagents) + if subagent_list: + body = f"{body}\n{subagent_list}" + + return self.translate_outbound(body) + + def _escape_text(self, text: str) -> str: + """Escape user text for safe rendering. + + Base implementation uses HTML escaping. Platform-specific adapters + override this for their own escaping needs.""" + return html.escape(text) + + _STATE_EMOJIS: ClassVar[dict[str, str]] = { + "working": "⚙️", + "awaiting-input": "⏸️", + "complete": "✅", + "failed": "❌", + "idle": "⏹️", + } + + def _state_emoji(self, state: str) -> str: + """Map subagent state to an emoji indicator.""" + return self._STATE_EMOJIS.get(state, "⚙️") + + def _max_subagents(self) -> int: + """Maximum number of subagents to display. + + Base adapter allows 20; platform-specific adapters override + (Slack: 10, Teams: 4).""" + return 20 + + def _max_subagent_detail_length(self) -> int: + """Maximum length for subagent detail text. + + Zero means no limit, which is the base; platform-specific adapters + override (Slack: 100 chars, Telegram: 120).""" + return 0 + + def _truncate_detail(self, detail: str) -> str: + """Truncate detail text to platform-specific limits.""" + max_length = self._max_subagent_detail_length() + if max_length > 0 and len(detail) > max_length: + return detail[: max_length - 1] + "…" + return detail + + def _format_subagents(self, active_subagents: list[ActiveSubagent] | None) -> str: + """Format active subagents as a bulleted list with state emojis. + + Returns empty string if no subagents. Enforces platform-specific limits + on count and detail length. Escapes user text (agent_name, detail) to + prevent injection.""" + if not active_subagents: + return "" + + lines = [] + for subagent in active_subagents[: self._max_subagents()]: + emoji = self._state_emoji(subagent["state"]) + name = self._escape_text(subagent["agent_name"]) + detail = subagent.get("detail") + if detail: + lines.append( + f"- {emoji} {name}: {self._escape_text(self._truncate_detail(detail))}" + ) + else: + lines.append(f"- {emoji} {name}") + + return "\n".join(lines) async def _ping_operator( self, diff --git a/core/switch_core/bridges/collaboration/bridge_core.py b/core/switch_core/bridges/collaboration/bridge_core.py index 27fe1bcd0..9af3bca07 100644 --- a/core/switch_core/bridges/collaboration/bridge_core.py +++ b/core/switch_core/bridges/collaboration/bridge_core.py @@ -1652,6 +1652,7 @@ async def handle_agent_runtime_state( detail=event.detail, trigger_thread_root_id=trigger_thread_ref, anchor_message_ref=anchor_message_ref, + active_subagents=event.active_subagents, ) await self._follow_reported_anchor( channel_id, diff --git a/core/switch_core/bridges/collaboration/discord/adapter.py b/core/switch_core/bridges/collaboration/discord/adapter.py index 0285b23ee..618bda4fe 100644 --- a/core/switch_core/bridges/collaboration/discord/adapter.py +++ b/core/switch_core/bridges/collaboration/discord/adapter.py @@ -17,6 +17,7 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.agent.commands import Command as InRoomCommand from switch_core.bridges.collaboration.adapter import ( + ActiveSubagent, CollaborationAdapter, LiveRuntimeIndicator, ) @@ -610,6 +611,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Render runtime state as persistent, truly-deletable status messages. @@ -636,7 +638,7 @@ async def _apply_runtime_state( await self._clear_input_pings(channel_id, agent_name) # Posted under the agent's own name/icon, so the body just states # the activity — no need to repeat the agent name in the text. - body = self._working_body(detail, deeplink_url) + body = self._working_body(detail, deeplink_url, active_subagents) existing = self._working_msg.get(key) if existing is not None: await self.update_message(channel_id, existing.message_ref, body) @@ -1066,6 +1068,17 @@ async def get_channel_agent_names(self, channel_id: str) -> list[str]: # ── Translation ────────────────────────────────────────────────────────── + def _escape_text(self, text: str) -> str: + """Escape markdown special characters for Discord. + + Discord renders markdown natively, so user-supplied text (agent names, + detail strings) must escape markdown metacharacters to prevent + unintended formatting in status messages.""" + escape_chars = ["\\", "*", "_", "`", "~", "|", "[", "]"] + for char in escape_chars: + text = text.replace(char, f"\\{char}") + return text + def translate_outbound(self, content: str) -> str: # Discord renders markdown natively (bold, code, headers, masked # links), so only @name mentions need rewriting to real Discord diff --git a/core/switch_core/bridges/collaboration/mattermost/adapter.py b/core/switch_core/bridges/collaboration/mattermost/adapter.py index f415c0dc6..2476eede0 100644 --- a/core/switch_core/bridges/collaboration/mattermost/adapter.py +++ b/core/switch_core/bridges/collaboration/mattermost/adapter.py @@ -20,6 +20,7 @@ from switch_core.agent_icon import default_icon_url from switch_core.bridges.collaboration.adapter import ( + ActiveSubagent, CollaborationAdapter, LiveRuntimeIndicator, format_elapsed, @@ -507,6 +508,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Surface runtime state as a posted message that is **never deleted**. @@ -537,7 +539,7 @@ async def _apply_runtime_state( # Resuming work means the requested input was provided — remove the # now-resolved pings, then ensure the working indicator is up. await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) + body = self._working_body(detail, deeplink_url, active_subagents) existing = self._working_msg.get(key) if existing is not None: # Refresh the live message in place with the latest activity. @@ -1279,6 +1281,22 @@ def _upload(data: bytes = image_bytes) -> None: # ── Translation ────────────────────────────────────────────────────────── + def _escape_text(self, text: str) -> str: + """Escape user text for Mattermost markdown rendering. + + Mattermost uses markdown, so escape markdown special characters to + prevent user input from being interpreted as formatting.""" + return ( + text.replace("\\", "\\\\") + .replace("*", "\\*") + .replace("_", "\\_") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("`", "\\`") + .replace("#", "\\#") + .replace(">", "\\>") + ) + def translate_outbound(self, content: str) -> str: return re.sub( r"@(\w+):\S+", diff --git a/core/switch_core/bridges/collaboration/slack/adapter.py b/core/switch_core/bridges/collaboration/slack/adapter.py index 194bd9a2d..77ae0f356 100644 --- a/core/switch_core/bridges/collaboration/slack/adapter.py +++ b/core/switch_core/bridges/collaboration/slack/adapter.py @@ -19,6 +19,7 @@ from slack_sdk.web.async_client import AsyncWebClient from switch_core.bridges.collaboration.adapter import ( + ActiveSubagent, CollaborationAdapter, LiveRuntimeIndicator, ) @@ -728,6 +729,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Render runtime state as persistent, truly-deletable status messages. @@ -766,7 +768,7 @@ async def _apply_runtime_state( return # Posted under the agent's own name/icon, so the body just states # the activity — no need to repeat the agent name in the text. - body = self._working_body(detail, deeplink_url) + body = self._working_body(detail, deeplink_url, active_subagents) existing = self._working_msg.get(key) if existing is not None: # Refresh the live message in place with the latest activity. @@ -2477,6 +2479,23 @@ def _replace(match: re.Match[str]) -> str: # what Slack actually sends on some paths, so accept both. return re.sub(r"]*)?>", _replace, message) + # ── Runtime state formatting ───────────────────────────────────────────── + + def _escape_text(self, text: str) -> str: + """Escape user text for mrkdwn rendering. + + Slack uses mrkdwn, which requires escaping &, <, and > to prevent + them from being interpreted as special characters or breaking mentions.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + def _max_subagents(self) -> int: + """Slack-specific limit on displayed subagents.""" + return 10 + + def _max_subagent_detail_length(self) -> int: + """Slack-specific limit on subagent detail text.""" + return 100 + # ── Markdown → mrkdwn ──────────────────────────────────────────────────── @staticmethod diff --git a/core/switch_core/bridges/collaboration/teams/adapter.py b/core/switch_core/bridges/collaboration/teams/adapter.py index 463316cc3..49846446a 100644 --- a/core/switch_core/bridges/collaboration/teams/adapter.py +++ b/core/switch_core/bridges/collaboration/teams/adapter.py @@ -22,6 +22,7 @@ from switch_core.bridges.agent.commands import COMMANDS_BY_NAME from switch_core.bridges.collaboration.adapter import ( + ActiveSubagent, CollaborationAdapter, LiveRuntimeIndicator, format_elapsed, @@ -981,6 +982,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Persistent status messages, mirroring Slack. @@ -1003,7 +1005,7 @@ async def _apply_runtime_state( key = (channel_id, agent_name) if state == "working": await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) + body = self._working_body(detail, deeplink_url, active_subagents) existing = self._working_msg.get(key) if existing is not None: await self._refresh_card( @@ -1137,6 +1139,19 @@ async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: else: await self.delete_message(channel_id, ref) + def _max_subagents(self) -> int: + """Teams displays at most 4 subagents.""" + return 4 + + def _escape_text(self, text: str) -> str: + """Escape markdown special characters for Teams Adaptive Cards TextBlock. + + Adaptive Cards render markdown formatting, so we escape special + characters to prevent injection.""" + for char in ["\\", "*", "_", "[", "]", ">"]: + text = text.replace(char, f"\\{char}") + return text + # ── Channels ───────────────────────────────────────────────────────────── @staticmethod diff --git a/core/switch_core/bridges/collaboration/telegram/adapter.py b/core/switch_core/bridges/collaboration/telegram/adapter.py index 688e7d2a5..4e03b72b0 100644 --- a/core/switch_core/bridges/collaboration/telegram/adapter.py +++ b/core/switch_core/bridges/collaboration/telegram/adapter.py @@ -26,6 +26,7 @@ from switch_core.bridges.agent.commands import COMMANDS, COMMANDS_BY_NAME, CommandArg from switch_core.bridges.collaboration.adapter import ( + ActiveSubagent, CollaborationAdapter, LiveRuntimeIndicator, ) @@ -985,6 +986,7 @@ async def _apply_runtime_state( detail: str | None = None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[ActiveSubagent] | None = None, ) -> None: """Render runtime state as persistent, deletable status messages. @@ -1009,7 +1011,7 @@ async def _apply_runtime_state( if state == "working": await self._clear_input_pings(channel_id, agent_name) - body = self._working_body(detail, deeplink_url) + body = self._working_body(detail, deeplink_url, active_subagents) existing = self._working_msg.get(key) if existing is not None: await self.update_message( @@ -1047,6 +1049,12 @@ async def _clear_input_pings(self, channel_id: str, agent_name: str) -> None: for ref in refs: await self.delete_message(channel_id, ref) + def _max_subagent_detail_length(self) -> int: + """Maximum length for subagent detail text. + + Telegram has a 120 character platform constraint.""" + return 120 + # ── Channels ───────────────────────────────────────────────────────────── async def create_channel( diff --git a/core/switch_core/db/models.py b/core/switch_core/db/models.py index 6798bfe95..dce0d51e8 100644 --- a/core/switch_core/db/models.py +++ b/core/switch_core/db/models.py @@ -713,6 +713,7 @@ class AgentRuntimeState(Base): # Null when no controller reports capabilities (e.g. a standalone `claude` # session), which resolves session_dependent commands to "unsupported". control_capabilities: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + active_subagents: Mapped[list | None] = mapped_column(JSONB, nullable=True) updated_at: Mapped[str] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/core/switch_core/db/stores/agent_runtime_state_store.py b/core/switch_core/db/stores/agent_runtime_state_store.py index 20ea5c8a8..ca54d29a8 100644 --- a/core/switch_core/db/stores/agent_runtime_state_store.py +++ b/core/switch_core/db/stores/agent_runtime_state_store.py @@ -29,6 +29,7 @@ async def upsert( state: str, deeplink_url: str | None = None, control_capabilities: dict | None = None, + active_subagents: list | None = None, ) -> None: now = datetime.now(UTC) stmt = insert(AgentRuntimeState).values( @@ -37,6 +38,7 @@ async def upsert( state=state, deeplink_url=deeplink_url, control_capabilities=control_capabilities, + active_subagents=active_subagents, updated_at=now, ) set_: dict[str, object] = {"state": state, "updated_at": now} @@ -49,6 +51,10 @@ async def upsert( # report carries none and must not wipe the last-known capabilities. if control_capabilities is not None: set_["control_capabilities"] = control_capabilities + # Same preserve-on-omit rule for active subagents: only update when + # explicitly provided. + if active_subagents is not None: + set_["active_subagents"] = active_subagents stmt = stmt.on_conflict_do_update( constraint="uq_agent_runtime_states_agent_room", set_=set_, diff --git a/core/switch_core/events.py b/core/switch_core/events.py index dcd46ac9d..ab08eee8f 100644 --- a/core/switch_core/events.py +++ b/core/switch_core/events.py @@ -1,8 +1,19 @@ from __future__ import annotations +from typing import TypedDict + from pydantic import BaseModel, ConfigDict +class ActiveSubagent(TypedDict): + """One subagent an agent has spawned, as reported with its runtime state.""" + + agent_id: str + agent_name: str + state: str + detail: str | None + + class MediationResult(BaseModel): verdict: str reason: str | None = None @@ -164,6 +175,10 @@ class AgentRuntimeStateEvent(SwitchEvent): # the agent really has. Unchanged between reports (e.g. the periodic # activity refresh) means the indicator stays where it is. anchor_event_id: str | None = None + # Subagents this agent currently has running, listed under the working + # indicator. Persisted on agent_runtime_states under the same + # preserve-on-omit rule as control_capabilities: None means "unchanged". + active_subagents: list[ActiveSubagent] | None = None # ── Permission ──────────────────────────────────────────────────────────────── diff --git a/core/switch_core/migrations/versions/f8a1b2c3d4e5_add_active_subagents.py b/core/switch_core/migrations/versions/f8a1b2c3d4e5_add_active_subagents.py new file mode 100644 index 000000000..e1987667b --- /dev/null +++ b/core/switch_core/migrations/versions/f8a1b2c3d4e5_add_active_subagents.py @@ -0,0 +1,29 @@ +"""add active_subagents to agent_runtime_states + +Revision ID: f8a1b2c3d4e5 +Revises: c81f4a06d2b7 +Create Date: 2026-09-03 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "f8a1b2c3d4e5" +down_revision: str | None = "c81f4a06d2b7" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "agent_runtime_states", + sa.Column("active_subagents", postgresql.JSONB, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("agent_runtime_states", "active_subagents") diff --git a/core/tests/switch_core/bridges/agent/api/test_runtime_state_request.py b/core/tests/switch_core/bridges/agent/api/test_runtime_state_request.py new file mode 100644 index 000000000..4de08d1c6 --- /dev/null +++ b/core/tests/switch_core/bridges/agent/api/test_runtime_state_request.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from switch_core.bridges.agent.api.schemas import RuntimeStateRequest + + +def _subagents(count: int) -> list[dict[str, str]]: + return [ + {"agent_id": f"sub-{i}", "agent_name": f"agent{i}", "state": "working"} + for i in range(count) + ] + + +def test_active_subagents_default_to_unchanged() -> None: + # Omitted means "unchanged", so the stored list survives a report that says + # nothing about subagents (the periodic activity refresh, the idle sweep). + req = RuntimeStateRequest(room_id="room-1", state="working") + + assert req.active_subagents is None + + +def test_a_subagent_needs_a_name_and_a_known_state() -> None: + with pytest.raises(ValidationError): + RuntimeStateRequest( + room_id="room-1", + state="working", + active_subagents=[{"agent_id": "sub-1", "state": "working"}], + ) + with pytest.raises(ValidationError): + RuntimeStateRequest( + room_id="room-1", + state="working", + active_subagents=[ + {"agent_id": "sub-1", "agent_name": "a", "state": "dancing"} + ], + ) + + +def test_the_subagent_list_is_capped() -> None: + RuntimeStateRequest( + room_id="room-1", state="working", active_subagents=_subagents(50) + ) + + with pytest.raises(ValidationError): + RuntimeStateRequest( + room_id="room-1", state="working", active_subagents=_subagents(51) + ) diff --git a/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py b/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py index 2ecd902ce..711059e6a 100644 --- a/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py +++ b/core/tests/switch_core/bridges/collaboration/test_bridge_runtime_state_thread.py @@ -44,6 +44,7 @@ async def apply_runtime_state( detail: str | None, trigger_thread_root_id: str | None = None, anchor_message_ref: str | None = None, + active_subagents: list[dict[str, Any]] | None = None, ) -> None: self.applied.append(thread_root_id) self.trigger_threads.append(trigger_thread_root_id) diff --git a/core/tests/switch_core/bridges/collaboration/test_subagent_indicator.py b/core/tests/switch_core/bridges/collaboration/test_subagent_indicator.py new file mode 100644 index 000000000..53e67538a --- /dev/null +++ b/core/tests/switch_core/bridges/collaboration/test_subagent_indicator.py @@ -0,0 +1,287 @@ +"""Subagents listed under an agent's live "working on it…" indicator. + +An agent that delegates reports the subagents it currently has running with its +runtime state. The bridge hands the list to the adapter, which renders one line +per subagent below the activity line. These pin down what the reader sees: the +agent's own activity line untouched, each subagent's name and detail escaped for +the platform, and the per-platform ceilings on how many lines and how long. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any + +from switch_core.bridges.collaboration.bridge_core import BridgeCore +from switch_core.bridges.collaboration.discord.adapter import ( + DiscordAdapter, + DiscordConnectionConfig, +) +from switch_core.bridges.collaboration.mattermost.adapter import ( + MattermostAdapter, + MattermostConnectionConfig, +) +from switch_core.bridges.collaboration.slack.adapter import ( + SlackAdapter, + SlackConnectionConfig, +) +from switch_core.bridges.collaboration.teams.adapter import ( + TeamsAdapter, + TeamsConnectionConfig, +) +from switch_core.bridges.collaboration.telegram.adapter import ( + TelegramAdapter, + TelegramConnectionConfig, +) +from switch_core.events import ActiveSubagent, AgentRuntimeStateEvent + + +def _mattermost() -> MattermostAdapter: + return MattermostAdapter( + config=MattermostConnectionConfig( + url="http://mm", + admin_user="admin", + admin_password="pw", + team_name="team", + ) + ) + + +def _slack() -> SlackAdapter: + return SlackAdapter( + config=SlackConnectionConfig( + bot_token="xoxb-test", app_token="xapp-test", workspace_id="T123" + ) + ) + + +def _teams() -> TeamsAdapter: + return TeamsAdapter( + config=TeamsConnectionConfig( + app_id="app-123", + app_password="secret", + tenant_id="tenant-1", + team_id="team-1", + public_base_url="https://switch.example", + client_state="s3cr3t", + ) + ) + + +def _telegram() -> TelegramAdapter: + return TelegramAdapter( + config=TelegramConnectionConfig(bot_token="token", bot_username="bot") + ) + + +def _discord() -> DiscordAdapter: + return DiscordAdapter( + config=DiscordConnectionConfig(bot_token="token", guild_id="1") + ) + + +def _subagent(name: str, state: str, detail: str | None = None) -> ActiveSubagent: + return { + "agent_id": f"sub-{name}", + "agent_name": name, + "state": state, + "detail": detail, + } + + +# ── Rendering ──────────────────────────────────────────────────────────────── + + +def test_the_agents_own_activity_line_is_never_escaped() -> None: + # The detail comes from the connector, not from a room member, and has + # always been rendered as written — "Editing foo_bar.py" must not turn into + # "Editing foo\_bar.py" just because subagents can now appear below it. + adapter = _mattermost() + + assert adapter._working_body("Editing foo_bar.py", None) == "⚙️ Editing foo_bar.py" + assert adapter._working_body( + "Editing foo_bar.py", None, [_subagent("Explore", "working")] + ) == ("⚙️ Editing foo_bar.py\n- ⚙️ Explore") + + +def test_no_subagents_leaves_the_working_line_alone() -> None: + adapter = _mattermost() + plain = adapter._working_body("Running tests", None) + + assert adapter._working_body("Running tests", None, None) == plain + assert adapter._working_body("Running tests", None, []) == plain + + +def test_a_subagent_detail_is_rendered_after_its_name() -> None: + adapter = _mattermost() + + body = adapter._working_body( + "Running tests", None, [_subagent("Explore", "working", "reading models.py")] + ) + + assert body == "⚙️ Running tests\n- ⚙️ Explore: reading models.py" + + +def test_each_state_gets_its_own_marker() -> None: + adapter = _mattermost() + subagents = [ + _subagent("a", "working"), + _subagent("b", "awaiting-input"), + _subagent("c", "complete"), + _subagent("d", "failed"), + _subagent("e", "idle"), + ] + + lines = adapter._working_body("Delegating", None, subagents).splitlines()[1:] + + assert lines == [ + "- ⚙️ a", + "- ⏸️ b", + "- ✅ c", + "- ❌ d", + "- ⏹️ e", + ] + + +def test_teams_renders_the_same_emoji_as_everywhere_else() -> None: + # Adaptive Cards render emoji, so Teams has no reason to fall back to ASCII. + body = _teams()._working_body("Delegating", None, [_subagent("Explore", "failed")]) + + assert "❌ Explore" in body + + +def test_a_subagent_name_and_detail_are_escaped_for_the_platform() -> None: + # Unlike the agent's own detail, these are names an agent chose and text it + # wrote, so markdown in them must not reformat the indicator. + body = _mattermost()._working_body( + "Delegating", + None, + [_subagent("code_review", "working", "found *3* issues")], + ) + + assert body == "⚙️ Delegating\n- ⚙️ code\\_review: found \\*3\\* issues" + + +def test_slack_escapes_the_mrkdwn_control_characters() -> None: + body = _slack()._working_body( + "Delegating", None, [_subagent("