feat: show mock agent hint bubble in empty state

This commit is contained in:
Nathan Flurry 2026-01-27 21:32:56 -08:00
parent 02c9201bda
commit 50b5289e47
11 changed files with 186 additions and 2 deletions

View file

@ -22,6 +22,7 @@ Capabilities tell you which features are supported for the selected agent:
- `questions` and `permissions` indicate HITL flows. - `questions` and `permissions` indicate HITL flows.
- `plan_mode` indicates that the agent supports plan-only execution. - `plan_mode` indicates that the agent supports plan-only execution.
- `reasoning` and `status` indicate that the agent can emit reasoning/status content parts. - `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.). Use these to enable or disable UI affordances (tool panels, approval buttons, etc.).

View file

@ -668,6 +668,7 @@
"fileChanges", "fileChanges",
"mcpTools", "mcpTools",
"streamingDeltas", "streamingDeltas",
"itemStarted",
"sharedProcess" "sharedProcess"
], ],
"properties": { "properties": {
@ -686,6 +687,9 @@
"images": { "images": {
"type": "boolean" "type": "boolean"
}, },
"itemStarted": {
"type": "boolean"
},
"mcpTools": { "mcpTools": {
"type": "boolean" "type": "boolean"
}, },

View file

@ -723,6 +723,26 @@
width: auto; 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 { .empty-state-menu-wrapper {
position: relative; position: relative;
} }

View file

@ -853,6 +853,7 @@ export default function App() {
agentsLoading={agentsLoading} agentsLoading={agentsLoading}
agentsError={agentsError} agentsError={agentsError}
messagesEndRef={messagesEndRef} messagesEndRef={messagesEndRef}
agentId={agentId}
agentLabel={agentLabel} agentLabel={agentLabel}
agentMode={agentMode} agentMode={agentMode}
permissionMode={permissionMode} permissionMode={permissionMode}

View file

@ -3,6 +3,7 @@ import {
Activity, Activity,
AlertTriangle, AlertTriangle,
Brain, Brain,
CircleDot,
Download, Download,
FileDiff, FileDiff,
Gauge, Gauge,
@ -35,7 +36,8 @@ const badges = [
{ key: "commandExecution", label: "Commands", icon: Terminal }, { key: "commandExecution", label: "Commands", icon: Terminal },
{ key: "fileChanges", label: "File Changes", icon: FileDiff }, { key: "fileChanges", label: "File Changes", icon: FileDiff },
{ key: "mcpTools", label: "MCP", icon: Plug }, { 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; ] as const;
type BadgeItem = (typeof badges)[number]; type BadgeItem = (typeof badges)[number];

View file

@ -22,6 +22,7 @@ const ChatPanel = ({
agentsLoading, agentsLoading,
agentsError, agentsError,
messagesEndRef, messagesEndRef,
agentId,
agentLabel, agentLabel,
agentMode, agentMode,
permissionMode, permissionMode,
@ -63,6 +64,7 @@ const ChatPanel = ({
agentsLoading: boolean; agentsLoading: boolean;
agentsError: string | null; agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>; messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentLabel: string; agentLabel: string;
agentMode: string; agentMode: string;
permissionMode: string; permissionMode: string;
@ -230,6 +232,11 @@ const ChatPanel = ({
<Terminal className="empty-state-icon" /> <Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div> <div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p> <p className="empty-state-text">Send a message to start a conversation with the agent.</p>
{agentId === "mock" && (
<div className="mock-agent-hint">
The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands.
</div>
)}
</div> </div>
) : ( ) : (
<ChatMessages <ChatMessages

View file

@ -13,6 +13,7 @@ export type AgentCapabilitiesView = AgentCapabilities & {
fileChanges?: boolean; fileChanges?: boolean;
mcpTools?: boolean; mcpTools?: boolean;
streamingDeltas?: boolean; streamingDeltas?: boolean;
itemStarted?: boolean;
}; };
export const emptyCapabilities: AgentCapabilitiesView = { export const emptyCapabilities: AgentCapabilitiesView = {
@ -32,5 +33,6 @@ export const emptyCapabilities: AgentCapabilitiesView = {
fileChanges: false, fileChanges: false,
mcpTools: false, mcpTools: false,
streamingDeltas: false, streamingDeltas: false,
itemStarted: false,
sharedProcess: false sharedProcess: false
}; };

View file

@ -59,6 +59,7 @@ export interface components {
fileAttachments: boolean; fileAttachments: boolean;
fileChanges: boolean; fileChanges: boolean;
images: boolean; images: boolean;
itemStarted: boolean;
mcpTools: boolean; mcpTools: boolean;
permissions: boolean; permissions: boolean;
planMode: boolean; planMode: boolean;

View file

@ -105,3 +105,10 @@ cargo test -p sandbox-agent --test http_endpoints
## Universal Schema ## Universal Schema
When modifying agent conversion code in `server/packages/universal-agent-schema/src/agents/` or adding/changing properties on the universal schema, update the feature matrix in `README.md` to reflect which agents support which features. When modifying agent conversion code in `server/packages/universal-agent-schema/src/agents/` or adding/changing properties on the universal schema, update the feature matrix in `README.md` to reflect which agents support which features.
## Capabilities sync
When updating agent capabilities (flags or values), keep them in sync across:
- `README.md` (feature matrix / documented support)
- server Rust implementation (`AgentCapabilities` + `agent_capabilities_for`)
- frontend capability views/badges (Inspector UI)

View file

@ -267,6 +267,8 @@ struct SessionState {
session_started_emitted: bool, session_started_emitted: bool,
last_claude_message_id: Option<String>, last_claude_message_id: Option<String>,
claude_message_counter: u64, claude_message_counter: u64,
pending_assistant_native_ids: VecDeque<String>,
pending_assistant_counter: u64,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -322,9 +324,40 @@ impl SessionState {
session_started_emitted: false, session_started_emitted: false,
last_claude_message_id: None, last_claude_message_id: None,
claude_message_counter: 0, 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<EventConversion>) -> Vec<UniversalEvent> { fn record_conversions(&mut self, conversions: Vec<EventConversion>) -> Vec<UniversalEvent> {
let mut events = Vec::new(); let mut events = Vec::new();
for conversion in conversions { for conversion in conversions {
@ -357,6 +390,54 @@ impl SessionState {
} }
let mut conversions = Vec::new(); 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 { match conversion.event_type {
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
if let UniversalEventData::Item(ref mut data) = conversion.data { if let UniversalEventData::Item(ref mut data) = conversion.data {
@ -1505,11 +1586,21 @@ impl SessionManager {
self.ensure_opencode_stream(session_id.clone()).await?; self.ensure_opencode_stream(session_id.clone()).await?;
self.send_opencode_prompt(&session_snapshot, &message) self.send_opencode_prompt(&session_snapshot, &message)
.await?; .await?;
if !agent_supports_item_started(session_snapshot.agent) {
let _ = self
.emit_synthetic_assistant_start(&session_snapshot.session_id)
.await;
}
return Ok(()); return Ok(());
} }
if session_snapshot.agent == AgentId::Codex { if session_snapshot.agent == AgentId::Codex {
// Use the shared Codex app-server // Use the shared Codex app-server
self.send_codex_turn(&session_snapshot, &message).await?; 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(()); return Ok(());
} }
@ -1537,6 +1628,12 @@ impl SessionManager {
})?; })?;
let spawn_result = spawn_result.map_err(|err| map_spawn_error(agent_id, err))?; 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); let manager = Arc::clone(self);
tokio::spawn(async move { tokio::spawn(async move {
manager manager
@ -1547,6 +1644,23 @@ impl SessionManager {
Ok(()) 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. /// Reopens a session that was ended by an agent process completing.
/// This allows resumable agents (Claude, Amp, OpenCode) to continue conversations. /// This allows resumable agents (Claude, Amp, OpenCode) to continue conversations.
async fn reopen_session_if_ended(&self, session_id: &str) { async fn reopen_session_if_ended(&self, session_id: &str) {
@ -3023,6 +3137,7 @@ pub struct AgentCapabilities {
pub file_changes: bool, pub file_changes: bool,
pub mcp_tools: bool, pub mcp_tools: bool,
pub streaming_deltas: bool, pub streaming_deltas: bool,
pub item_started: bool,
/// Whether this agent uses a shared long-running server process (vs per-turn subprocess) /// Whether this agent uses a shared long-running server process (vs per-turn subprocess)
pub shared_process: bool, 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) 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 { fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
match agent { match agent {
// Headless Claude CLI does not expose AskUserQuestion and does not emit tool_result, // 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, file_changes: false,
mcp_tools: false, mcp_tools: false,
streaming_deltas: false, streaming_deltas: false,
item_started: false,
shared_process: false, // per-turn subprocess with --resume shared_process: false, // per-turn subprocess with --resume
}, },
AgentId::Codex => AgentCapabilities { AgentId::Codex => AgentCapabilities {
@ -3642,6 +3762,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
file_changes: true, file_changes: true,
mcp_tools: true, mcp_tools: true,
streaming_deltas: true, streaming_deltas: true,
item_started: true,
shared_process: true, // shared app-server via JSON-RPC shared_process: true, // shared app-server via JSON-RPC
}, },
AgentId::Opencode => AgentCapabilities { AgentId::Opencode => AgentCapabilities {
@ -3661,6 +3782,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
file_changes: false, file_changes: false,
mcp_tools: false, mcp_tools: false,
streaming_deltas: true, streaming_deltas: true,
item_started: true,
shared_process: true, // shared HTTP server shared_process: true, // shared HTTP server
}, },
AgentId::Amp => AgentCapabilities { AgentId::Amp => AgentCapabilities {
@ -3680,6 +3802,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
file_changes: false, file_changes: false,
mcp_tools: false, mcp_tools: false,
streaming_deltas: false, streaming_deltas: false,
item_started: false,
shared_process: false, // per-turn subprocess with --continue shared_process: false, // per-turn subprocess with --continue
}, },
AgentId::Mock => AgentCapabilities { AgentId::Mock => AgentCapabilities {
@ -3699,6 +3822,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
file_changes: true, file_changes: true,
mcp_tools: true, mcp_tools: true,
streaming_deltas: true, streaming_deltas: true,
item_started: true,
shared_process: false, // in-memory mock (no subprocess) shared_process: false, // in-memory mock (no subprocess)
}, },
} }

View file

@ -572,12 +572,27 @@ fn normalize_item(item: &Value) -> Value {
map.insert("status".to_string(), Value::String(status.to_string())); map.insert("status".to_string(), Value::String(status.to_string()));
} }
if let Some(content) = item.get("content").and_then(Value::as_array) { if let Some(content) = item.get("content").and_then(Value::as_array) {
let types = content let mut types = content
.iter() .iter()
.filter_map(|part| part.get("type").and_then(Value::as_str)) .filter_map(|part| part.get("type").and_then(Value::as_str))
.filter(|value| *value != "reasoning" && *value != "status") .filter(|value| *value != "reasoning" && *value != "status")
.map(|value| Value::String(value.to_string())) .map(|value| Value::String(value.to_string()))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
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)); map.insert("content_types".to_string(), Value::Array(types));
} }
Value::Object(map) Value::Object(map)