From 5125323aad9bda230350cbe143c12f8213710347 Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Mon, 4 May 2026 00:15:50 -0700 Subject: [PATCH] feat(runtime): expose typing-indicator control for runtime-token agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/agents/runtime/pods/:podId/typing — runtime-token agents can now emit agent_typing_start / agent_typing_stop, getting the same conversational chrome as native cloud agents. Body: { action: 'start' | 'stop' } (defaults to 'start') Why this exists --------------- Internal callers (nativeRuntimeService, agentEventService) auto-emit typing_start when they enter the agent loop and typing_stop on message land. agentMessageService emits typing_stop when an external agent's POST /messages lands — but never typing_start. Net effect: external agent messages appear in chat instantly with no '…' pre-roll, while native ones get the expected conversational fade-in. This route closes that gap. CLI wrappers (ADR-005), webhook bots (ADR-006), and demo drivers can now POST {action:'start'} ~1s before their content message and the message lands with proper chat chrome. Safety ------ - 30s auto-clear in agentTypingService prevents stuck indicators if a caller forgets stop or crashes between start and message land - Existing agentMessageService.postMessage emits typing_stop on land, so callers can fire-and-forget the start (the message itself stops the indicator) - Same auth (agentRuntimeAuth) + pod authorization as POST /messages - Same rate-limit (phase4RateLimit) Identity -------- displayName + avatar resolved via AgentIdentityService.getOrCreateAgentUser the same way agentEventService does — User.botMetadata.displayName wins over installation.displayName, falls back to agentName. Driver pattern -------------- send_with_typing(podId, agent, msg) { POST /pods/$podId/typing {action: 'start'} sleep 1.2s POST /pods/$podId/messages {content: msg} // auto-emits typing_stop } --- backend/routes/agentsRuntime.ts | 68 +++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/backend/routes/agentsRuntime.ts b/backend/routes/agentsRuntime.ts index 0f139366..f873cf16 100644 --- a/backend/routes/agentsRuntime.ts +++ b/backend/routes/agentsRuntime.ts @@ -1374,6 +1374,74 @@ router.get('/pods/:podId/posts', agentRuntimeAuth, async (req: any, res: any) => } }); +// Manual typing-indicator control for runtime-token agents. +// +// The internal callers (nativeRuntimeService, agentEventService) emit +// agent_typing_start automatically when they enter their agent loop, then +// agent_typing_stop when the message lands. External agents posting via +// `POST /pods/:podId/messages` get the auto-stop on message land but no +// auto-start — meaning their messages appear without the conversational +// "typing…" pre-roll. +// +// This route exposes typing_start/stop to runtime-token holders so external +// agents (CLI wrappers, webhook bots, demo drivers) can render the same +// chat chrome as native ones. Auto-clear after 30s safety window in +// agentTypingService prevents stuck indicators on dropped sessions. +// +// Body: { action: 'start' | 'stop' } (defaults to 'start') +router.post('/pods/:podId/typing', agentRuntimeAuth, phase4RateLimit, async (req: any, res: any) => { + try { + const { podId } = req.params; + const installation = resolveInstallationForPod( + req.agentInstallations, + req.agentInstallation, + podId, + ); + + if (!ensurePodMatch(req.agentInstallations || installation, podId, req.agentAuthorizedPodIds)) { + return res.status(403).json({ message: 'Agent token not authorized for this pod' }); + } + + const action = String(req.body?.action || 'start').toLowerCase(); + if (action !== 'start' && action !== 'stop') { + return res.status(400).json({ message: "action must be 'start' or 'stop'" }); + } + + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const typing = require('../services/agentTypingService'); + const agentName = installation.agentName; + const instanceId = installation.instanceId || 'default'; + + if (action === 'stop') { + typing.emitAgentTypingStop({ podId, agentName, instanceId }); + return res.json({ ok: true, action: 'stop' }); + } + + // Build the start payload from the bot User row (display name + avatar). + let displayName = installation.displayName || agentName; + let avatar: string | undefined; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const AgentIdentityService = require('../services/agentIdentityService'); + const agentUser = await AgentIdentityService.getOrCreateAgentUser(agentName, { instanceId }) as { + username?: string; + profilePicture?: string; + botMetadata?: { displayName?: string }; + }; + displayName = agentUser?.botMetadata?.displayName || agentUser?.username || displayName; + avatar = agentUser?.profilePicture || undefined; + } catch (identityError) { + console.warn('[agent-typing route] identity lookup failed:', (identityError as Error).message); + } + + typing.emitAgentTypingStart({ podId, agentName, instanceId, displayName, avatar }); + return res.json({ ok: true, action: 'start', displayName }); + } catch (error: any) { + console.error('agent typing route error:', error?.message || error); + return res.status(500).json({ message: 'typing-indicator emit failed' }); + } +}); + router.post('/pods/:podId/messages', agentRuntimeAuth, phase4RateLimit, async (req: any, res: any) => { try { const { podId } = req.params;