diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 259bce8..b9ebbdb 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -12,23 +12,23 @@ diff --git a/frontend/packages/inspector/public/logos/amp.svg b/frontend/packages/inspector/public/logos/amp.svg new file mode 100644 index 0000000..624c311 --- /dev/null +++ b/frontend/packages/inspector/public/logos/amp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/claude.svg b/frontend/packages/inspector/public/logos/claude.svg new file mode 100644 index 0000000..879ad81 --- /dev/null +++ b/frontend/packages/inspector/public/logos/claude.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/packages/inspector/public/logos/openai.svg b/frontend/packages/inspector/public/logos/openai.svg new file mode 100644 index 0000000..ee3125f --- /dev/null +++ b/frontend/packages/inspector/public/logos/openai.svg @@ -0,0 +1,2 @@ + +OpenAI icon \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/opencode.svg b/frontend/packages/inspector/public/logos/opencode.svg new file mode 100644 index 0000000..c2404f2 --- /dev/null +++ b/frontend/packages/inspector/public/logos/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/pi.svg b/frontend/packages/inspector/public/logos/pi.svg new file mode 100644 index 0000000..ed14b63 --- /dev/null +++ b/frontend/packages/inspector/public/logos/pi.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index b99bc72..bb73af5 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -50,11 +50,15 @@ type SessionListItem = { sessionId: string; agent: string; ended: boolean; + archived: boolean; }; const ERROR_TOAST_MS = 6000; const MAX_ERROR_TOASTS = 3; +const CREATE_SESSION_SLOW_WARNING_MS = 90_000; const HTTP_ERROR_EVENT = "inspector-http-error"; +const ARCHIVED_SESSIONS_KEY = "sandbox-agent-inspector-archived-sessions"; +const SESSION_MODELS_KEY = "sandbox-agent-inspector-session-models"; const DEFAULT_ENDPOINT = "http://localhost:2468"; @@ -112,6 +116,31 @@ const getHttpErrorMessage = (status: number, statusText: string, responseBody: s return `${base}: ${clippedBody}`; }; +const shouldIgnoreGlobalError = (value: unknown): boolean => { + const name = value instanceof Error ? value.name : ""; + const message = (() => { + if (typeof value === "string") return value; + if (value instanceof Error) return value.message; + if (value && typeof value === "object" && "message" in value && typeof (value as { message?: unknown }).message === "string") { + return (value as { message: string }).message; + } + return ""; + })().toLowerCase(); + + if (name === "AbortError") return true; + if (!message) return false; + + return ( + message.includes("aborterror") || + message.includes("the operation was aborted") || + message.includes("signal is aborted") || + message.includes("acp client is closed") || + (message.includes("method not found") && message.includes("unstable/set_session_model")) || + message.includes("resizeobserver loop limit exceeded") || + message.includes("resizeobserver loop completed with undelivered notifications") + ); +}; + const getSessionIdFromPath = (): string => { const basePath = import.meta.env.BASE_URL; const path = window.location.pathname; @@ -120,6 +149,50 @@ const getSessionIdFromPath = (): string => { return match ? match[1] : ""; }; +const getArchivedSessionIds = (): Set => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(ARCHIVED_SESSIONS_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); + } catch { + return new Set(); + } +}; + +const archiveSessionId = (id: string): void => { + if (typeof window === "undefined" || !id) return; + const archived = getArchivedSessionIds(); + archived.add(id); + window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); +}; + +const unarchiveSessionId = (id: string): void => { + if (typeof window === "undefined" || !id) return; + const archived = getArchivedSessionIds(); + if (!archived.delete(id)) return; + window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); +}; + +const getPersistedSessionModels = (): Record => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(SESSION_MODELS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0 + ) + ); + } catch { + return {}; + } +}; + const updateSessionPath = (id: string) => { const basePath = import.meta.env.BASE_URL; const params = window.location.search; @@ -129,6 +202,15 @@ const updateSessionPath = (id: string) => { } }; +const areEventsEqualById = (a: SessionEvent[], b: SessionEvent[]): boolean => { + if (a === b) return true; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i]?.id !== b[i]?.id) return false; + } + return true; +}; + const getInitialConnection = () => { if (typeof window === "undefined") { return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record, hasUrlParam: false }; @@ -146,8 +228,11 @@ const getInitialConnection = () => { } } const hasUrlParam = urlParam != null && urlParam.length > 0; + const defaultEndpoint = import.meta.env.DEV + ? DEFAULT_ENDPOINT + : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT); return { - endpoint: hasUrlParam ? urlParam : (getCurrentOriginEndpoint() ?? DEFAULT_ENDPOINT), + endpoint: hasUrlParam ? urlParam : defaultEndpoint, token: tokenParam, headers, hasUrlParam @@ -185,10 +270,12 @@ export default function App() { const [agentId, setAgentId] = useState("claude"); const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState(null); + const [sessionModelById, setSessionModelById] = useState>(() => getPersistedSessionModels()); const [message, setMessage] = useState(""); const [events, setEvents] = useState([]); - const [sending, setSending] = useState(false); + const [sendingSessionId, setSendingSessionId] = useState(null); + const [historyLoadingSessionId, setHistoryLoadingSessionId] = useState(null); const [requestLog, setRequestLog] = useState([]); const logIdRef = useRef(1); @@ -196,14 +283,31 @@ export default function App() { const [errorToasts, setErrorToasts] = useState([]); const toastIdRef = useRef(1); const toastTimeoutsRef = useRef>(new Map()); + const toastExpiryRef = useRef>(new Map()); + const toastRemainingMsRef = useRef>(new Map()); const [debugTab, setDebugTab] = useState("events"); + const [highlightedEventId, setHighlightedEventId] = useState(null); const messagesEndRef = useRef(null); const clientRef = useRef(null); const activeSessionRef = useRef(null); const eventUnsubRef = useRef<(() => void) | null>(null); + const sessionEventsCacheRef = useRef>(new Map()); + const selectedSessionIdRef = useRef(sessionId); + const resumeInFlightSessionIdRef = useRef(null); + const subscriptionGenerationRef = useRef(0); + const reconnectingAfterCreateFailureRef = useRef(false); + const creatingSessionRef = useRef(false); + const createNoiseIgnoreUntilRef = useRef(0); + + useEffect(() => { + selectedSessionIdRef.current = sessionId; + if (!sessionId) { + setHistoryLoadingSessionId(null); + } + }, [sessionId]); const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { @@ -316,9 +420,49 @@ export default function App() { window.clearTimeout(timeoutId); toastTimeoutsRef.current.delete(toastId); } + toastExpiryRef.current.delete(toastId); + toastRemainingMsRef.current.delete(toastId); setErrorToasts((prev) => prev.filter((toast) => toast.id !== toastId)); }, []); + const scheduleErrorToastDismiss = useCallback((toastId: number, delayMs: number) => { + const existingTimeoutId = toastTimeoutsRef.current.get(toastId); + if (existingTimeoutId != null) { + window.clearTimeout(existingTimeoutId); + toastTimeoutsRef.current.delete(toastId); + } + + const clampedDelayMs = Math.max(0, delayMs); + const timeoutId = window.setTimeout(() => { + dismissErrorToast(toastId); + }, clampedDelayMs); + + toastTimeoutsRef.current.set(toastId, timeoutId); + toastExpiryRef.current.set(toastId, Date.now() + clampedDelayMs); + toastRemainingMsRef.current.set(toastId, clampedDelayMs); + }, [dismissErrorToast]); + + const pauseErrorToastDismiss = useCallback((toastId: number) => { + const expiryMs = toastExpiryRef.current.get(toastId); + if (expiryMs == null) return; + const remainingMs = Math.max(0, expiryMs - Date.now()); + toastRemainingMsRef.current.set(toastId, remainingMs); + + const timeoutId = toastTimeoutsRef.current.get(toastId); + if (timeoutId != null) { + window.clearTimeout(timeoutId); + toastTimeoutsRef.current.delete(toastId); + } + toastExpiryRef.current.delete(toastId); + }, []); + + const resumeErrorToastDismiss = useCallback((toastId: number) => { + if (toastTimeoutsRef.current.has(toastId)) return; + const remainingMs = toastRemainingMsRef.current.get(toastId); + if (remainingMs == null) return; + scheduleErrorToastDismiss(toastId, remainingMs); + }, [scheduleErrorToastDismiss]); + const pushErrorToast = useCallback((error: unknown, fallback: string) => { const messageText = getErrorMessage(error, fallback).trim() || fallback; const toastId = toastIdRef.current++; @@ -328,14 +472,17 @@ export default function App() { } return [...prev, { id: toastId, message: messageText }].slice(-MAX_ERROR_TOASTS); }); - const timeoutId = window.setTimeout(() => { - dismissErrorToast(toastId); - }, ERROR_TOAST_MS); - toastTimeoutsRef.current.set(toastId, timeoutId); - }, [dismissErrorToast]); + scheduleErrorToastDismiss(toastId, ERROR_TOAST_MS); + }, [scheduleErrorToastDismiss]); // Subscribe to events for the current active session const subscribeToSession = useCallback((session: Session) => { + const generation = ++subscriptionGenerationRef.current; + const isCurrentSubscription = (): boolean => + subscriptionGenerationRef.current === generation + && activeSessionRef.current?.id === session.id + && selectedSessionIdRef.current === session.id; + // Unsubscribe from previous if (eventUnsubRef.current) { eventUnsubRef.current(); @@ -343,6 +490,13 @@ export default function App() { } activeSessionRef.current = session; + const cachedEvents = sessionEventsCacheRef.current.get(session.id); + if (cachedEvents && isCurrentSubscription()) { + setEvents(cachedEvents); + setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); + } else if (isCurrentSubscription()) { + setHistoryLoadingSessionId(session.id); + } // Hydrate existing events from persistence const hydrateEvents = async () => { @@ -358,13 +512,29 @@ export default function App() { if (!page.nextCursor) break; cursor = page.nextCursor; } - setEvents(allEvents); + sessionEventsCacheRef.current.set(session.id, allEvents); + if (!isCurrentSubscription()) return; + setEvents((prev) => (areEventsEqualById(prev, allEvents) ? prev : allEvents)); + setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); }; - hydrateEvents().catch(() => {}); + hydrateEvents().catch((error) => { + console.error("Failed to hydrate events:", error); + if (isCurrentSubscription()) { + setHistoryLoadingSessionId((current) => (current === session.id ? null : current)); + } + }); // Subscribe to new events const unsub = session.onEvent((event) => { - setEvents((prev) => [...prev, event]); + if (!isCurrentSubscription()) return; + setEvents((prev) => { + if (prev.some((existing) => existing.id === event.id)) { + return prev; + } + const next = [...prev, event]; + sessionEventsCacheRef.current.set(session.id, next); + return next; + }); }); eventUnsubRef.current = unsub; }, [getClient]); @@ -375,6 +545,23 @@ export default function App() { setConnectError(null); } try { + // Ensure reconnects do not keep stale session subscriptions/clients around. + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + subscriptionGenerationRef.current += 1; + activeSessionRef.current = null; + if (clientRef.current) { + try { + await clientRef.current.dispose(); + } catch (disposeError) { + console.warn("Failed to dispose previous client during reconnect:", disposeError); + } finally { + clientRef.current = null; + } + } + const client = await createClient(overrideEndpoint); await client.getHealth(); if (overrideEndpoint) { @@ -383,6 +570,15 @@ export default function App() { setConnected(true); await refreshAgents(); await fetchSessions(); + if (sessionId) { + try { + const resumed = await client.resumeSession(sessionId); + subscribeToSession(resumed); + } catch (resumeError) { + console.warn("Failed to resume current session after reconnect:", resumeError); + setHistoryLoadingSessionId(null); + } + } if (reportError) { setConnectError(null); } @@ -406,16 +602,21 @@ export default function App() { eventUnsubRef.current(); eventUnsubRef.current = null; } + subscriptionGenerationRef.current += 1; activeSessionRef.current = null; if (clientRef.current) { - void clientRef.current.dispose(); + void clientRef.current.dispose().catch((error) => { + console.warn("Failed to dispose client on disconnect:", error); + }); } setConnected(false); clientRef.current = null; setSessionError(null); setEvents([]); + setHistoryLoadingSessionId(null); setAgents([]); setSessions([]); + sessionEventsCacheRef.current.clear(); setAgentsLoading(false); setSessionsLoading(false); setAgentsError(null); @@ -436,12 +637,15 @@ export default function App() { }; const loadAgentConfig = useCallback(async (targetAgentId: string) => { + console.log("[loadAgentConfig] Loading config for agent:", targetAgentId); try { const info = await getClient().getAgent(targetAgentId, { config: true }); + console.log("[loadAgentConfig] Got agent info:", info); setAgents((prev) => prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a)) ); - } catch { + } catch (error) { + console.error("[loadAgentConfig] Failed to load config:", error); // Config loading is best-effort; the menu still works without it. } }, [getClient]); @@ -450,6 +654,7 @@ export default function App() { setSessionsLoading(true); setSessionsError(null); try { + const archivedSessionIds = getArchivedSessionIds(); // TODO: This eagerly paginates all sessions so we can reverse-sort to // show newest first. Replace with a server-side descending sort or a // dedicated "recent sessions" query once the API supports it. @@ -462,6 +667,7 @@ export default function App() { sessionId: s.id, agent: s.agent, ended: s.destroyedAt != null, + archived: archivedSessionIds.has(s.id), }); } cursor = page.nextCursor; @@ -475,6 +681,44 @@ export default function App() { } }; + const archiveSession = async (targetSessionId: string) => { + archiveSessionId(targetSessionId); + try { + try { + await getClient().destroySession(targetSessionId); + } catch (error) { + // If the server already considers the session gone, still archive in local UI. + console.warn("Destroy session returned an error while archiving:", error); + } + setSessions((prev) => + prev.map((session) => + session.sessionId === targetSessionId + ? { ...session, archived: true, ended: true } + : session + ) + ); + setSessionModelById((prev) => { + if (!(targetSessionId in prev)) return prev; + const next = { ...prev }; + delete next[targetSessionId]; + return next; + }); + await fetchSessions(); + } catch (error) { + console.error("Failed to archive session:", error); + } + }; + + const unarchiveSession = async (targetSessionId: string) => { + unarchiveSessionId(targetSessionId); + setSessions((prev) => + prev.map((session) => + session.sessionId === targetSessionId ? { ...session, archived: false } : session + ) + ); + await fetchSessions(); + }; + const installAgent = async (targetId: string, reinstall: boolean) => { try { await getClient().installAgent(targetId, { reinstall }); @@ -486,47 +730,57 @@ export default function App() { const sendMessage = async () => { const prompt = message.trim(); - if (!prompt || !sessionId || sending) return; + if (!prompt || !sessionId || sendingSessionId) return; + const targetSessionId = sessionId; setSessionError(null); setMessage(""); - setSending(true); + setSendingSessionId(targetSessionId); try { let session = activeSessionRef.current; - if (!session || session.id !== sessionId) { - session = await getClient().resumeSession(sessionId); + if (!session || session.id !== targetSessionId) { + session = await getClient().resumeSession(targetSessionId); subscribeToSession(session); } await session.prompt([{ type: "text", text: prompt }]); } catch (error) { setSessionError(getErrorMessage(error, "Unable to send message")); } finally { - setSending(false); + setSendingSessionId(null); } }; - const selectSession = async (session: SessionListItem) => { + const selectSession = (session: SessionListItem) => { setSessionId(session.sessionId); + selectedSessionIdRef.current = session.sessionId; updateSessionPath(session.sessionId); setAgentId(session.agent); - setEvents([]); - setSessionError(null); - - try { - const sdkSession = await getClient().resumeSession(session.sessionId); - subscribeToSession(sdkSession); - } catch (error) { - setSessionError(getErrorMessage(error, "Unable to load session")); + setSessionModelById((prev) => { + if (prev[session.sessionId]) return prev; + const fallbackModel = defaultModelByAgent[session.agent]; + if (!fallbackModel) return prev; + return { ...prev, [session.sessionId]: fallbackModel }; + }); + const cachedEvents = sessionEventsCacheRef.current.get(session.sessionId); + if (cachedEvents) { + setEvents(cachedEvents); + setHistoryLoadingSessionId(null); + } else { + setEvents([]); + setHistoryLoadingSessionId(session.sessionId); } + setSessionError(null); }; const createNewSession = async (nextAgentId: string, config: { agentMode: string; model: string }) => { - setAgentId(nextAgentId); + console.log("[createNewSession] Creating session for agent:", nextAgentId, "config:", config); setSessionError(null); - setEvents([]); + creatingSessionRef.current = true; + createNoiseIgnoreUntilRef.current = Date.now() + 10_000; try { - const session = await getClient().createSession({ + console.log("[createNewSession] Calling createSession..."); + const createSessionPromise = getClient().createSession({ agent: nextAgentId, sessionInit: { cwd: "/", @@ -534,12 +788,34 @@ export default function App() { }, }); + let slowWarningShown = false; + const slowWarningTimerId = window.setTimeout(() => { + slowWarningShown = true; + setSessionError("Session creation is taking longer than expected. Waiting for agent startup..."); + }, CREATE_SESSION_SLOW_WARNING_MS); + let session: Awaited>; + try { + session = await createSessionPromise; + } finally { + window.clearTimeout(slowWarningTimerId); + } + console.log("[createNewSession] Session created:", session.id); + if (slowWarningShown) { + setSessionError(null); + } + + setAgentId(nextAgentId); + setEvents([]); + setHistoryLoadingSessionId(null); setSessionId(session.id); + selectedSessionIdRef.current = session.id; updateSessionPath(session.id); + sessionEventsCacheRef.current.set(session.id, []); subscribeToSession(session); + const skipPostCreateConfig = nextAgentId === "opencode"; // Apply mode if selected - if (config.agentMode) { + if (!skipPostCreateConfig && config.agentMode) { try { await session.send("session/set_mode", { modeId: config.agentMode }); } catch { @@ -549,18 +825,49 @@ export default function App() { // Apply model if selected if (config.model) { - try { - await session.send("unstable/set_session_model", { modelId: config.model }); - } catch { - // Model application is best-effort + setSessionModelById((prev) => ({ ...prev, [session.id]: config.model })); + if (!skipPostCreateConfig) { + try { + const agentInfo = agents.find((agent) => agent.id === nextAgentId); + const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find( + (opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string" + ); + if (modelOption && config.model !== modelOption.currentValue) { + await session.send("session/set_config_option", { + optionId: modelOption.id, + value: config.model, + }); + } + } catch { + // Model application is best-effort + } } } - await fetchSessions(); + // Refresh session list in background; UI should not stay blocked on list pagination. + void fetchSessions(); } catch (error) { + console.error("[createNewSession] Failed to create session:", error); const messageText = getErrorMessage(error, "Unable to create session"); + console.error("[createNewSession] Error message:", messageText); setSessionError(messageText); pushErrorToast(error, messageText); + if (!reconnectingAfterCreateFailureRef.current) { + reconnectingAfterCreateFailureRef.current = true; + // Run recovery in background so failed creates do not block UI. + void connectToDaemon(false) + .catch((reconnectError) => { + console.error("[createNewSession] Soft reconnect failed:", reconnectError); + }) + .finally(() => { + reconnectingAfterCreateFailureRef.current = false; + }); + } + throw error; + } finally { + creatingSessionRef.current = false; + // Keep a short post-create window for delayed transport rejections. + createNoiseIgnoreUntilRef.current = Date.now() + 2_000; } }; @@ -659,9 +966,20 @@ export default function App() { flushThought(time); const params = payload.params as Record | undefined; const promptArray = params?.prompt as Array<{ type: string; text?: string }> | undefined; - const text = promptArray?.[0]?.text ?? ""; + const replayPrefix = "Previous session history is replayed below"; + const text = (promptArray ?? []) + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text!.trim()) + // SDK replay prompt is prepended to the real user prompt; drop only replay blocks. + .filter((partText) => partText.length > 0 && !partText.startsWith(replayPrefix)) + .join("\n\n") + .trim(); + if (!text) { + continue; + } entries.push({ id: event.id, + eventId: event.id, kind: "message", time, role: "user", @@ -684,6 +1002,7 @@ export default function App() { assistantAccumText = ""; entries.push({ id: assistantAccumId, + eventId: event.id, kind: "message", time, role: "assistant", @@ -707,6 +1026,7 @@ export default function App() { thoughtAccumText = ""; entries.push({ id: thoughtAccumId, + eventId: event.id, kind: "reasoning", time, reasoning: { text: "", visibility: "public" }, @@ -726,6 +1046,7 @@ export default function App() { const text = content?.type === "text" ? (content.text ?? "") : JSON.stringify(content); entries.push({ id: event.id, + eventId: event.id, kind: "message", time, role: "user", @@ -748,6 +1069,7 @@ export default function App() { } else { const entry: TimelineEntry = { id: `tool-${toolCallId}`, + eventId: event.id, kind: "tool", time, toolName: (update.title as string) ?? "tool", @@ -776,6 +1098,7 @@ export default function App() { const detail = planEntries.map((e) => `[${e.status}] ${e.content}`).join("\n"); entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Plan", detail, severity: "info" }, @@ -786,6 +1109,7 @@ export default function App() { const title = update.title as string | undefined; entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Session info update", detail: title ? `Title: ${title}` : undefined, severity: "info" }, @@ -793,24 +1117,13 @@ export default function App() { break; } case "usage_update": { - const size = update.size as number | undefined; - const used = update.used as number | undefined; - const cost = update.cost as { total?: number } | undefined; - const parts = [`${used ?? 0}/${size ?? 0} tokens`]; - if (cost?.total != null) { - parts.push(`cost: $${cost.total.toFixed(4)}`); - } - entries.push({ - id: event.id, - kind: "meta", - time, - meta: { title: "Usage update", detail: parts.join(" | "), severity: "info" }, - }); + // Token usage is displayed in the config bar, not in the transcript break; } case "current_mode_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Mode changed", detail: update.currentModeId as string, severity: "info" }, @@ -820,6 +1133,7 @@ export default function App() { case "config_option_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Config option update", severity: "info" }, @@ -829,6 +1143,7 @@ export default function App() { case "available_commands_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Available commands update", severity: "info" }, @@ -838,6 +1153,7 @@ export default function App() { default: { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: `session/update: ${update.sessionUpdate}`, severity: "info" }, @@ -852,6 +1168,7 @@ export default function App() { const params = payload.params as { error?: string; location?: string } | undefined; entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { @@ -867,6 +1184,7 @@ export default function App() { if (method) { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: method, detail: event.sender, severity: "info" }, @@ -887,10 +1205,32 @@ export default function App() { }, []); useEffect(() => { + const shouldIgnoreCreateNoise = (value: unknown): boolean => { + if (Date.now() > createNoiseIgnoreUntilRef.current) return false; + const message = getErrorMessage(value, "").trim().toLowerCase(); + return ( + message.length === 0 || + message === "request failed" || + message.includes("request failed") || + message.includes("unhandled promise rejection") + ); + }; + const handleWindowError = (event: ErrorEvent) => { - pushErrorToast(event.error ?? event.message, "Unexpected error"); + const errorLike = event.error ?? event.message; + if (shouldIgnoreCreateNoise(errorLike)) return; + if (shouldIgnoreGlobalError(errorLike)) return; + pushErrorToast(errorLike, "Unexpected error"); }; const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + if (shouldIgnoreCreateNoise(event.reason)) { + event.preventDefault(); + return; + } + if (shouldIgnoreGlobalError(event.reason)) { + event.preventDefault(); + return; + } pushErrorToast(event.reason, "Unhandled promise rejection"); }; const handleHttpError = (event: Event) => { @@ -915,6 +1255,8 @@ export default function App() { window.clearTimeout(timeoutId); } toastTimeoutsRef.current.clear(); + toastExpiryRef.current.clear(); + toastRemainingMsRef.current.clear(); }; }, []); @@ -966,13 +1308,39 @@ export default function App() { // Auto-load session when sessionId changes useEffect(() => { if (!connected || !sessionId) return; - const hasSession = sessions.some((s) => s.sessionId === sessionId); - if (!hasSession) return; + if (creatingSessionRef.current) return; + if (resumeInFlightSessionIdRef.current === sessionId) return; + const sessionInfo = sessions.find((s) => s.sessionId === sessionId); + if (!sessionInfo) return; if (activeSessionRef.current?.id === sessionId) return; - getClient().resumeSession(sessionId).then((session) => { + // Set the correct agent from the session + setAgentId(sessionInfo.agent); + const cachedEvents = sessionEventsCacheRef.current.get(sessionId); + if (cachedEvents) { + setEvents(cachedEvents); + setHistoryLoadingSessionId(null); + } else { + setEvents([]); + setHistoryLoadingSessionId(sessionId); + } + setSessionError(null); + + const requestedSessionId = sessionId; + resumeInFlightSessionIdRef.current = requestedSessionId; + + getClient().resumeSession(requestedSessionId).then((session) => { + if (selectedSessionIdRef.current !== requestedSessionId) return; subscribeToSession(session); - }).catch(() => {}); + }).catch((error) => { + if (selectedSessionIdRef.current !== requestedSessionId) return; + setSessionError(getErrorMessage(error, "Unable to resume session")); + setHistoryLoadingSessionId((current) => (current === requestedSessionId ? null : current)); + }).finally(() => { + if (resumeInFlightSessionIdRef.current === requestedSessionId) { + resumeInFlightSessionIdRef.current = null; + } + }); }, [connected, sessionId, sessions, getClient, subscribeToSession]); useEffect(() => { @@ -981,7 +1349,59 @@ export default function App() { const currentAgent = agents.find((agent) => agent.id === agentId); const agentLabel = agentDisplayNames[agentId] ?? agentId; - const sessionEnded = sessions.find((s) => s.sessionId === sessionId)?.ended ?? false; + const selectedSession = sessions.find((s) => s.sessionId === sessionId); + const sessionArchived = selectedSession?.archived ?? false; + // Archived sessions are treated as ended in UI so they can never be "ended again". + const sessionEnded = (selectedSession?.ended ?? false) || sessionArchived; + + // Determine if agent is thinking (has in-progress tools or waiting for response) + const isThinking = useMemo(() => { + if (!sessionId || sessionEnded) return false; + // If actively sending a prompt, show thinking + if (sendingSessionId === sessionId) return true; + // Check for in-progress tool calls + const hasInProgressTool = transcriptEntries.some( + (e) => e.kind === "tool" && e.toolStatus === "in_progress" + ); + if (hasInProgressTool) return true; + // Check if last message was from user with no subsequent agent activity + const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user"); + if (lastUserMessageIndex === -1) return false; + // If user message is the very last entry, we're waiting for response + if (lastUserMessageIndex === 0) return true; + // Check if there's any agent response after the user message + const entriesAfterUser = transcriptEntries.slice(-(lastUserMessageIndex)); + const hasAgentResponse = entriesAfterUser.some( + (e) => e.kind === "message" && e.role === "assistant" + ); + // If no assistant message after user, but there are completed tools, not thinking + const hasCompletedTools = entriesAfterUser.some( + (e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed") + ); + if (!hasAgentResponse && !hasCompletedTools) return true; + return false; + }, [sessionId, sessionEnded, transcriptEntries, sendingSessionId]); + + // Extract latest token usage from events + const tokenUsage = useMemo(() => { + let latest: { used: number; size: number; cost?: number } | null = null; + for (const event of events) { + const payload = event.payload as Record; + const method = typeof payload.method === "string" ? payload.method : null; + if (event.sender === "agent" && method === "session/update") { + const params = payload.params as Record | undefined; + const update = params?.update as Record | undefined; + if (update?.sessionUpdate === "usage_update") { + latest = { + used: (update.used as number) ?? 0, + size: (update.size as number) ?? 0, + cost: (update.cost as { total?: number })?.total, + }; + } + } + } + return latest; + }, [events]); // Extract modes and models from configOptions const modesByAgent = useMemo(() => { @@ -1030,6 +1450,88 @@ export default function App() { return result; }, [agents]); + const currentSessionModelId = useMemo(() => { + let latestModelId: string | null = null; + + for (const event of events) { + const payload = event.payload as Record; + const method = typeof payload.method === "string" ? payload.method : null; + const params = payload.params as Record | undefined; + + if (event.sender === "agent" && method === "session/update") { + const update = params?.update as Record | undefined; + if (update?.sessionUpdate !== "config_option_update") continue; + + const category = (update.category as string | undefined) + ?? ((update.option as Record | undefined)?.category as string | undefined); + if (category && category !== "model") continue; + + const optionId = (update.optionId as string | undefined) + ?? (update.configOptionId as string | undefined) + ?? ((update.option as Record | undefined)?.id as string | undefined); + const seemsModelOption = !optionId || optionId.toLowerCase().includes("model"); + if (!seemsModelOption) continue; + + const candidate = (update.value as string | undefined) + ?? (update.currentValue as string | undefined) + ?? (update.selectedValue as string | undefined) + ?? (update.modelId as string | undefined); + if (candidate) { + latestModelId = candidate; + } + continue; + } + + // Capture explicit client-side model changes; these are persisted and survive refresh. + if (event.sender === "client" && method === "unstable/set_session_model") { + const candidate = params?.modelId as string | undefined; + if (candidate) { + latestModelId = candidate; + } + continue; + } + + if (event.sender === "client" && method === "session/set_config_option") { + const category = params?.category as string | undefined; + const optionId = params?.optionId as string | undefined; + const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model")); + if (!seemsModelOption) continue; + const candidate = (params?.value as string | undefined) + ?? (params?.currentValue as string | undefined) + ?? (params?.modelId as string | undefined); + if (candidate) { + latestModelId = candidate; + } + } + } + + return latestModelId; + }, [events]); + + const modelPillLabel = useMemo(() => { + const sessionModelId = + currentSessionModelId + ?? (sessionId ? sessionModelById[sessionId] : undefined) + ?? (sessionId ? defaultModelByAgent[agentId] : undefined); + if (!sessionModelId) return null; + return sessionModelId; + }, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]); + + useEffect(() => { + if (!sessionId || !currentSessionModelId) return; + setSessionModelById((prev) => + prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId } + ); + }, [currentSessionModelId, sessionId]); + + useEffect(() => { + try { + window.localStorage.setItem(SESSION_MODELS_KEY, JSON.stringify(sessionModelById)); + } catch { + // Ignore storage write failures. + } + }, [sessionModelById]); + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -1040,19 +1542,27 @@ export default function App() { const toastStack = (
{errorToasts.map((toast) => ( -
-
-
Request failed
-
{toast.message}
-
+
pauseErrorToastDismiss(toast.id)} + onMouseLeave={() => resumeErrorToastDismiss(toast.id)} + onFocus={() => pauseErrorToastDismiss(toast.id)} + onBlur={() => resumeErrorToastDismiss(toast.id)} + > +
+
Request failed
+
{toast.message}
+
))}
@@ -1083,6 +1593,7 @@ export default function App() {
Sandbox Agent + {endpoint}
@@ -1097,7 +1608,6 @@ export default function App() { Issues - {endpoint} @@ -1130,6 +1640,7 @@ export default function App() { { + if (sessionId) { + void archiveSession(sessionId); + } + }} + onUnarchiveSession={() => { + if (sessionId) { + void unarchiveSession(sessionId); + } + }} modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} + onEventClick={(eventId) => { + setDebugTab("events"); + setHighlightedEventId(eventId); + }} + isThinking={isThinking} + agentId={agentId} + tokenUsage={tokenUsage} /> setEvents([])} + highlightedEventId={highlightedEventId} + onClearHighlight={() => setHighlightedEventId(null)} requestLog={requestLog} copiedLogId={copiedLogId} onClearRequestLog={() => setRequestLog([])} diff --git a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx index b20b0af..e952890 100644 --- a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx +++ b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx @@ -16,7 +16,17 @@ const agentLabels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", - amp: "Amp" + amp: "Amp", + pi: "Pi", + cursor: "Cursor" +}; + +const agentLogos: Record = { + claude: `${import.meta.env.BASE_URL}logos/claude.svg`, + codex: `${import.meta.env.BASE_URL}logos/openai.svg`, + opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`, + amp: `${import.meta.env.BASE_URL}logos/amp.svg`, + pi: `${import.meta.env.BASE_URL}logos/pi.svg`, }; const SessionCreateMenu = ({ @@ -37,7 +47,7 @@ const SessionCreateMenu = ({ modesByAgent: Record; modelsByAgent: Record; defaultModelByAgent: Record; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; open: boolean; onClose: () => void; @@ -48,7 +58,7 @@ const SessionCreateMenu = ({ const [selectedModel, setSelectedModel] = useState(""); const [customModel, setCustomModel] = useState(""); const [isCustomModel, setIsCustomModel] = useState(false); - const [configLoadDone, setConfigLoadDone] = useState(false); + const [creating, setCreating] = useState(false); // Reset state when menu closes useEffect(() => { @@ -59,18 +69,10 @@ const SessionCreateMenu = ({ setSelectedModel(""); setCustomModel(""); setIsCustomModel(false); - setConfigLoadDone(false); + setCreating(false); } }, [open]); - // Transition to config phase after load completes — deferred via useEffect - // so parent props (modelsByAgent) have settled before we render the config form - useEffect(() => { - if (phase === "loading-config" && configLoadDone) { - setPhase("config"); - } - }, [phase, configLoadDone]); - // Auto-select first mode when modes load for selected agent useEffect(() => { if (!selectedAgent) return; @@ -80,6 +82,14 @@ const SessionCreateMenu = ({ } }, [modesByAgent, selectedAgent, agentMode]); + // Agent-specific config should not leak between agent selections. + useEffect(() => { + setAgentMode(""); + setSelectedModel(""); + setCustomModel(""); + setIsCustomModel(false); + }, [selectedAgent]); + // Auto-select default model when agent is selected useEffect(() => { if (!selectedAgent) return; @@ -99,21 +109,21 @@ const SessionCreateMenu = ({ const handleAgentClick = (agentId: string) => { setSelectedAgent(agentId); - setPhase("loading-config"); - setConfigLoadDone(false); - onSelectAgent(agentId).finally(() => { - setConfigLoadDone(true); + setPhase("config"); + // Load agent config in background; creation should not block on this call. + void onSelectAgent(agentId).catch((error) => { + console.error("[SessionCreateMenu] Failed to load agent config:", error); }); }; const handleBack = () => { + if (creating) return; setPhase("agent"); setSelectedAgent(""); setAgentMode(""); setSelectedModel(""); setCustomModel(""); setIsCustomModel(false); - setConfigLoadDone(false); }; const handleModelSelectChange = (value: string) => { @@ -129,9 +139,17 @@ const SessionCreateMenu = ({ const resolvedModel = isCustomModel ? customModel : selectedModel; - const handleCreate = () => { - onCreateSession(selectedAgent, { agentMode, model: resolvedModel }); - onClose(); + const handleCreate = async () => { + if (!selectedAgent) return; + setCreating(true); + try { + await onCreateSession(selectedAgent, { agentMode, model: resolvedModel }); + onClose(); + } catch (error) { + console.error("[SessionCreateMenu] Failed to create session:", error); + } finally { + setCreating(false); + } }; if (phase === "agent") { @@ -142,43 +160,57 @@ const SessionCreateMenu = ({ {!agentsLoading && !agentsError && agents.length === 0 && (
No agents available.
)} - {!agentsLoading && !agentsError && - agents.map((agent) => ( - - ))} + {!agentsLoading && !agentsError && (() => { + const codingAgents = agents.filter((a) => a.id !== "mock"); + const mockAgent = agents.find((a) => a.id === "mock"); + return ( + <> + {codingAgents.map((agent) => ( + + ))} + {mockAgent && ( + <> +
+ + + )} + + ); + })()}
); } const agentLabel = agentLabels[selectedAgent] ?? selectedAgent; - if (phase === "loading-config") { - return ( -
-
- - {agentLabel} -
-
Loading config...
-
- ); - } - // Phase 2: config form const activeModes = modesByAgent[selectedAgent] ?? []; const activeModels = modelsByAgent[selectedAgent] ?? []; @@ -257,8 +289,8 @@ const SessionCreateMenu = ({
-
diff --git a/frontend/packages/inspector/src/components/SessionSidebar.tsx b/frontend/packages/inspector/src/components/SessionSidebar.tsx index d45df21..311032b 100644 --- a/frontend/packages/inspector/src/components/SessionSidebar.tsx +++ b/frontend/packages/inspector/src/components/SessionSidebar.tsx @@ -1,6 +1,7 @@ -import { Plus, RefreshCw } from "lucide-react"; +import { Archive, ArrowLeft, ArrowUpRight, Plus, RefreshCw } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { AgentInfo } from "sandbox-agent"; +import { formatShortId } from "../utils/format"; type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; @@ -10,6 +11,7 @@ type SessionListItem = { sessionId: string; agent: string; ended: boolean; + archived: boolean; }; const agentLabels: Record = { @@ -21,6 +23,7 @@ const agentLabels: Record = { cursor: "Cursor" }; const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence"; +const MIN_REFRESH_SPIN_MS = 350; const SessionSidebar = ({ sessions, @@ -42,7 +45,7 @@ const SessionSidebar = ({ selectedSessionId: string; onSelectSession: (session: SessionListItem) => void; onRefresh: () => void; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; agents: AgentInfo[]; agentsLoading: boolean; @@ -54,7 +57,16 @@ const SessionSidebar = ({ defaultModelByAgent: Record; }) => { const [showMenu, setShowMenu] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [refreshing, setRefreshing] = useState(false); const menuRef = useRef(null); + const archivedCount = sessions.filter((session) => session.archived).length; + const activeSessions = sessions.filter((session) => !session.archived); + const archivedSessions = sessions.filter((session) => session.archived); + const visibleSessions = showArchived ? archivedSessions : activeSessions; + const orderedVisibleSessions = showArchived + ? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended)) + : visibleSessions; useEffect(() => { if (!showMenu) return; @@ -68,13 +80,54 @@ const SessionSidebar = ({ return () => document.removeEventListener("mousedown", handler); }, [showMenu]); + useEffect(() => { + // Prevent getting stuck in archived view when there are no archived sessions. + if (!showArchived) return; + if (archivedSessions.length === 0) { + setShowArchived(false); + } + }, [showArchived, archivedSessions.length]); + + const handleRefresh = async () => { + if (refreshing) return; + const startedAt = Date.now(); + setRefreshing(true); + try { + await Promise.resolve(onRefresh()); + } finally { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < MIN_REFRESH_SPIN_MS) { + await new Promise((resolve) => window.setTimeout(resolve, MIN_REFRESH_SPIN_MS - elapsedMs)); + } + setRefreshing(false); + } + }; + return (
Sessions
- + )} +
) : sessionsError ? (
{sessionsError}
- ) : sessions.length === 0 ? ( -
No sessions yet.
+ ) : visibleSessions.length === 0 ? ( +
{showArchived ? "No archived sessions." : "No sessions yet."}
) : ( - sessions.map((session) => ( - - )) + <> + {showArchived &&
Archived Sessions
} + {orderedVisibleSessions.map((session) => ( +
+ +
+ ))} + )}
Sessions are persisted in your browser using IndexedDB.{" "} - + Configure persistence + - .
); diff --git a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx index b57f13c..b35943b 100644 --- a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx @@ -1,130 +1,256 @@ import { useState } from "react"; -import { getAvatarLabel, getMessageClass } from "./messageUtils"; +import { getMessageClass } from "./messageUtils"; import type { TimelineEntry } from "./types"; -import { AlertTriangle, Settings, ChevronRight, ChevronDown } from "lucide-react"; +import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react"; -const CollapsibleMessage = ({ - id, - icon, - label, - children, - className = "" +const ToolItem = ({ + entry, + isLast, + onEventClick }: { - id: string; - icon: React.ReactNode; - label: string; - children: React.ReactNode; - className?: string; + entry: TimelineEntry; + isLast: boolean; + onEventClick?: (eventId: string) => void; }) => { const [expanded, setExpanded] = useState(false); + const isTool = entry.kind === "tool"; + const isReasoning = entry.kind === "reasoning"; + const isMeta = entry.kind === "meta"; + + const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed"); + const isFailed = isTool && entry.toolStatus === "failed"; + const isInProgress = isTool && entry.toolStatus === "in_progress"; + + let label = ""; + let icon = ; + + if (isTool) { + const statusLabel = entry.toolStatus && entry.toolStatus !== "completed" + ? ` (${entry.toolStatus.replace("_", " ")})` + : ""; + label = `${entry.toolName ?? "tool"}${statusLabel}`; + icon = ; + } else if (isReasoning) { + label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`; + icon = ; + } else if (isMeta) { + label = entry.meta?.title ?? "Status"; + icon = entry.meta?.severity === "error" ? : ; + } + + const hasContent = isTool + ? Boolean(entry.toolInput || entry.toolOutput) + : isReasoning + ? Boolean(entry.reasoning?.text?.trim()) + : Boolean(entry.meta?.detail?.trim()); + const canOpenEvent = Boolean( + entry.eventId && + onEventClick && + !(isMeta && entry.meta?.title === "Available commands update"), + ); + return ( -
- - {expanded &&
{children}
} +
+
+
+ {!isLast &&
} +
+
+ + {expanded && hasContent && ( +
+ {isTool && entry.toolInput && ( +
+
Input
+
{entry.toolInput}
+
+ )} + {isTool && isComplete && entry.toolOutput && ( +
+
Output
+
{entry.toolOutput}
+
+ )} + {isReasoning && entry.reasoning?.text && ( +
+
{entry.reasoning.text}
+
+ )} + {isMeta && entry.meta?.detail && ( +
+
{entry.meta.detail}
+
+ )} +
+ )} +
); }; +const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => { + const [expanded, setExpanded] = useState(false); + + // If only one item, render it directly without macro wrapper + if (entries.length === 1) { + return ( +
+ +
+ ); + } + + const totalCount = entries.length; + const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`; + + // Check if any are in progress + const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress"); + const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed"); + + return ( +
+ + {expanded && ( +
+ {entries.map((entry, idx) => ( + + ))} +
+ )} +
+ ); +}; + +const agentLogos: Record = { + claude: `${import.meta.env.BASE_URL}logos/claude.svg`, + codex: `${import.meta.env.BASE_URL}logos/openai.svg`, + opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`, + amp: `${import.meta.env.BASE_URL}logos/amp.svg`, + pi: `${import.meta.env.BASE_URL}logos/pi.svg`, +}; + const ChatMessages = ({ entries, sessionError, - messagesEndRef + eventError, + messagesEndRef, + onEventClick, + isThinking, + agentId }: { entries: TimelineEntry[]; sessionError: string | null; + eventError?: string | null; messagesEndRef: React.RefObject; + onEventClick?: (eventId: string) => void; + isThinking?: boolean; + agentId?: string; }) => { + // Group consecutive tool/reasoning/meta entries together + const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = []; + + let currentToolGroup: TimelineEntry[] = []; + + const flushToolGroup = () => { + if (currentToolGroup.length > 0) { + groupedEntries.push({ type: "tool-group", entries: currentToolGroup }); + currentToolGroup = []; + } + }; + + for (const entry of entries) { + const isStatusDivider = entry.kind === "meta" && + ["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? ""); + + if (isStatusDivider) { + flushToolGroup(); + groupedEntries.push({ type: "divider", entries: [entry] }); + } else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) { + currentToolGroup.push(entry); + } else if (entry.kind === "meta" && !entry.meta?.detail) { + // Simple meta without detail - add to tool group as single item + currentToolGroup.push(entry); + } else { + // Regular message + flushToolGroup(); + groupedEntries.push({ type: "message", entries: [entry] }); + } + } + flushToolGroup(); + return (
- {entries.map((entry) => { - const messageClass = getMessageClass(entry); - - if (entry.kind === "meta") { - const isError = entry.meta?.severity === "error"; + {groupedEntries.map((group, idx) => { + if (group.type === "divider") { + const entry = group.entries[0]; const title = entry.meta?.title ?? "Status"; - const isStatusDivider = ["Session Started", "Turn Started", "Turn Ended"].includes(title); - - if (isStatusDivider) { - return ( -
-
- - - {title} - -
-
- ); - } - return ( - : } - label={title} - className={isError ? "error" : "system"} - > - {entry.meta?.detail &&
{entry.meta.detail}
} -
- ); - } - - if (entry.kind === "reasoning") { - return ( -
-
{getAvatarLabel("assistant")}
-
-
- reasoning - {entry.reasoning?.visibility ?? "public"} -
-
{entry.reasoning?.text ?? ""}
-
+
+
+ {title} +
); } - if (entry.kind === "tool") { - const isComplete = entry.toolStatus === "completed" || entry.toolStatus === "failed"; - const isFailed = entry.toolStatus === "failed"; - const statusLabel = entry.toolStatus && entry.toolStatus !== "completed" - ? entry.toolStatus.replace("_", " ") - : ""; - - return ( - T} - label={`tool call - ${entry.toolName ?? "tool"}${statusLabel ? ` (${statusLabel})` : ""}`} - className={`tool${isFailed ? " error" : ""}`} - > - {entry.toolInput &&
{entry.toolInput}
} - {isComplete && entry.toolOutput && ( -
-
result
-
{entry.toolOutput}
-
- )} - {!isComplete && !entry.toolInput && ( - - - - - - )} -
- ); + if (group.type === "tool-group") { + return ; } + // Regular message + const entry = group.entries[0]; + const messageClass = getMessageClass(entry); + return ( -
-
{getAvatarLabel(messageClass)}
+
{entry.text ? (
{entry.text}
@@ -140,6 +266,23 @@ const ChatMessages = ({ ); })} {sessionError &&
{sessionError}
} + {eventError &&
{eventError}
} + {isThinking && ( +
+
+ {agentId && agentLogos[agentId] ? ( + + ) : ( + AI + )} +
+ + + + + +
+ )}
); diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 9c64889..273ccf6 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -1,6 +1,7 @@ -import { CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react"; +import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { AgentInfo } from "sandbox-agent"; +import { formatShortId } from "../../utils/format"; type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; @@ -9,9 +10,27 @@ import ChatInput from "./ChatInput"; import ChatMessages from "./ChatMessages"; import type { TimelineEntry } from "./types"; +const HistoryLoadingSkeleton = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + const ChatPanel = ({ sessionId, transcriptEntries, + isLoadingHistory, sessionError, message, onMessageChange, @@ -24,35 +43,53 @@ const ChatPanel = ({ agentsError, messagesEndRef, agentLabel, + modelLabel, currentAgentVersion, sessionEnded, + sessionArchived, onEndSession, + onArchiveSession, + onUnarchiveSession, modesByAgent, modelsByAgent, defaultModelByAgent, + onEventClick, + isThinking, + agentId, + tokenUsage, }: { sessionId: string; transcriptEntries: TimelineEntry[]; + isLoadingHistory?: boolean; sessionError: string | null; message: string; onMessageChange: (value: string) => void; onSendMessage: () => void; onKeyDown: (event: React.KeyboardEvent) => void; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; messagesEndRef: React.RefObject; agentLabel: string; + modelLabel?: string | null; currentAgentVersion?: string | null; sessionEnded: boolean; + sessionArchived: boolean; onEndSession: () => void; + onArchiveSession: () => void; + onUnarchiveSession: () => void; modesByAgent: Record; modelsByAgent: Record; defaultModelByAgent: Record; + onEventClick?: (eventId: string) => void; + isThinking?: boolean; + agentId?: string; + tokenUsage?: { used: number; size: number; cost?: number } | null; }) => { const [showAgentMenu, setShowAgentMenu] = useState(false); + const [copiedSessionId, setCopiedSessionId] = useState(false); const menuRef = useRef(null); useEffect(() => { @@ -67,21 +104,92 @@ const ChatPanel = ({ return () => document.removeEventListener("mousedown", handler); }, [showAgentMenu]); + const copySessionId = async () => { + if (!sessionId) return; + const onSuccess = () => { + setCopiedSessionId(true); + window.setTimeout(() => setCopiedSessionId(false), 1200); + }; + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(sessionId); + onSuccess(); + return; + } + } catch { + // Fallback below for older/insecure contexts. + } + + const textarea = document.createElement("textarea"); + textarea.value = sessionId; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + onSuccess(); + } finally { + document.body.removeChild(textarea); + } + }; + + const handleArchiveSession = () => { + if (!sessionId) return; + onArchiveSession(); + }; + + const handleUnarchiveSession = () => { + if (!sessionId) return; + onUnarchiveSession(); + }; + return (
- {sessionId ? "Session" : "No Session"} - {sessionId && {sessionId}} + {sessionId ? agentLabel : "No Session"} + {sessionId && modelLabel && ( + + {modelLabel} + + )} + {sessionId && currentAgentVersion && ( + v{currentAgentVersion} + )} + {sessionId && ( + + )}
+ {sessionId && tokenUsage && ( + {tokenUsage.used.toLocaleString()} tokens + )} {sessionId && ( sessionEnded ? ( - - - Ended - + <> + + + Ended + + + ) : (
+ {sessionError && ( +
+ + {sessionError} +
+ )} +
{!sessionId ? (
-
No Session Selected
-

Create a new session to start chatting with an agent.

+

Create a new session to start chatting with an agent.

) : transcriptEntries.length === 0 && !sessionError ? ( -
- -
Ready to Chat
-

Send a message to start a conversation with the agent.

-
+ isLoadingHistory ? ( + + ) : ( +
+ +
Ready to Chat
+

Send a message to start a conversation with the agent.

+
+ ) ) : ( )}
@@ -145,24 +267,9 @@ const ChatPanel = ({ onMessageChange={onMessageChange} onSendMessage={onSendMessage} onKeyDown={onKeyDown} - placeholder={sessionId ? "Send a message..." : "Select or create a session first"} - disabled={!sessionId} + placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"} + disabled={!sessionId || sessionEnded} /> - - {sessionId && ( -
-
- Agent - {agentLabel} -
- {currentAgentVersion && ( -
- Version - {currentAgentVersion} -
- )} -
- )}
); }; diff --git a/frontend/packages/inspector/src/components/chat/messageUtils.tsx b/frontend/packages/inspector/src/components/chat/messageUtils.tsx index bdf9f2d..148f3d1 100644 --- a/frontend/packages/inspector/src/components/chat/messageUtils.tsx +++ b/frontend/packages/inspector/src/components/chat/messageUtils.tsx @@ -1,5 +1,5 @@ import type { TimelineEntry } from "./types"; -import { Settings, AlertTriangle, User } from "lucide-react"; +import { Settings, AlertTriangle } from "lucide-react"; import type { ReactNode } from "react"; export const getMessageClass = (entry: TimelineEntry) => { @@ -11,7 +11,7 @@ export const getMessageClass = (entry: TimelineEntry) => { }; export const getAvatarLabel = (messageClass: string): ReactNode => { - if (messageClass === "user") return ; + if (messageClass === "user") return null; if (messageClass === "tool") return "T"; if (messageClass === "system") return ; if (messageClass === "error") return ; diff --git a/frontend/packages/inspector/src/components/chat/types.ts b/frontend/packages/inspector/src/components/chat/types.ts index bd41778..a748b02 100644 --- a/frontend/packages/inspector/src/components/chat/types.ts +++ b/frontend/packages/inspector/src/components/chat/types.ts @@ -1,5 +1,6 @@ export type TimelineEntry = { id: string; + eventId?: string; // Links back to the original event for navigation kind: "message" | "tool" | "meta" | "reasoning"; time: string; // For messages: diff --git a/frontend/packages/inspector/src/components/debug/AgentsTab.tsx b/frontend/packages/inspector/src/components/debug/AgentsTab.tsx index 0f23702..c666c73 100644 --- a/frontend/packages/inspector/src/components/debug/AgentsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/AgentsTab.tsx @@ -5,6 +5,7 @@ import type { AgentInfo } from "sandbox-agent"; type AgentModeInfo = { id: string; name: string; description: string }; import FeatureCoverageBadges from "../agents/FeatureCoverageBadges"; import { emptyFeatureCoverage } from "../../types/agents"; +const MIN_REFRESH_SPIN_MS = 350; const AgentsTab = ({ agents, @@ -24,6 +25,7 @@ const AgentsTab = ({ error: string | null; }) => { const [installingAgent, setInstallingAgent] = useState(null); + const [refreshing, setRefreshing] = useState(false); const handleInstall = async (agentId: string, reinstall: boolean) => { setInstallingAgent(agentId); @@ -34,16 +36,30 @@ const AgentsTab = ({ } }; + const handleRefresh = async () => { + if (refreshing) return; + const startedAt = Date.now(); + setRefreshing(true); + try { + await Promise.resolve(onRefresh()); + } finally { + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < MIN_REFRESH_SPIN_MS) { + await new Promise((resolve) => window.setTimeout(resolve, MIN_REFRESH_SPIN_MS - elapsedMs)); + } + setRefreshing(false); + } + }; + return ( <>
-
{error &&
{error}
} - {loading &&
Loading agents...
} {!loading && agents.length === 0 && (
No agents reported. Click refresh to check.
)} diff --git a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx index f945355..cd9e816 100644 --- a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx +++ b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx @@ -16,6 +16,8 @@ const DebugPanel = ({ onDebugTabChange, events, onResetEvents, + highlightedEventId, + onClearHighlight, requestLog, copiedLogId, onClearRequestLog, @@ -33,6 +35,8 @@ const DebugPanel = ({ onDebugTabChange: (tab: DebugTab) => void; events: SessionEvent[]; onResetEvents: () => void; + highlightedEventId?: string | null; + onClearHighlight?: () => void; requestLog: RequestLog[]; copiedLogId: number | null; onClearRequestLog: () => void; @@ -86,6 +90,8 @@ const DebugPanel = ({ )} diff --git a/frontend/packages/inspector/src/components/debug/EventsTab.tsx b/frontend/packages/inspector/src/components/debug/EventsTab.tsx index 1568717..c0a69d4 100644 --- a/frontend/packages/inspector/src/components/debug/EventsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/EventsTab.tsx @@ -28,9 +28,9 @@ import { Wrench, type LucideIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { SessionEvent } from "sandbox-agent"; -import { formatJson, formatTime } from "../../utils/format"; +import { formatJson, formatShortId, formatTime } from "../../utils/format"; type EventIconInfo = { Icon: LucideIcon; category: string }; @@ -111,9 +111,13 @@ function getEventIcon(method: string, payload: Record): EventIc const EventsTab = ({ events, onClear, + highlightedEventId, + onClearHighlight, }: { events: SessionEvent[]; onClear: () => void; + highlightedEventId?: string | null; + onClearHighlight?: () => void; }) => { const [collapsedEvents, setCollapsedEvents] = useState>({}); const [copied, setCopied] = useState(false); @@ -155,6 +159,25 @@ const EventsTab = ({ } }, [events.length]); + // Scroll to highlighted event (with delay to ensure DOM is ready after tab switch) + useEffect(() => { + if (highlightedEventId) { + const scrollToEvent = () => { + const el = document.getElementById(`event-${highlightedEventId}`); + if (el) { + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Clear highlight after animation + setTimeout(() => { + onClearHighlight?.(); + }, 2000); + } + }; + // Small delay to ensure tab switch and DOM render completes + const timer = setTimeout(scrollToEvent, 100); + return () => clearTimeout(timer); + } + }, [highlightedEventId, onClearHighlight]); + const getMethod = (event: SessionEvent): string => { const payload = event.payload as Record; return typeof payload.method === "string" ? payload.method : "(response)"; @@ -200,8 +223,14 @@ const EventsTab = ({ const time = formatTime(new Date(event.createdAt).toISOString()); const senderClass = event.sender === "client" ? "client" : "agent"; + const isHighlighted = highlightedEventId === event.id; + return ( -
+
-
- {event.id} +
+ {formatShortId(event.id)}
diff --git a/frontend/packages/inspector/src/components/debug/McpTab.tsx b/frontend/packages/inspector/src/components/debug/McpTab.tsx index c6f259c..d9ebcf8 100644 --- a/frontend/packages/inspector/src/components/debug/McpTab.tsx +++ b/frontend/packages/inspector/src/components/debug/McpTab.tsx @@ -1,4 +1,4 @@ -import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; +import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { SandboxAgent } from "sandbox-agent"; import { formatJson } from "../../utils/format"; @@ -8,15 +8,25 @@ type McpEntry = { config: Record; }; +const MCP_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-mcp-directory"; + const McpTab = ({ getClient, }: { getClient: () => SandboxAgent; }) => { - const [directory, setDirectory] = useState("/"); + const [directory, setDirectory] = useState(() => { + if (typeof window === "undefined") return "/"; + try { + return window.localStorage.getItem(MCP_DIRECTORY_STORAGE_KEY) ?? "/"; + } catch { + return "/"; + } + }); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [collapsedServers, setCollapsedServers] = useState>({}); // Add/edit form state const [editing, setEditing] = useState(false); @@ -52,6 +62,14 @@ const McpTab = ({ loadAll(directory); }, [directory, loadAll]); + useEffect(() => { + try { + window.localStorage.setItem(MCP_DIRECTORY_STORAGE_KEY, directory); + } catch { + // Ignore storage failures. + } + }, [directory]); + const startAdd = () => { setEditing(true); setEditName(""); @@ -158,7 +176,7 @@ const McpTab = ({ value={editJson} onChange={(e) => { setEditJson(e.target.value); setEditError(null); }} rows={6} - style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11 }} + style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11, resize: "vertical" }} /> {editError &&
{editError}
}
@@ -180,29 +198,44 @@ const McpTab = ({
)} - {entries.map((entry) => ( -
-
- {entry.name} -
- - {(entry.config as { type?: string }).type ?? "unknown"} - - + {entries.map((entry) => { + const isCollapsed = collapsedServers[entry.name] ?? true; + return ( +
+
+
+ + {entry.name} +
+
+ + {(entry.config as { type?: string }).type ?? "unknown"} + + +
+ {!isCollapsed && ( +
+                {formatJson(entry.config)}
+              
+ )}
-
-            {formatJson(entry.config)}
-          
-
- ))} + ); + })} ); }; diff --git a/frontend/packages/inspector/src/components/debug/SkillsTab.tsx b/frontend/packages/inspector/src/components/debug/SkillsTab.tsx index cd2dd92..2a6fab3 100644 --- a/frontend/packages/inspector/src/components/debug/SkillsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/SkillsTab.tsx @@ -1,4 +1,4 @@ -import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; +import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { SandboxAgent } from "sandbox-agent"; import { formatJson } from "../../utils/format"; @@ -8,15 +8,49 @@ type SkillEntry = { config: { sources: Array<{ source: string; type: string; ref?: string | null; subpath?: string | null; skills?: string[] | null }> }; }; +const SKILLS_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-skills-directory"; + const SkillsTab = ({ getClient, }: { getClient: () => SandboxAgent; }) => { - const [directory, setDirectory] = useState("/"); + const officialSkills = [ + { + name: "Sandbox Agent SDK", + skillId: "sandbox-agent", + source: "rivet-dev/skills", + summary: "Skills bundle for fast Sandbox Agent SDK setup and consistent workflows.", + }, + { + name: "Rivet", + skillId: "rivet", + source: "rivet-dev/skills", + summary: "Open-source platform for building, deploying, and scaling AI agents.", + features: [ + "Session Persistence", + "Resumable Sessions", + "Multi-Agent Support", + "Realtime Events", + "Tool Call Visibility", + ], + }, + ]; + + const [directory, setDirectory] = useState(() => { + if (typeof window === "undefined") return "/"; + try { + return window.localStorage.getItem(SKILLS_DIRECTORY_STORAGE_KEY) ?? "/"; + } catch { + return "/"; + } + }); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [showSdkSkills, setShowSdkSkills] = useState(false); + const [collapsedSkills, setCollapsedSkills] = useState>({}); // Add form state const [editing, setEditing] = useState(false); @@ -56,6 +90,14 @@ const SkillsTab = ({ loadAll(directory); }, [directory, loadAll]); + useEffect(() => { + try { + window.localStorage.setItem(SKILLS_DIRECTORY_STORAGE_KEY, directory); + } catch { + // Ignore storage failures. + } + }, [directory]); + const startAdd = () => { setEditing(true); setEditName(""); @@ -128,11 +170,66 @@ const SkillsTab = ({ } }; + const fallbackCopy = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + }; + + const copyText = async (id: string, text: string) => { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(text); + } + setCopiedId(id); + window.setTimeout(() => { + setCopiedId((current) => (current === id ? null : current)); + }, 1800); + } catch { + setError("Failed to copy snippet"); + } + }; + + const applySkillPreset = (skill: typeof officialSkills[0]) => { + setEditing(true); + setEditName(skill.skillId); + setEditSource(skill.source); + setEditType("github"); + setEditRef(""); + setEditSubpath(""); + setEditSkills(skill.skillId); + setEditError(null); + setShowSdkSkills(false); + }; + + const copySkillToInput = async (skillId: string) => { + const skill = officialSkills.find((s) => s.skillId === skillId); + if (skill) { + applySkillPreset(skill); + await copyText(`skill-input-${skillId}`, skillId); + } + }; + return ( <>
Skills Configuration -
+
+ {!editing && (
+ {showSdkSkills && ( +
+
+ Pick a skill to auto-fill the form. +
+ {officialSkills.map((skill) => ( +
+
+
{skill.name}
+ +
+
{skill.summary}
+ {skill.features && ( +
+ {skill.features.map((feature) => ( + + {feature} + + ))} +
+ )} +
+ ))} +
+ )} +
)} - {entries.map((entry) => ( -
-
- {entry.name} -
- - {entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""} - - + {entries.map((entry) => { + const isCollapsed = collapsedSkills[entry.name] ?? true; + return ( +
+
+
+ + {entry.name} +
+
+ + {entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""} + + +
+ {!isCollapsed && ( +
+                {formatJson(entry.config)}
+              
+ )}
-
-            {formatJson(entry.config)}
-          
-
- ))} + ); + })} ); }; diff --git a/frontend/packages/inspector/src/utils/format.ts b/frontend/packages/inspector/src/utils/format.ts index fca69c7..cce1665 100644 --- a/frontend/packages/inspector/src/utils/format.ts +++ b/frontend/packages/inspector/src/utils/format.ts @@ -16,3 +16,9 @@ export const formatTime = (value: string) => { }; export const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`); + +export const formatShortId = (value: string, head = 8, tail = 4) => { + if (!value) return ""; + if (value.length <= head + tail + 1) return value; + return `${value.slice(0, head)}...${value.slice(-tail)}`; +}; diff --git a/frontend/packages/inspector/vite.config.ts b/frontend/packages/inspector/vite.config.ts index 076e709..5280cd7 100644 --- a/frontend/packages/inspector/vite.config.ts +++ b/frontend/packages/inspector/vite.config.ts @@ -11,10 +11,6 @@ export default defineConfig(({ command }) => ({ target: "http://localhost:2468", changeOrigin: true, }, - "/v1": { - target: "http://localhost:2468", - changeOrigin: true, - }, }, }, })); diff --git a/sdks/acp-http-client/src/index.ts b/sdks/acp-http-client/src/index.ts index 976a464..5f67997 100644 --- a/sdks/acp-http-client/src/index.ts +++ b/sdks/acp-http-client/src/index.ts @@ -271,6 +271,8 @@ class StreamableHttpAcpTransport { private closed = false; private closingPromise: Promise | null = null; private postedOnce = false; + private readonly seenResponseIds = new Set(); + private readonly seenResponseIdOrder: string[] = []; constructor(options: StreamableHttpAcpTransportOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); @@ -535,6 +537,21 @@ class StreamableHttpAcpTransport { return; } + const responseId = responseEnvelopeId(envelope); + if (responseId) { + if (this.seenResponseIds.has(responseId)) { + return; + } + this.seenResponseIds.add(responseId); + this.seenResponseIdOrder.push(responseId); + if (this.seenResponseIdOrder.length > 2048) { + const oldest = this.seenResponseIdOrder.shift(); + if (oldest) { + this.seenResponseIds.delete(oldest); + } + } + } + this.observeEnvelope(envelope, "inbound"); try { @@ -632,10 +649,32 @@ function buildClientHandlers(client?: Partial): Client { waitForTerminalExit: client?.waitForTerminalExit, killTerminal: client?.killTerminal, extMethod: client?.extMethod, - extNotification: client?.extNotification, + extNotification: async (method: string, params: Record) => { + if (client?.extNotification) { + await client.extNotification(method, params); + } + }, }; } +function responseEnvelopeId(message: AnyMessage): string | null { + if (typeof message !== "object" || message === null) { + return null; + } + const record = message as Record; + if ("method" in record) { + return null; + } + if (!("result" in record) && !("error" in record)) { + return null; + } + const id = record.id; + if (id === null || id === undefined) { + return null; + } + return String(id); +} + async function readProblem(response: Response): Promise { try { const text = await response.clone().text(); diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index e724e24..4e81909 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -175,6 +175,8 @@ export class LiveAcpConnection { private readonly pendingNewSessionLocals: string[] = []; private readonly pendingRequestSessionById = new Map(); private readonly pendingReplayByLocalSessionId = new Map(); + private lastAdapterExit: { success: boolean; code: number | null } | null = null; + private lastAdapterExitAt = 0; private readonly onObservedEnvelope: ( connection: LiveAcpConnection, @@ -230,6 +232,10 @@ export class LiveAcpConnection { sessionUpdate: async (_notification: SessionNotification) => { // Session updates are observed via envelope persistence. }, + extNotification: async (method: string, params: Record) => { + if (!live) return; + live.handleAdapterNotification(method, params); + }, }, onEnvelope: (envelope, direction) => { if (!live) { @@ -286,6 +292,7 @@ export class LiveAcpConnection { localSessionId: string, sessionInit: Omit, ): Promise { + const createStartedAt = Date.now(); this.pendingNewSessionLocals.push(localSessionId); try { @@ -297,6 +304,11 @@ export class LiveAcpConnection { if (index !== -1) { this.pendingNewSessionLocals.splice(index, 1); } + const adapterExit = this.lastAdapterExit; + if (adapterExit && this.lastAdapterExitAt >= createStartedAt) { + const suffix = adapterExit.code == null ? "" : ` (code ${adapterExit.code})`; + throw new Error(`Agent process exited while creating session${suffix}`); + } throw error; } } @@ -356,6 +368,17 @@ export class LiveAcpConnection { this.onObservedEnvelope(this, envelope, direction, localSessionId); } + private handleAdapterNotification(method: string, params: Record): void { + if (method !== "_adapter/agent_exited") { + return; + } + this.lastAdapterExit = { + success: params.success === true, + code: typeof params.code === "number" ? params.code : null, + }; + this.lastAdapterExitAt = Date.now(); + } + private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null { const id = envelopeId(envelope); const method = envelopeMethod(envelope); @@ -413,6 +436,7 @@ export class SandboxAgent { private spawnHandle?: SandboxAgentSpawnHandle; private readonly liveConnections = new Map(); + private readonly pendingLiveConnections = new Map>(); private readonly sessionHandles = new Map(); private readonly eventListeners = new Map>(); private readonly nextSessionEventIndexBySession = new Map(); @@ -463,6 +487,15 @@ export class SandboxAgent { async dispose(): Promise { const connections = [...this.liveConnections.values()]; this.liveConnections.clear(); + const pending = [...this.pendingLiveConnections.values()]; + this.pendingLiveConnections.clear(); + + const pendingSettled = await Promise.allSettled(pending); + for (const item of pendingSettled) { + if (item.status === "fulfilled") { + connections.push(item.value); + } + } await Promise.all( connections.map(async (connection) => { @@ -725,21 +758,43 @@ export class SandboxAgent { return existing; } - const serverId = `sdk-${agent}-${randomId()}`; - const created = await LiveAcpConnection.create({ - baseUrl: this.baseUrl, - token: this.token, - fetcher: this.fetcher, - headers: this.defaultHeaders, - agent, - serverId, - onObservedEnvelope: (connection, envelope, direction, localSessionId) => { - void this.persistObservedEnvelope(connection, envelope, direction, localSessionId); - }, - }); + const pending = this.pendingLiveConnections.get(agent); + if (pending) { + return pending; + } - this.liveConnections.set(agent, created); - return created; + const creating = (async () => { + const serverId = `sdk-${agent}-${randomId()}`; + const created = await LiveAcpConnection.create({ + baseUrl: this.baseUrl, + token: this.token, + fetcher: this.fetcher, + headers: this.defaultHeaders, + agent, + serverId, + onObservedEnvelope: (connection, envelope, direction, localSessionId) => { + void this.persistObservedEnvelope(connection, envelope, direction, localSessionId); + }, + }); + + const raced = this.liveConnections.get(agent); + if (raced) { + await created.close(); + return raced; + } + + this.liveConnections.set(agent, created); + return created; + })(); + + this.pendingLiveConnections.set(agent, creating); + try { + return await creating; + } finally { + if (this.pendingLiveConnections.get(agent) === creating) { + this.pendingLiveConnections.delete(agent); + } + } } private async persistObservedEnvelope(