From 50b5289e476108bb0757a4f6ec095af9ed727be0 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 27 Jan 2026 21:32:56 -0800 Subject: [PATCH] feat: show mock agent hint bubble in empty state --- docs/building-chat-ui.mdx | 1 + docs/openapi.json | 4 + frontend/packages/inspector/index.html | 20 +++ frontend/packages/inspector/src/App.tsx | 1 + .../components/agents/CapabilityBadges.tsx | 4 +- .../src/components/chat/ChatPanel.tsx | 7 + .../packages/inspector/src/types/agents.ts | 2 + sdks/typescript/src/generated/openapi.ts | 1 + server/CLAUDE.md | 7 + server/packages/sandbox-agent/src/router.rs | 124 ++++++++++++++++++ .../sandbox-agent/tests/common/http.rs | 17 ++- 11 files changed, 186 insertions(+), 2 deletions(-) diff --git a/docs/building-chat-ui.mdx b/docs/building-chat-ui.mdx index 80363f9..240b8a4 100644 --- a/docs/building-chat-ui.mdx +++ b/docs/building-chat-ui.mdx @@ -22,6 +22,7 @@ Capabilities tell you which features are supported for the selected agent: - `questions` and `permissions` indicate HITL flows. - `plan_mode` indicates that the agent supports plan-only execution. - `reasoning` and `status` indicate that the agent can emit reasoning/status content parts. +- `item_started` indicates that the agent emits `item.started` on its own; when false the daemon will emit a synthetic `item.started` immediately after sending a user message. Use these to enable or disable UI affordances (tool panels, approval buttons, etc.). diff --git a/docs/openapi.json b/docs/openapi.json index 900b945..88cfcb0 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -668,6 +668,7 @@ "fileChanges", "mcpTools", "streamingDeltas", + "itemStarted", "sharedProcess" ], "properties": { @@ -686,6 +687,9 @@ "images": { "type": "boolean" }, + "itemStarted": { + "type": "boolean" + }, "mcpTools": { "type": "boolean" }, diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 2fd0b9d..07e48b2 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -723,6 +723,26 @@ width: auto; } + .mock-agent-hint { + margin-top: 16px; + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--border-2); + border-radius: var(--radius); + font-size: 12px; + color: var(--text-secondary); + max-width: 320px; + line-height: 1.5; + } + + .mock-agent-hint code { + background: var(--border-2); + padding: 2px 6px; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + font-size: 11px; + } + .empty-state-menu-wrapper { position: relative; } diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 17aebb2..215af70 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -853,6 +853,7 @@ export default function App() { agentsLoading={agentsLoading} agentsError={agentsError} messagesEndRef={messagesEndRef} + agentId={agentId} agentLabel={agentLabel} agentMode={agentMode} permissionMode={permissionMode} diff --git a/frontend/packages/inspector/src/components/agents/CapabilityBadges.tsx b/frontend/packages/inspector/src/components/agents/CapabilityBadges.tsx index 1994fd7..9521477 100644 --- a/frontend/packages/inspector/src/components/agents/CapabilityBadges.tsx +++ b/frontend/packages/inspector/src/components/agents/CapabilityBadges.tsx @@ -3,6 +3,7 @@ import { Activity, AlertTriangle, Brain, + CircleDot, Download, FileDiff, Gauge, @@ -35,7 +36,8 @@ const badges = [ { key: "commandExecution", label: "Commands", icon: Terminal }, { key: "fileChanges", label: "File Changes", icon: FileDiff }, { key: "mcpTools", label: "MCP", icon: Plug }, - { key: "streamingDeltas", label: "Deltas", icon: Activity } + { key: "streamingDeltas", label: "Deltas", icon: Activity }, + { key: "itemStarted", label: "Item Start", icon: CircleDot } ] as const; type BadgeItem = (typeof badges)[number]; diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 1c03fa7..4faea51 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -22,6 +22,7 @@ const ChatPanel = ({ agentsLoading, agentsError, messagesEndRef, + agentId, agentLabel, agentMode, permissionMode, @@ -63,6 +64,7 @@ const ChatPanel = ({ agentsLoading: boolean; agentsError: string | null; messagesEndRef: React.RefObject; + agentId: string; agentLabel: string; agentMode: string; permissionMode: string; @@ -230,6 +232,11 @@ const ChatPanel = ({
Ready to Chat

Send a message to start a conversation with the agent.

+ {agentId === "mock" && ( +
+ The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send help for available commands. +
+ )} ) : ( , claude_message_counter: u64, + pending_assistant_native_ids: VecDeque, + pending_assistant_counter: u64, } #[derive(Debug, Clone)] @@ -322,9 +324,40 @@ impl SessionState { session_started_emitted: false, last_claude_message_id: None, claude_message_counter: 0, + pending_assistant_native_ids: VecDeque::new(), + pending_assistant_counter: 0, }) } + fn next_pending_assistant_native_id(&mut self) -> String { + self.pending_assistant_counter += 1; + format!( + "{}_pending_assistant_{}", + self.session_id, self.pending_assistant_counter + ) + } + + fn enqueue_pending_assistant_start(&mut self) -> EventConversion { + let native_item_id = self.next_pending_assistant_native_id(); + self.pending_assistant_native_ids + .push_back(native_item_id.clone()); + EventConversion::new( + UniversalEventType::ItemStarted, + UniversalEventData::Item(ItemEventData { + item: UniversalItem { + item_id: String::new(), + native_item_id: Some(native_item_id), + parent_id: None, + kind: ItemKind::Message, + role: Some(ItemRole::Assistant), + content: Vec::new(), + status: ItemStatus::InProgress, + }, + }), + ) + .synthetic() + } + fn record_conversions(&mut self, conversions: Vec) -> Vec { let mut events = Vec::new(); for conversion in conversions { @@ -357,6 +390,54 @@ impl SessionState { } let mut conversions = Vec::new(); + if !agent_supports_item_started(self.agent) { + if conversion.event_type == UniversalEventType::ItemStarted { + if let UniversalEventData::Item(ref data) = conversion.data { + let is_assistant_message = data.item.kind == ItemKind::Message + && matches!(data.item.role, Some(ItemRole::Assistant)); + if is_assistant_message { + let keep = data + .item + .native_item_id + .as_ref() + .map(|id| self.pending_assistant_native_ids.contains(id)) + .unwrap_or(false); + if !keep { + return conversions; + } + } + } + } + match conversion.event_type { + UniversalEventType::ItemCompleted => { + if let UniversalEventData::Item(ref mut data) = conversion.data { + let is_assistant_message = data.item.kind == ItemKind::Message + && matches!(data.item.role, Some(ItemRole::Assistant)); + if is_assistant_message { + if let Some(pending) = self.pending_assistant_native_ids.pop_front() { + data.item.native_item_id = Some(pending); + data.item.item_id.clear(); + } + } + } + } + UniversalEventType::ItemDelta => { + if let UniversalEventData::ItemDelta(ref mut data) = conversion.data { + let is_user = data + .native_item_id + .as_ref() + .is_some_and(|id| id.starts_with("user_")); + if !is_user { + if let Some(pending) = self.pending_assistant_native_ids.front() { + data.native_item_id = Some(pending.clone()); + data.item_id.clear(); + } + } + } + } + _ => {} + } + } match conversion.event_type { UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { if let UniversalEventData::Item(ref mut data) = conversion.data { @@ -1505,11 +1586,21 @@ impl SessionManager { self.ensure_opencode_stream(session_id.clone()).await?; self.send_opencode_prompt(&session_snapshot, &message) .await?; + if !agent_supports_item_started(session_snapshot.agent) { + let _ = self + .emit_synthetic_assistant_start(&session_snapshot.session_id) + .await; + } return Ok(()); } if session_snapshot.agent == AgentId::Codex { // Use the shared Codex app-server self.send_codex_turn(&session_snapshot, &message).await?; + if !agent_supports_item_started(session_snapshot.agent) { + let _ = self + .emit_synthetic_assistant_start(&session_snapshot.session_id) + .await; + } return Ok(()); } @@ -1537,6 +1628,12 @@ impl SessionManager { })?; let spawn_result = spawn_result.map_err(|err| map_spawn_error(agent_id, err))?; + if !agent_supports_item_started(session_snapshot.agent) { + let _ = self + .emit_synthetic_assistant_start(&session_snapshot.session_id) + .await; + } + let manager = Arc::clone(self); tokio::spawn(async move { manager @@ -1547,6 +1644,23 @@ impl SessionManager { Ok(()) } + async fn emit_synthetic_assistant_start( + &self, + session_id: &str, + ) -> Result<(), SandboxError> { + let conversion = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + session.enqueue_pending_assistant_start() + }; + let _ = self.record_conversions(session_id, vec![conversion]).await?; + Ok(()) + } + /// Reopens a session that was ended by an agent process completing. /// This allows resumable agents (Claude, Amp, OpenCode) to continue conversations. async fn reopen_session_if_ended(&self, session_id: &str) { @@ -3023,6 +3137,7 @@ pub struct AgentCapabilities { pub file_changes: bool, pub mcp_tools: bool, pub streaming_deltas: bool, + pub item_started: bool, /// Whether this agent uses a shared long-running server process (vs per-turn subprocess) pub shared_process: bool, } @@ -3602,6 +3717,10 @@ fn agent_supports_resume(agent: AgentId) -> bool { matches!(agent, AgentId::Claude | AgentId::Amp | AgentId::Opencode | AgentId::Codex) } +fn agent_supports_item_started(agent: AgentId) -> bool { + agent_capabilities_for(agent).item_started +} + fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { match agent { // Headless Claude CLI does not expose AskUserQuestion and does not emit tool_result, @@ -3623,6 +3742,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { file_changes: false, mcp_tools: false, streaming_deltas: false, + item_started: false, shared_process: false, // per-turn subprocess with --resume }, AgentId::Codex => AgentCapabilities { @@ -3642,6 +3762,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { file_changes: true, mcp_tools: true, streaming_deltas: true, + item_started: true, shared_process: true, // shared app-server via JSON-RPC }, AgentId::Opencode => AgentCapabilities { @@ -3661,6 +3782,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { file_changes: false, mcp_tools: false, streaming_deltas: true, + item_started: true, shared_process: true, // shared HTTP server }, AgentId::Amp => AgentCapabilities { @@ -3680,6 +3802,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { file_changes: false, mcp_tools: false, streaming_deltas: false, + item_started: false, shared_process: false, // per-turn subprocess with --continue }, AgentId::Mock => AgentCapabilities { @@ -3699,6 +3822,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { file_changes: true, mcp_tools: true, streaming_deltas: true, + item_started: true, shared_process: false, // in-memory mock (no subprocess) }, } diff --git a/server/packages/sandbox-agent/tests/common/http.rs b/server/packages/sandbox-agent/tests/common/http.rs index 4b9ee48..8910e62 100644 --- a/server/packages/sandbox-agent/tests/common/http.rs +++ b/server/packages/sandbox-agent/tests/common/http.rs @@ -572,12 +572,27 @@ fn normalize_item(item: &Value) -> Value { map.insert("status".to_string(), Value::String(status.to_string())); } if let Some(content) = item.get("content").and_then(Value::as_array) { - let types = content + let mut types = content .iter() .filter_map(|part| part.get("type").and_then(Value::as_str)) .filter(|value| *value != "reasoning" && *value != "status") .map(|value| Value::String(value.to_string())) .collect::>(); + let is_assistant_message = item + .get("kind") + .and_then(Value::as_str) + .is_some_and(|kind| kind == "message") + && item + .get("role") + .and_then(Value::as_str) + .is_some_and(|role| role == "assistant"); + let is_in_progress = item + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "in_progress"); + if types.is_empty() && is_assistant_message && is_in_progress { + types.push(Value::String("text".to_string())); + } map.insert("content_types".to_string(), Value::Array(types)); } Value::Object(map)