From 46701753c346f12ac08049ef1d96c3a695a2298e Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Wed, 24 Jun 2026 22:50:05 +0800 Subject: [PATCH 01/64] feat(chat): improve prompt queue composer controls --- .../web/src/pages/chat/ChatComposerBar.tsx | 228 ++++++++++++++++-- crates/agent-gateway/web/src/styles.css | 48 ++++ crates/agent-gui/src/index.css | 43 ++++ .../pages/chat/components/ChatComposerBar.tsx | 218 ++++++++++++++++- 4 files changed, 511 insertions(+), 26 deletions(-) diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 2ca2e95a..774dce95 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type MutableRefObject, + type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; @@ -161,6 +162,19 @@ export type ChatQueueTurnPreview = { fileCount: number; }; +type QueueScrollbarState = { + visible: boolean; + thumbHeight: number; + thumbTop: number; +}; + +const QUEUE_SCROLLBAR_MIN_THUMB_HEIGHT = 24; +const DEFAULT_QUEUE_SCROLLBAR_STATE: QueueScrollbarState = { + visible: false, + thumbHeight: QUEUE_SCROLLBAR_MIN_THUMB_HEIGHT, + thumbTop: 0, +}; + export const ChatComposerBar = memo(function ChatComposerBar(props: { composerRef: MutableRefObject; isSending: boolean; @@ -224,13 +238,30 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { const { t } = useLocale(); const [composerIsEmpty, setComposerIsEmpty] = useState(true); const composerLayerRef = useRef(null); + const queuePanelRef = useRef(null); + const queueListRef = useRef(null); + const queueScrollbarTrackRef = useRef(null); + const queueScrollbarDragRef = useRef<{ + pointerId: number; + startScrollTop: number; + startY: number; + } | null>(null); const queueHadTurnsRef = useRef(false); const [queueCollapsed, setQueueCollapsed] = useState(false); + const [queueScrollbar, setQueueScrollbar] = useState( + DEFAULT_QUEUE_SCROLLBAR_STATE, + ); const uploadDisabled = isInputDisabled || isUploadingFiles || !isAgentMode || !workdir; const controlsDisabled = isInputDisabled; const hasSendableDraft = !composerIsEmpty || pendingUploadedFiles.length > 0; const thinkingSupported = reasoningOptions.length > 0; const sendDisabled = isInputDisabled || isUploadingFiles || !hasSendableDraft; + const canQueueDraftWhileSending = isSending && !sendDisabled; + const primaryActionTitle = canQueueDraftWhileSending + ? t("chat.queue.addToQueue") + : isSending + ? t("chat.stopGeneration") + : t("chat.sendMessage"); const selectedReasoning = reasoningOptions.includes(chatRuntimeControls.reasoning) ? chatRuntimeControls.reasoning : DEFAULT_CHAT_RUNTIME_CONTROLS.reasoning; @@ -245,6 +276,107 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { setQueueCollapsed((current) => !current); }, []); + const shouldShowQueueScrollbar = !queueCollapsed && queuedTurns.length > 2; + + const updateQueueScrollbar = useCallback(() => { + const list = queueListRef.current; + if (!list || !shouldShowQueueScrollbar) { + setQueueScrollbar((current) => (current.visible ? DEFAULT_QUEUE_SCROLLBAR_STATE : current)); + return; + } + + const { clientHeight, scrollHeight, scrollTop } = list; + const trackHeight = Math.max(clientHeight, QUEUE_SCROLLBAR_MIN_THUMB_HEIGHT); + const maxScrollTop = Math.max(0, scrollHeight - clientHeight); + const thumbHeight = + maxScrollTop <= 1 + ? trackHeight + : Math.min( + trackHeight, + Math.max( + QUEUE_SCROLLBAR_MIN_THUMB_HEIGHT, + Math.round((clientHeight / scrollHeight) * trackHeight), + ), + ); + const maxThumbTop = Math.max(0, trackHeight - thumbHeight); + const thumbTop = maxScrollTop <= 1 ? 0 : Math.round((scrollTop / maxScrollTop) * maxThumbTop); + + setQueueScrollbar((current) => { + if (current.visible && current.thumbHeight === thumbHeight && current.thumbTop === thumbTop) { + return current; + } + return { visible: true, thumbHeight, thumbTop }; + }); + }, [shouldShowQueueScrollbar]); + + const scrollQueueToThumbPosition = useCallback( + (clientY: number) => { + const list = queueListRef.current; + const track = queueScrollbarTrackRef.current; + if (!list || !track || !shouldShowQueueScrollbar) return; + + const rect = track.getBoundingClientRect(); + const maxThumbTop = Math.max(1, rect.height - queueScrollbar.thumbHeight); + const nextThumbTop = Math.min( + Math.max(clientY - rect.top - queueScrollbar.thumbHeight / 2, 0), + maxThumbTop, + ); + const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight); + list.scrollTop = (nextThumbTop / maxThumbTop) * maxScrollTop; + updateQueueScrollbar(); + }, + [queueScrollbar.thumbHeight, shouldShowQueueScrollbar, updateQueueScrollbar], + ); + + const handleQueueScrollbarPointerDown = useCallback( + (event: ReactPointerEvent) => { + if (!shouldShowQueueScrollbar || event.button !== 0) return; + const list = queueListRef.current; + const track = queueScrollbarTrackRef.current; + if (!list || !track) return; + + event.preventDefault(); + const target = event.target as HTMLElement; + if (!target.closest(".chat-queue-scrollbar-thumb")) { + scrollQueueToThumbPosition(event.clientY); + } + + queueScrollbarDragRef.current = { + pointerId: event.pointerId, + startScrollTop: list.scrollTop, + startY: event.clientY, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + [shouldShowQueueScrollbar, scrollQueueToThumbPosition], + ); + + const handleQueueScrollbarPointerMove = useCallback( + (event: ReactPointerEvent) => { + const drag = queueScrollbarDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + const list = queueListRef.current; + const track = queueScrollbarTrackRef.current; + if (!list || !track) return; + + const maxScrollTop = Math.max(0, list.scrollHeight - list.clientHeight); + const maxThumbTop = Math.max(1, track.clientHeight - queueScrollbar.thumbHeight); + list.scrollTop = + drag.startScrollTop + ((event.clientY - drag.startY) / maxThumbTop) * maxScrollTop; + updateQueueScrollbar(); + }, + [queueScrollbar.thumbHeight, updateQueueScrollbar], + ); + + const handleQueueScrollbarPointerUp = useCallback((event: ReactPointerEvent) => { + const drag = queueScrollbarDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + queueScrollbarDragRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }, []); + useEffect(() => { const hasQueuedTurns = queuedTurns.length > 0; if (hasQueuedTurns && !queueHadTurnsRef.current) { @@ -253,6 +385,29 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { queueHadTurnsRef.current = hasQueuedTurns; }, [queuedTurns.length]); + useEffect(() => { + const list = queueListRef.current; + if (!list) { + updateQueueScrollbar(); + return; + } + + updateQueueScrollbar(); + list.addEventListener("scroll", updateQueueScrollbar, { passive: true }); + const resizeObserver = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(updateQueueScrollbar); + resizeObserver?.observe(list); + window.addEventListener("resize", updateQueueScrollbar); + + return () => { + list.removeEventListener("scroll", updateQueueScrollbar); + resizeObserver?.disconnect(); + window.removeEventListener("resize", updateQueueScrollbar); + }; + }, [updateQueueScrollbar]); + useEffect(() => { if ( reasoningOptions.length > 0 && @@ -280,9 +435,11 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { } const updateComposerOverlayHeight = () => { + const composerLayerHeight = composerLayer.getBoundingClientRect().height; + const queueHeight = queuePanelRef.current?.getBoundingClientRect().height ?? 0; chatFrame.style.setProperty( "--gateway-chat-composer-overlay-height", - `${Math.ceil(composerLayer.getBoundingClientRect().height)}px`, + `${Math.ceil(Math.max(0, composerLayerHeight - queueHeight))}px`, ); }; @@ -348,6 +505,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { {queuedTurns.length > 0 ? (
-
-
    +
    +
      2 ? "true" : "false"} + className={cn( + "chat-queue-scroll flex min-w-0 flex-col gap-1 overflow-x-hidden", + queuedTurns.length > 2 + ? "h-[76px] overflow-y-scroll pr-3" + : "max-h-[76px] overflow-y-hidden pr-1", + )} + > {queuedTurns.map((item, index) => (
    • ))}
    + {shouldShowQueueScrollbar ? ( +
    +
    +
    + ) : null}
@@ -636,33 +822,47 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { +
+
+
+ {TUNNEL_DIAGNOSTIC_PROTOCOLS.map((protocol) => { + const diagnostic = diagnosticFor(tunnel, protocol); + const status = diagnostic?.status ?? "unknown"; + const title = diagnostic + ? [ + diagnosticLabel(protocol), + t( + status === "ok" + ? "projectTools.tunnelDiagnosticOk" + : status === "failed" + ? "projectTools.tunnelDiagnosticFailed" + : "projectTools.tunnelDiagnosticUnknown", + ), + diagnostic.statusCode ? String(diagnostic.statusCode) : "", + diagnostic.errorCode, + diagnostic.message, + ] + .filter(Boolean) + .join(" · ") + : t("projectTools.tunnelDiagnosticUnknown"); + return ( +
+ + {diagnosticLabel(protocol)} +
+ ); + })} +
+
+
+ {TUNNEL_DIAGNOSTIC_PROTOCOLS.map((protocol) => { + const diagnostic = diagnosticFor(tunnel, protocol); + const status = diagnostic?.status ?? "unknown"; + const title = diagnostic + ? [ + diagnosticLabel(protocol), + t( + status === "ok" + ? "projectTools.tunnelDiagnosticOk" + : status === "failed" + ? "projectTools.tunnelDiagnosticFailed" + : "projectTools.tunnelDiagnosticUnknown", + ), + diagnostic.statusCode ? String(diagnostic.statusCode) : "", + diagnostic.errorCode, + diagnostic.message, + ] + .filter(Boolean) + .join(" · ") + : t("projectTools.tunnelDiagnosticUnknown"); + return ( +
+ + {diagnosticLabel(protocol)} +
+ ); + })} +
+
-
- {theme === "light" ? t("settings.light") : t("settings.dark")} -
+
{getThemeLabel(theme)}
{selected ? (
diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 80b1f125..4788ff41 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -8,9 +8,12 @@ import { LocaleContext, t as translate } from "./i18n"; import { type AppUpdateController, useAppUpdateController } from "./lib/appUpdates"; import { type AppSettings, + getNextTheme, getDefaultSettings, normalizeSettings, + resolveEffectiveTheme, resolveWorkspaceProjects, + subscribeToSystemThemePreference, } from "./lib/settings"; import { loadPersistedSettingsWithDefaults, @@ -132,16 +135,24 @@ export default function App() { const saveSequenceRef = useRef(0); const saveChainRef = useRef>(Promise.resolve()); const defaultWorkdirRef = useRef(""); + const [systemThemeVersion, setSystemThemeVersion] = useState(0); + const effectiveTheme = useMemo( + () => resolveEffectiveTheme(settings.theme), + [settings.theme, systemThemeVersion], + ); + + useEffect(() => { + if (settings.theme !== "system") return; + return subscribeToSystemThemePreference(() => { + setSystemThemeVersion((version) => version + 1); + }); + }, [settings.theme]); // 同步主题 class 到 根节点 useEffect(() => { const root = document.documentElement; - if (settings.theme === "dark") { - root.classList.add("dark"); - } else { - root.classList.remove("dark"); - } - }, [settings.theme]); + root.classList.toggle("dark", effectiveTheme === "dark"); + }, [effectiveTheme]); useEffect(() => { let cancelled = false; @@ -259,7 +270,7 @@ export default function App() { const toggleTheme = useCallback(() => { setSettings((prev) => ({ ...prev, - theme: prev.theme === "dark" ? "light" : "dark", + theme: getNextTheme(prev.theme), })); }, [setSettings]); diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index da739dff..c1a62676 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -24,6 +24,7 @@ export const translations: Record> = { "tooltip.settings": "设置", "tooltip.switchToLight": "切换到浅色模式", "tooltip.switchToDark": "切换到深色模式", + "tooltip.switchToAuto": "切换到自动模式", "tooltip.closeSidebar": "关闭边栏", "tooltip.openSidebar": "打开边栏", "appUpdate.update": "更新", @@ -935,6 +936,8 @@ export const translations: Record> = { "settings.lightDesc": "明亮清爽的浅色界面", "settings.dark": "深色", "settings.darkDesc": "护眼舒适的深色界面", + "settings.auto": "自动", + "settings.autoDesc": "跟随系统外观设置", "settings.language": "语言", "settings.chinese": "简体中文", "settings.english": "English", @@ -1535,6 +1538,7 @@ export const translations: Record> = { "tooltip.settings": "Settings", "tooltip.switchToLight": "Switch to Light Mode", "tooltip.switchToDark": "Switch to Dark Mode", + "tooltip.switchToAuto": "Switch to Auto Mode", "tooltip.closeSidebar": "Close Sidebar", "tooltip.openSidebar": "Open Sidebar", "appUpdate.update": "Update", @@ -2485,6 +2489,8 @@ export const translations: Record> = { "settings.lightDesc": "Bright and clean light interface", "settings.dark": "Dark", "settings.darkDesc": "Eye-friendly dark interface", + "settings.auto": "Auto", + "settings.autoDesc": "Follow the system appearance setting", "settings.language": "Language", "settings.chinese": "简体中文", "settings.english": "English", diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index bf2dd537..a80e05f3 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -281,7 +281,12 @@ export type CustomProvider = { nativeWebSearchEnabled: boolean; }; -export type Theme = "light" | "dark"; +export type EffectiveTheme = "light" | "dark"; +export type Theme = EffectiveTheme | "system"; + +export const THEME_OPTIONS = ["light", "dark", "system"] as const satisfies readonly Theme[]; + +const SYSTEM_THEME_MEDIA_QUERY = "(prefers-color-scheme: dark)"; export type RemoteSettings = { enabled: boolean; @@ -1509,7 +1514,36 @@ export function normalizeSelectedModel(input: unknown): SelectedModel | undefine } export function normalizeTheme(input: unknown): Theme { - return input === "dark" ? "dark" : "light"; + if (input === "dark") return "dark"; + if (input === "system" || input === "auto") return "system"; + return "light"; +} + +export function resolveEffectiveTheme(theme: Theme): EffectiveTheme { + if (theme !== "system") return theme; + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "light"; + return window.matchMedia(SYSTEM_THEME_MEDIA_QUERY).matches ? "dark" : "light"; +} + +export function getNextTheme(theme: Theme): Theme { + if (theme === "light") return "dark"; + if (theme === "dark") return "system"; + return "light"; +} + +export function subscribeToSystemThemePreference(listener: () => void): () => void { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return () => undefined; + } + + const query = window.matchMedia(SYSTEM_THEME_MEDIA_QUERY); + if (typeof query.addEventListener === "function") { + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); + } + + query.addListener(listener); + return () => query.removeListener(listener); } function localTimezone() { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 4715c5aa..2ac31c9f 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -138,6 +138,7 @@ import { normalizeSystemToolSelection, openRightDockSingletonTab, removeRightDockProjectState, + resolveEffectiveTheme, resolveWorkspaceProjects, type SelectedModel, type SystemToolId, @@ -591,6 +592,7 @@ export function ChatPage(props: ChatPageProps) { props; // Monaco reads NLS globals while the lazy editor module imports monaco-editor. setPreferredMonacoNlsLocale(settings.locale); + const effectiveTheme = resolveEffectiveTheme(settings.theme); const { t } = useLocale(); const initialConversationRef = useRef(createConversationIdentity()); const initialConversationStateRef = useRef(createConversationStateFromContext(context)); @@ -5746,7 +5748,7 @@ export function ChatPage(props: ChatPageProps) { closeRequestId={workspaceEditorCloseRequestId} isOpen={workspaceEditorOpen} finalCloseRequested={workspaceEditorCleanupPending} - theme={settings.theme} + theme={effectiveTheme} onPreviewFile={(request) => openWorkspaceFilePreview(request)} onHide={() => setWorkspaceEditorOpen(false)} onClose={() => { @@ -5796,7 +5798,7 @@ export function ChatPage(props: ChatPageProps) { sessions={terminalSessions} client={tauriTerminalClient} sftpClient={tauriSftpClient} - theme={settings.theme} + theme={effectiveTheme} isOpen={workspaceSshTerminalOpen} onHide={() => setWorkspaceSshTerminalOpen(false)} /> @@ -5810,7 +5812,7 @@ export function ChatPage(props: ChatPageProps) { cwd={terminalProjectPath} sessions={terminalSessions} width={settings.customSettings.rightDock.width} - theme={settings.theme} + theme={effectiveTheme} disabledMessage={terminalDisabledMessage} projectState={rightDockProjectState} fileTreeState={getRightDockFileTreeState(settings.customSettings, terminalProjectPathKey)} diff --git a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx index 6c151d4f..cfaa37ba 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ClaudeIcon, GeminiIcon, + MonitorSmartphone, Moon, OpenaiChatgptIcon, PanelLeft, @@ -24,7 +25,13 @@ import { } from "../../../components/ui/dropdown-menu"; import { useLocale } from "../../../i18n"; import { type ModelOption, parseModelValue } from "../../../lib/providers/llm"; -import { type AppSettings, type ProviderId, setSelectedModel } from "../../../lib/settings"; +import { + type AppSettings, + type ProviderId, + getNextTheme, + setSelectedModel, + type Theme, +} from "../../../lib/settings"; import { cn } from "../../../lib/shared/utils"; import type { SectionId } from "../../settings/types"; @@ -35,6 +42,12 @@ function ProviderBrandIcon({ type, className }: { type: ProviderId; className?: return ; } +function ThemeToggleIcon(props: { theme: Theme }) { + if (props.theme === "light") return ; + if (props.theme === "dark") return ; + return ; +} + export const ChatHeader = memo(function ChatHeader(props: { settings: AppSettings; hasModels: boolean; @@ -64,6 +77,13 @@ export const ChatHeader = memo(function ChatHeader(props: { trailingActions, } = props; const { t } = useLocale(); + const nextTheme = getNextTheme(settings.theme); + const themeToggleTitle = + nextTheme === "light" + ? t("tooltip.switchToLight") + : nextTheme === "dark" + ? t("tooltip.switchToDark") + : t("tooltip.switchToAuto"); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); const [modelSearch, setModelSearch] = useState(""); const searchInputRef = useRef(null); @@ -243,14 +263,11 @@ export const ChatHeader = memo(function ChatHeader(props: { variant="ghost" size="icon" onClick={onToggleTheme} - title={settings.theme === "dark" ? t("tooltip.switchToLight") : t("tooltip.switchToDark")} + title={themeToggleTitle} + aria-label={themeToggleTitle} className="h-8 w-8 rounded-lg text-muted-foreground hover:text-foreground" > - {settings.theme === "dark" ? ( - - ) : ( - - )} + {!isMacOsTauri() && (
-
- {theme === "light" ? t("settings.light") : t("settings.dark")} -
+
{getThemeLabel(theme)}
{selected ? (
From 426a571c439af9aebe74ecfb2161829570367977 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 25 Jun 2026 21:32:08 +0800 Subject: [PATCH 08/64] fix(settings): remove settings page top shadow --- crates/agent-gateway/web/src/index.css | 19 ------------------- crates/agent-gui/src/index.css | 19 ------------------- 2 files changed, 38 deletions(-) diff --git a/crates/agent-gateway/web/src/index.css b/crates/agent-gateway/web/src/index.css index 37e23e6e..2c0b001e 100644 --- a/crates/agent-gateway/web/src/index.css +++ b/crates/agent-gateway/web/src/index.css @@ -634,21 +634,6 @@ isolation: isolate; } - .settings-section-shell::before { - content: ""; - position: absolute; - inset: -16px 0 auto; - height: 112px; - border-radius: 24px; - background: - radial-gradient(circle at top left, hsl(var(--primary) / 0.12), transparent 58%), - linear-gradient(180deg, hsl(var(--primary) / 0.06), transparent 76%); - filter: blur(18px); - opacity: 0.9; - pointer-events: none; - z-index: -1; - } - .ssh-auth-panel { overflow: hidden; transition: @@ -707,10 +692,6 @@ transition-duration: 1ms; } - .settings-section-shell::before { - opacity: 0.45; - filter: none; - } } /* Typing indicator */ diff --git a/crates/agent-gui/src/index.css b/crates/agent-gui/src/index.css index bd929172..fcf6efcb 100644 --- a/crates/agent-gui/src/index.css +++ b/crates/agent-gui/src/index.css @@ -803,21 +803,6 @@ isolation: isolate; } - .settings-section-shell::before { - content: ""; - position: absolute; - inset: -16px 0 auto; - height: 112px; - border-radius: 24px; - background: - radial-gradient(circle at top left, hsl(var(--primary) / 0.12), transparent 58%), - linear-gradient(180deg, hsl(var(--primary) / 0.06), transparent 76%); - filter: blur(18px); - opacity: 0.9; - pointer-events: none; - z-index: -1; - } - .ssh-auth-panel { overflow: hidden; transition: @@ -912,10 +897,6 @@ transition-duration: 1ms; } - .settings-section-shell::before { - opacity: 0.45; - filter: none; - } } /* Typing indicator */ From 84bdffbdb0a6e1d0fc7ac9c485c7f3439fd47268 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 29 Jun 2026 10:05:34 +0800 Subject: [PATCH 09/64] refactor(gateway): consolidate websocket helpers and manager internals --- .../agent-gateway/internal/server/tunnel.go | 8 --- .../internal/server/websocket.go | 67 +++++++------------ .../internal/server/websocket_git_handlers.go | 2 +- .../server/websocket_memory_handlers.go | 2 +- .../internal/server/websocket_payloads.go | 42 +----------- .../server/websocket_settings_handlers.go | 20 +++--- .../server/websocket_skills_handlers.go | 14 ++-- .../internal/server/websocket_writer.go | 35 ---------- .../internal/session/manager_chat_runs.go | 2 +- .../internal/session/manager_registry.go | 2 +- .../internal/session/manager_terminal.go | 6 +- .../internal/session/manager_test.go | 2 +- .../internal/session/manager_tunnel.go | 22 +++--- .../internal/session/manager_tunnel_test.go | 36 +++++----- .../internal/session/tunnel_probe.go | 2 +- 15 files changed, 85 insertions(+), 177 deletions(-) delete mode 100644 crates/agent-gateway/internal/server/websocket_writer.go diff --git a/crates/agent-gateway/internal/server/tunnel.go b/crates/agent-gateway/internal/server/tunnel.go index 964e1e8c..dd85b155 100644 --- a/crates/agent-gateway/internal/server/tunnel.go +++ b/crates/agent-gateway/internal/server/tunnel.go @@ -635,14 +635,6 @@ func appendTunnelForwardedHeaders( return headers } -func writeTunnelResponseHeaders( - w http.ResponseWriter, - frame *gatewayv1.TunnelFrame, - tunnel *gatewayv1.TunnelSummary, -) { - writeTunnelHTTPHeaders(w, tunnelResponseHeaders(frame, tunnel)) -} - func tunnelResponseHeaders( frame *gatewayv1.TunnelFrame, tunnel *gatewayv1.TunnelSummary, diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 6ef286b5..6888dae7 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -93,10 +93,11 @@ type websocketConnection struct { cfg *config.Config sm *session.Manager - conn *websocket.Conn - req *http.Request + conn *websocket.Conn + req *http.Request + writeMu sync.Mutex + writeTimeout time.Duration - writer *websocketConnectionWriter closeOnce sync.Once done chan struct{} authorized bool @@ -138,7 +139,7 @@ func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { sm: sm, conn: conn, req: r, - writer: newWebsocketConnectionWriter(conn, cfg.WebSocketWriteTimeout), + writeTimeout: cfg.WebSocketWriteTimeout, done: make(chan struct{}), terminalInterest: newWebsocketTerminalInterestTracker(), } @@ -265,7 +266,7 @@ func (c *websocketConnection) startHistorySyncForwarder() { if !ok { return } - if err := c.writeHistoryEvent(websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...)); err != nil { + if err := c.writeEvent("history.event", websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...)); err != nil { c.close() return } @@ -292,11 +293,11 @@ func (c *websocketConnection) startSettingsSyncForwarder() { if !ok { return } - payload, err := websocketSettingsSyncPayload(event) + payload, err := websocketSettingsJSONPayload(event.GetSettingsJson()) if err != nil { return } - if err := c.writeSettingsEvent(payload); err != nil { + if err := c.writeEvent("settings.event", payload); err != nil { c.close() return } @@ -326,7 +327,7 @@ func (c *websocketConnection) startTerminalEventForwarder() { if !c.shouldForwardTerminalEvent(event) { continue } - if err := c.writeTerminalEvent(websocketTerminalEventPayload(event)); err != nil { + if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(event)); err != nil { c.close() return } @@ -356,7 +357,7 @@ func (c *websocketConnection) startSftpEventForwarder() { if !c.sm.WebSshTerminalEnabled() { continue } - if err := c.writeSftpEvent(websocketSftpEventPayload(event)); err != nil { + if err := c.writeEvent("sftp.event", websocketSftpEventPayload(event)); err != nil { c.close() return } @@ -383,7 +384,7 @@ func (c *websocketConnection) startChatQueueEventForwarder() { if !ok { return } - if err := c.writeChatQueueEvent(websocketChatQueueEventPayload(event)); err != nil { + if err := c.writeEvent("chat_queue.event", websocketChatQueueEventPayload(event)); err != nil { c.close() return } @@ -400,7 +401,7 @@ func (c *websocketConnection) replayTerminalSessionSnapshot() { if !c.terminalSessionAllowed(terminalSession) { continue } - if err := c.writeTerminalEvent(websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ + if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ Kind: "created", SessionId: terminalSession.GetId(), ProjectPathKey: terminalSession.GetProjectPathKey(), @@ -519,41 +520,23 @@ func (c *websocketConnection) writeError(requestID string, message string) error }) } -func (c *websocketConnection) writeHistoryEvent(payload any) error { +func (c *websocketConnection) writeEvent(eventType string, payload any) error { return c.writeEnvelope(websocketEnvelope{ - Type: "history.event", - Payload: payload, - }) -} - -func (c *websocketConnection) writeSettingsEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "settings.event", - Payload: payload, - }) -} - -func (c *websocketConnection) writeTerminalEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "terminal.event", - Payload: payload, - }) -} - -func (c *websocketConnection) writeSftpEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "sftp.event", - Payload: payload, - }) -} - -func (c *websocketConnection) writeChatQueueEvent(payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: "chat_queue.event", + Type: eventType, Payload: payload, }) } func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { - return c.writer.write(envelope) + c.writeMu.Lock() + defer c.writeMu.Unlock() + if c.writeTimeout > 0 { + if err := c.conn.SetWriteDeadline(time.Now().Add(c.writeTimeout)); err != nil { + return err + } + defer func() { + _ = c.conn.SetWriteDeadline(time.Time{}) + }() + } + return c.conn.WriteJSON(envelope) } diff --git a/crates/agent-gateway/internal/server/websocket_git_handlers.go b/crates/agent-gateway/internal/server/websocket_git_handlers.go index 336c1af2..9097df7b 100644 --- a/crates/agent-gateway/internal/server/websocket_git_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_git_handlers.go @@ -60,7 +60,7 @@ func (c *websocketConnection) handleGitRequest(req websocketRequest) { _ = c.writeError(req.ID, "unexpected agent response") return } - payload, err := websocketGitResultPayload(resp.GetResultJson()) + payload, err := unmarshalJSONPayload(resp.GetResultJson()) if err != nil { _ = c.writeError(req.ID, err.Error()) return diff --git a/crates/agent-gateway/internal/server/websocket_memory_handlers.go b/crates/agent-gateway/internal/server/websocket_memory_handlers.go index 92c18ecc..b49e1cd0 100644 --- a/crates/agent-gateway/internal/server/websocket_memory_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_memory_handlers.go @@ -64,7 +64,7 @@ func (c *websocketConnection) handleMemoryManage(req websocketRequest) { return } - payloadValue, err := websocketMemoryResultPayload(resp.GetResultJson()) + payloadValue, err := unmarshalJSONPayload(resp.GetResultJson()) if err != nil { _ = c.writeError(req.ID, err.Error()) return diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index 76c67285..834d9551 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -15,7 +15,7 @@ import ( ) func websocketProtoPayload(message proto.Message, useProtoNames bool) map[string]any { - if isNilProtoMessage(message) { + if message == nil || (reflect.ValueOf(message).Kind() == reflect.Pointer && reflect.ValueOf(message).IsNil()) { return nil } raw, err := protojson.MarshalOptions{ @@ -33,14 +33,6 @@ func websocketProtoPayload(message proto.Message, useProtoNames bool) map[string return payload } -func isNilProtoMessage(message proto.Message) bool { - if message == nil { - return true - } - value := reflect.ValueOf(message) - return value.Kind() == reflect.Pointer && value.IsNil() -} - func coerceProtoJSONNumbers(payload map[string]any, descriptor protoreflect.MessageDescriptor, useProtoNames bool) { if payload == nil || descriptor == nil { return @@ -189,10 +181,6 @@ func websocketHistorySyncPayload( return payload } -func websocketSettingsSyncPayload(event *gatewayv1.SettingsSyncEvent) (map[string]any, error) { - return websocketSettingsJSONPayload(event.GetSettingsJson()) -} - func websocketSettingsJSONPayload(raw string) (map[string]any, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -390,30 +378,14 @@ func websocketTerminalEventPayload(event *gatewayv1.TerminalEvent) map[string]an return payload } -func websocketMemoryResultPayload(raw string) (any, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return map[string]any{}, nil - } - - var payload any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("gateway memory response is not valid JSON") - } - if payload == nil { - return map[string]any{}, nil - } - return payload, nil -} - -func websocketGitResultPayload(raw string) (any, error) { +func unmarshalJSONPayload(raw string) (any, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return map[string]any{}, nil } var payload any if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("gateway git response is not valid JSON") + return nil, errors.New("response is not valid JSON") } if payload == nil { return map[string]any{}, nil @@ -442,14 +414,6 @@ func websocketRawPayloadJSON(raw json.RawMessage) (string, error) { return string(normalized), nil } -func nullableTrimmedString(value string) any { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil - } - return trimmed -} - func websocketOptionalUint32(value *int, field string) (uint32, error) { if value == nil { return 0, nil diff --git a/crates/agent-gateway/internal/server/websocket_settings_handlers.go b/crates/agent-gateway/internal/server/websocket_settings_handlers.go index 5ce275bf..1c20c7fb 100644 --- a/crates/agent-gateway/internal/server/websocket_settings_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_settings_handlers.go @@ -71,8 +71,15 @@ func (c *websocketConnection) handleSettingsUpdate(req websocketRequest) { _ = c.writeError(req.ID, "unexpected agent response") return } - if settingsResp.GetAccepted() && !settingsUpdateHasSshPatch(payloadJSON) { - c.sm.ApplySettingsJSONPreservingRemote(payloadJSON) + if settingsResp.GetAccepted() { + var patch map[string]any + hasSshPatch := json.Unmarshal([]byte(payloadJSON), &patch) == nil && patch != nil + if hasSshPatch { + _, hasSshPatch = patch["sshPatch"] + } + if !hasSshPatch { + c.sm.ApplySettingsJSONPreservingRemote(payloadJSON) + } } _ = c.writeResponse(req.ID, map[string]any{ @@ -81,15 +88,6 @@ func (c *websocketConnection) handleSettingsUpdate(req websocketRequest) { }) } -func settingsUpdateHasSshPatch(payloadJSON string) bool { - var payload map[string]any - if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { - return false - } - _, ok := payload["sshPatch"] - return ok -} - func (c *websocketConnection) handleSettingsResetSshKnownHost(req websocketRequest) { if !c.sm.WebSshTerminalEnabled() { _ = c.writeError(req.ID, "web SSH terminal is disabled in desktop Remote settings") diff --git a/crates/agent-gateway/internal/server/websocket_skills_handlers.go b/crates/agent-gateway/internal/server/websocket_skills_handlers.go index a8a51e79..c140cfcf 100644 --- a/crates/agent-gateway/internal/server/websocket_skills_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_skills_handlers.go @@ -145,10 +145,16 @@ func (c *websocketConnection) handleSkillMetadataRead(req websocketRequest) { return } - _ = c.writeResponse(req.ID, map[string]any{ - "name": nullableTrimmedString(resp.GetName()), - "description": nullableTrimmedString(resp.GetDescription()), - }) + name := strings.TrimSpace(resp.GetName()) + description := strings.TrimSpace(resp.GetDescription()) + result := map[string]any{"name": any(nil), "description": any(nil)} + if name != "" { + result["name"] = name + } + if description != "" { + result["description"] = description + } + _ = c.writeResponse(req.ID, result) } func (c *websocketConnection) handleSkillTextRead(req websocketRequest) { diff --git a/crates/agent-gateway/internal/server/websocket_writer.go b/crates/agent-gateway/internal/server/websocket_writer.go deleted file mode 100644 index 750e89d9..00000000 --- a/crates/agent-gateway/internal/server/websocket_writer.go +++ /dev/null @@ -1,35 +0,0 @@ -package server - -import ( - "sync" - "time" - - "github.com/gorilla/websocket" -) - -type websocketConnectionWriter struct { - conn *websocket.Conn - timeout time.Duration - mu sync.Mutex -} - -func newWebsocketConnectionWriter(conn *websocket.Conn, timeout time.Duration) *websocketConnectionWriter { - return &websocketConnectionWriter{ - conn: conn, - timeout: timeout, - } -} - -func (w *websocketConnectionWriter) write(envelope websocketEnvelope) error { - w.mu.Lock() - defer w.mu.Unlock() - if w.timeout > 0 { - if err := w.conn.SetWriteDeadline(time.Now().Add(w.timeout)); err != nil { - return err - } - defer func() { - _ = w.conn.SetWriteDeadline(time.Time{}) - }() - } - return w.conn.WriteJSON(envelope) -} diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go index 65c4c22a..86eb0140 100644 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ b/crates/agent-gateway/internal/session/manager_chat_runs.go @@ -417,7 +417,7 @@ func (m *Manager) FailStartingChatRun(requestID string, message string) bool { }, ) if failed { - m.ClearSessionForEpoch(sessionEpoch) + m.clearSessionForEpoch(sessionEpoch) } return failed } diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 850d03cc..e25837aa 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -97,7 +97,7 @@ func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout ti return true } -func (m *Manager) ClearSessionForEpoch(sessionEpoch uint64) bool { +func (m *Manager) clearSessionForEpoch(sessionEpoch uint64) bool { m.registry.mu.Lock() session := m.registry.session if session == nil || m.registry.sessionEpoch != sessionEpoch { diff --git a/crates/agent-gateway/internal/session/manager_terminal.go b/crates/agent-gateway/internal/session/manager_terminal.go index 97383461..6ccd31db 100644 --- a/crates/agent-gateway/internal/session/manager_terminal.go +++ b/crates/agent-gateway/internal/session/manager_terminal.go @@ -213,7 +213,7 @@ func (m *Manager) TerminalSessionSnapshot(projectPathKey string) []*gatewayv1.Te return sessions } -func (m *Manager) ReplaceTerminalSessionSnapshot( +func (m *Manager) replaceTerminalSessionSnapshot( projectPathKey string, sessions []*gatewayv1.TerminalSession, ) { @@ -251,9 +251,9 @@ func (m *Manager) ApplyTerminalResponseSnapshot( switch action { case "list": - m.ReplaceTerminalSessionSnapshot(projectPathKey, resp.GetSessions()) + m.replaceTerminalSessionSnapshot(projectPathKey, resp.GetSessions()) case "close_project": - m.ReplaceTerminalSessionSnapshot(projectPathKey, nil) + m.replaceTerminalSessionSnapshot(projectPathKey, nil) case "close": if sessionID := strings.TrimSpace(resp.GetSession().GetId()); sessionID != "" { m.syncHub.terminalMu.Lock() diff --git a/crates/agent-gateway/internal/session/manager_test.go b/crates/agent-gateway/internal/session/manager_test.go index 6b4af97f..567a7df4 100644 --- a/crates/agent-gateway/internal/session/manager_test.go +++ b/crates/agent-gateway/internal/session/manager_test.go @@ -58,7 +58,7 @@ func TestApplySettingsJSONPreservingRemoteDoesNotTrustIncomingRemote(t *testing. func TestTerminalSessionSnapshotPreservesSshMetadataAndSorts(t *testing.T) { manager := NewManager() - manager.ReplaceTerminalSessionSnapshot("", []*gatewayv1.TerminalSession{ + manager.replaceTerminalSessionSnapshot("", []*gatewayv1.TerminalSession{ { Id: "ssh-2", ProjectPathKey: "/workspace/b", diff --git a/crates/agent-gateway/internal/session/manager_tunnel.go b/crates/agent-gateway/internal/session/manager_tunnel.go index 239f1df1..00c8957b 100644 --- a/crates/agent-gateway/internal/session/manager_tunnel.go +++ b/crates/agent-gateway/internal/session/manager_tunnel.go @@ -15,8 +15,8 @@ import ( ) const ( - MaxTunnelsPerAgent = 5 - MaxTunnelConnections = 20 + maxTunnelsPerAgent = 5 + maxTunnelConnections = 20 defaultTunnelTTLSeconds = 3600 tunnelSlugEntropyBytes = 24 tunnelStreamChannelDepth = 256 @@ -158,7 +158,7 @@ func (m *Manager) ListTunnels() []*gatewayv1.TunnelSummary { return summaries } -func (m *Manager) SetTunnelDiagnostics(identifier string, diagnostics []*gatewayv1.TunnelDiagnostic) (*gatewayv1.TunnelSummary, error) { +func (m *Manager) setTunnelDiagnostics(identifier string, diagnostics []*gatewayv1.TunnelDiagnostic) (*gatewayv1.TunnelSummary, error) { identifier = strings.TrimSpace(identifier) if identifier == "" { return nil, ErrTunnelNotFound @@ -208,7 +208,7 @@ func (m *Manager) PrepareTunnelCreate( } activeCount += 1 } - if activeCount >= MaxTunnelsPerAgent { + if activeCount >= maxTunnelsPerAgent { return nil, ErrTunnelLimitExceeded } @@ -371,7 +371,7 @@ func (m *Manager) StorePreparedTunnel( return tunnelSummaryLocked(record, now, online), nil } -func (m *Manager) CreateTunnelFromAgent( +func (m *Manager) createTunnelFromAgent( input *gatewayv1.TunnelControlRequest, ) (*gatewayv1.TunnelSummary, error) { prepared, err := m.PrepareTunnelCreate(input, input.GetPublicBaseUrl()) @@ -381,7 +381,7 @@ func (m *Manager) CreateTunnelFromAgent( return m.StorePreparedTunnel(prepared, input.GetTargetUrl()) } -func (m *Manager) UpdateTunnelFromAgent( +func (m *Manager) updateTunnelFromAgent( input *gatewayv1.TunnelControlRequest, ) (*gatewayv1.TunnelSummary, error) { prepared, err := m.PrepareTunnelUpdate(input) @@ -468,7 +468,7 @@ func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLeas if isTunnelExpired(record, now) { return nil, ErrTunnelExpired } - if record.activeConnections >= MaxTunnelConnections { + if record.activeConnections >= maxTunnelConnections { return nil, ErrTunnelOverLimit } stream := &tunnelStream{ @@ -535,7 +535,7 @@ func (m *Manager) CloseTunnel(identifier string) (*gatewayv1.TunnelSummary, erro return summary, nil } -func (m *Manager) ResumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { +func (m *Manager) resumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { if input == nil { return nil, errors.New("resume tunnel input is required") } @@ -640,7 +640,7 @@ func (m *Manager) handleAgentTunnelControlInner( Tunnels: m.ListTunnels(), } case "create": - tunnel, err := m.CreateTunnelFromAgent(request) + tunnel, err := m.createTunnelFromAgent(request) if err != nil { return tunnelControlErrorFor(action, err) } @@ -650,7 +650,7 @@ func (m *Manager) handleAgentTunnelControlInner( Tunnels: m.ListTunnels(), } case "update": - tunnel, err := m.UpdateTunnelFromAgent(request) + tunnel, err := m.updateTunnelFromAgent(request) if err != nil { return tunnelControlErrorFor(action, err) } @@ -688,7 +688,7 @@ func (m *Manager) handleAgentTunnelControlInner( Tunnels: m.ListTunnels(), } case "resume": - tunnel, err := m.ResumeTunnel(request) + tunnel, err := m.resumeTunnel(request) if err != nil { return tunnelControlErrorFor(action, err) } diff --git a/crates/agent-gateway/internal/session/manager_tunnel_test.go b/crates/agent-gateway/internal/session/manager_tunnel_test.go index 1e147a0b..b61c4893 100644 --- a/crates/agent-gateway/internal/session/manager_tunnel_test.go +++ b/crates/agent-gateway/internal/session/manager_tunnel_test.go @@ -20,7 +20,7 @@ func onlineTunnelTestManager() *Manager { func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.TunnelSummary { t.Helper() - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "create", TargetUrl: "http://localhost:3000/app", Name: name, @@ -28,7 +28,7 @@ func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.Tu PublicBaseUrl: "https://gateway.example", }) if err != nil { - t.Fatalf("CreateTunnelFromAgent: %v", err) + t.Fatalf("createTunnelFromAgent: %v", err) } if tunnel.GetSlug() == "" || tunnel.GetPublicUrl() == "" { t.Fatalf("created tunnel missing slug/public URL: %+v", tunnel) @@ -40,14 +40,14 @@ func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { manager := onlineTunnelTestManager() var first *gatewayv1.TunnelSummary - for i := 0; i < MaxTunnelsPerAgent; i++ { + for i := 0; i < maxTunnelsPerAgent; i++ { tunnel := createTestTunnel(t, manager, "app") if i == 0 { first = tunnel } } - if _, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + if _, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "create", TargetUrl: "http://localhost:3001", TtlSeconds: 3600, @@ -56,8 +56,8 @@ func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { t.Fatalf("expected ErrTunnelLimitExceeded, got %v", err) } - if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent { - t.Fatalf("ListTunnels returned %d tunnels, want %d", got, MaxTunnelsPerAgent) + if got := len(manager.ListTunnels()); got != maxTunnelsPerAgent { + t.Fatalf("ListTunnels returned %d tunnels, want %d", got, maxTunnelsPerAgent) } closed, err := manager.CloseTunnel(first.GetId()) @@ -67,8 +67,8 @@ func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { if closed.GetStatus() != "expired" { t.Fatalf("closed tunnel summary status = %q, want expired", closed.GetStatus()) } - if got := len(manager.ListTunnels()); got != MaxTunnelsPerAgent-1 { - t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, MaxTunnelsPerAgent-1) + if got := len(manager.ListTunnels()); got != maxTunnelsPerAgent-1 { + t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, maxTunnelsPerAgent-1) } } @@ -76,8 +76,8 @@ func TestTunnelAcquireConnectionLimitAndRelease(t *testing.T) { manager := onlineTunnelTestManager() tunnel := createTestTunnel(t, manager, "app") - leases := make([]*TunnelStreamLease, 0, MaxTunnelConnections) - for i := 0; i < MaxTunnelConnections; i++ { + leases := make([]*TunnelStreamLease, 0, maxTunnelConnections) + for i := 0; i < maxTunnelConnections; i++ { lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-"+string(rune('a'+i))) if err != nil { t.Fatalf("AcquireTunnel %d: %v", i, err) @@ -129,7 +129,7 @@ func TestTunnelExpiredCannotBeAcquired(t *testing.T) { func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { manager := onlineTunnelTestManager() - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "create", TargetUrl: "http://localhost:3000/app", Name: "app", @@ -137,7 +137,7 @@ func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { PublicBaseUrl: "https://gateway.example", }) if err != nil { - t.Fatalf("CreateTunnelFromAgent with infinite TTL: %v", err) + t.Fatalf("createTunnelFromAgent with infinite TTL: %v", err) } if tunnel.GetExpiresAt() != 0 { t.Fatalf("infinite tunnel expiresAt = %d, want 0", tunnel.GetExpiresAt()) @@ -161,7 +161,7 @@ func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { manager := onlineTunnelTestManager() tunnel := createTestTunnel(t, manager, "app") - updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + updated, err := manager.updateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "update", TunnelId: tunnel.GetId(), TargetUrl: "http://127.0.0.1:4000/dashboard", @@ -170,7 +170,7 @@ func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { ProjectPathKey: "project:/tmp/liveagent", }) if err != nil { - t.Fatalf("UpdateTunnelFromAgent: %v", err) + t.Fatalf("updateTunnelFromAgent: %v", err) } if updated.GetName() != "dashboard" { t.Fatalf("updated name = %q, want dashboard", updated.GetName()) @@ -197,7 +197,7 @@ func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { manager := onlineTunnelTestManager() - tunnel, err := manager.CreateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "create", TargetUrl: "http://localhost:3000/app", Name: "app", @@ -206,7 +206,7 @@ func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { ProjectPathKey: "/workspace/app", }) if err != nil { - t.Fatalf("CreateTunnelFromAgent: %v", err) + t.Fatalf("createTunnelFromAgent: %v", err) } if tunnel.GetExpiresAt() != 0 { t.Fatalf("infinite tunnel expires_at = %d, want 0", tunnel.GetExpiresAt()) @@ -231,7 +231,7 @@ func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { manager := onlineTunnelTestManager() tunnel := createTestTunnel(t, manager, "app") - updated, err := manager.UpdateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ + updated, err := manager.updateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ Action: "update", TunnelId: tunnel.GetId(), TargetUrl: "http://localhost:3000/next", @@ -240,7 +240,7 @@ func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { ProjectPathKey: "/workspace/app", }) if err != nil { - t.Fatalf("UpdateTunnelFromAgent: %v", err) + t.Fatalf("updateTunnelFromAgent: %v", err) } if updated.GetTargetUrl() != "http://localhost:3000/next" { t.Fatalf("target_url = %q, want http://localhost:3000/next", updated.GetTargetUrl()) diff --git a/crates/agent-gateway/internal/session/tunnel_probe.go b/crates/agent-gateway/internal/session/tunnel_probe.go index f67acf2c..2d913fdc 100644 --- a/crates/agent-gateway/internal/session/tunnel_probe.go +++ b/crates/agent-gateway/internal/session/tunnel_probe.go @@ -49,7 +49,7 @@ func (m *Manager) ProbeTunnel( m.tunnels.mu.Unlock() diagnostics := ProbePublicTunnel(ctx, publicURL) - if updated, err := m.SetTunnelDiagnostics(recordID, diagnostics); err == nil { + if updated, err := m.setTunnelDiagnostics(recordID, diagnostics); err == nil { return updated, nil } m.tunnels.mu.Lock() From 1fcdf243e66ee7c4106fc0d86dfdb1e15bd8ac6b Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 29 Jun 2026 10:05:37 +0800 Subject: [PATCH 10/64] fix(chat): clean up orphaned local draft conversations --- .../agent-gateway/web/src/app/GatewayApp.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 07fc01b3..18aa4a46 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -4049,6 +4049,21 @@ export default function GatewayApp() { pendingDraftConversationMigrationRef.current?.draftConversationId === activeConversationId ) { pendingDraftConversationMigrationRef.current = null; + if (startedAsDraftConversation && isLocalDraftConversationId(activeConversationId)) { + optimisticTitleConversationIdsRef.current.delete(activeConversationId); + unlockHistoryTitlePosition(activeConversationId); + updateHistoryItems((current) => + current.filter((item) => item.id !== activeConversationId), + ); + if ( + conversationIdRef.current === activeConversationId || + selectedHistoryIdRef.current === activeConversationId + ) { + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + }); + } + } } for (const conversationIdValue of lockedConversationIds) { chatStartLocksRef.current.delete(conversationIdValue); @@ -6142,6 +6157,17 @@ export default function GatewayApp() { setHistoryError("后台任务仍在运行,暂时不能删除该对话。"); return; } + if (isLocalDraftConversationId(id)) { + optimisticTitleConversationIdsRef.current.delete(id); + unlockHistoryTitlePosition(id); + updateHistoryItems((current) => current.filter((item) => item.id !== id)); + if (conversationIdRef.current === id || selectedHistoryIdRef.current === id) { + startNewConversation({ + workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, + }); + } + return; + } void (async () => { setHistoryMutating(true); try { From a822b454a6918935123e3ba3a63e0978862e875d Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 29 Jun 2026 18:59:22 +0800 Subject: [PATCH 11/64] fix(gateway): stabilize chat run replay and recovery --- .../internal/server/websocket.go | 11 +- .../internal/server/websocket_payload_test.go | 34 + .../internal/server/websocket_payloads.go | 33 + .../agent-gateway/internal/session/manager.go | 5 +- .../internal/session/manager_chat_runs.go | 808 +++++-------- .../internal/session/manager_test.go | 43 +- .../agent-gateway/web/src/app/GatewayApp.tsx | 1046 ++++++++--------- .../agent-gateway/web/src/app/historyUtils.ts | 4 +- crates/agent-gateway/web/src/app/types.ts | 6 - .../web/src/components/GatewayTranscript.tsx | 31 +- crates/agent-gateway/web/src/index.css | 16 - .../web/src/lib/chatStreamRecovery.ts | 90 -- .../web/src/lib/gatewaySocket.ts | 14 +- .../agent-gateway/web/src/lib/gatewayTypes.ts | 22 + .../web/src/lib/liveConversationCommit.ts | 326 ----- .../src/lib/liveConversationStreamStore.ts | 460 -------- .../web/src/lib/localDraftConversation.ts | 9 - .../chat/gateway/useGatewayBridgeBatcher.ts | 69 +- 18 files changed, 987 insertions(+), 2040 deletions(-) delete mode 100644 crates/agent-gateway/web/src/lib/chatStreamRecovery.ts delete mode 100644 crates/agent-gateway/web/src/lib/liveConversationCommit.ts delete mode 100644 crates/agent-gateway/web/src/lib/liveConversationStreamStore.ts delete mode 100644 crates/agent-gateway/web/src/lib/localDraftConversation.ts diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 6888dae7..0d313fc7 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -266,7 +266,16 @@ func (c *websocketConnection) startHistorySyncForwarder() { if !ok { return } - if err := c.writeEvent("history.event", websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...)); err != nil { + payload := websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...) + if payload["kind"] == "running" && payload["run_id"] == nil { + conversationID := historySyncPayloadConversationID(payload, event) + if conversationID != "" { + if summary, ok := c.sm.ConversationRunSummary(conversationID); ok { + enrichHistorySyncRunningPayload(payload, summary) + } + } + } + if err := c.writeEvent("history.event", payload); err != nil { c.close() return } diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index 2a7fc3e2..fb3eaaf9 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -108,6 +108,40 @@ func TestHistoryRunningPayloadIncludesReplayCursor(t *testing.T) { } } +func TestHistoryRunningPayloadFallbackEnrichment(t *testing.T) { + event := &gatewayv1.HistorySyncEvent{ + Kind: "running", + ConversationId: "conversation-1", + } + payload := websocketHistorySyncPayload(event) + + if payload["run_id"] != nil { + t.Fatalf("expected no run_id before enrichment, got %#v", payload["run_id"]) + } + + cid := historySyncPayloadConversationID(payload, event) + if cid != "conversation-1" { + t.Fatalf("expected conversation_id conversation-1, got %q", cid) + } + + enrichHistorySyncRunningPayload(payload, session.ActiveChatRunSummary{ + ConversationID: "conversation-1", + RequestID: "run-fallback", + FirstSeq: 10, + LatestSeq: 15, + RunEpoch: 3, + UpdatedAt: 456, + }) + + if payload["run_id"] != "run-fallback" || + payload["first_seq"] != int64(10) || + payload["latest_seq"] != int64(15) || + payload["run_epoch"] != int64(3) || + payload["updated_at"] != int64(456) { + t.Fatalf("fallback enriched payload = %#v", payload) + } +} + func TestWebsocketChatQueueSnapshotResponsePayload(t *testing.T) { payload := websocketChatQueueSnapshotResponsePayload(&gatewayv1.ChatQueueEvent{ ConversationId: "conversation-1", diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index 834d9551..fa16c5c3 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -181,6 +181,39 @@ func websocketHistorySyncPayload( return payload } +func historySyncPayloadConversationID(payload map[string]any, event *gatewayv1.HistorySyncEvent) string { + if cid, ok := payload["conversation_id"].(string); ok && cid != "" { + return cid + } + if event != nil { + if cid := strings.TrimSpace(event.GetConversationId()); cid != "" { + return cid + } + if event.GetConversation() != nil { + return strings.TrimSpace(event.GetConversation().GetId()) + } + } + return "" +} + +func enrichHistorySyncRunningPayload(payload map[string]any, summary session.ActiveChatRunSummary) { + if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { + payload["run_id"] = requestID + } + if summary.FirstSeq > 0 { + payload["first_seq"] = summary.FirstSeq + } + if summary.LatestSeq > 0 { + payload["latest_seq"] = summary.LatestSeq + } + if summary.RunEpoch > 0 { + payload["run_epoch"] = summary.RunEpoch + } + if summary.UpdatedAt > 0 { + payload["updated_at"] = summary.UpdatedAt + } +} + func websocketSettingsJSONPayload(raw string) (map[string]any, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index 913dc9ea..403af891 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -17,9 +17,8 @@ var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") const ( maxBufferedChatRunEvents = 50000 - chatRunDoneRetention = time.Hour - chatRunStartRetention = 5 * time.Minute - chatRunStaleRetention = 12 * time.Hour + chatRunDoneRetention = time.Hour + chatRunStaleRetention = 12 * time.Hour chatRuntimeReadyTTL = 15 * time.Second agentSessionHeartbeatTTL = 90 * time.Second diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go index 86eb0140..c26b081d 100644 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ b/crates/agent-gateway/internal/session/manager_chat_runs.go @@ -4,7 +4,6 @@ import ( "encoding/json" "log" "sort" - "strconv" "strings" "sync" "time" @@ -151,13 +150,12 @@ func (m *Manager) startPendingChatCommandRun( } func (m *Manager) latestConversationSeqLocked(conversationID string) int64 { - conversationID = strings.TrimSpace(conversationID) if conversationID == "" { return 0 } var latestSeq int64 for _, run := range m.chatStore.chatRuns { - if run == nil || strings.TrimSpace(run.conversationID) != conversationID { + if run == nil || run.conversationID != conversationID { continue } if run.nextSeq > latestSeq { @@ -168,12 +166,10 @@ func (m *Manager) latestConversationSeqLocked(conversationID string) int64 { } func (m *Manager) chatRunCanClaimConversationLocked(conversationID string, requestID string) bool { - conversationID = strings.TrimSpace(conversationID) - requestID = strings.TrimSpace(requestID) if conversationID == "" || requestID == "" { return false } - currentRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]) + currentRequestID := m.chatStore.chatRunByConversation[conversationID] if currentRequestID == "" || currentRequestID == requestID { return true } @@ -185,7 +181,7 @@ func chatRunControlCanClaimConversation(controlType string, state string) bool { if normalizeChatRunState(state) == ChatRunStateRunning { return true } - return strings.TrimSpace(controlType) == "started" + return controlType == "started" } func (m *Manager) upsertChatRunSnapshotLocked( @@ -250,17 +246,7 @@ func (m *Manager) applyChatRunSnapshotLocked(run *chatRun, snapshot ChatRunSnaps requestID = run.requestID } conversationID := strings.TrimSpace(snapshot.ConversationID) - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } + m.updateRunConversationLocked(run, requestID, conversationID, false) if clientRequestID := strings.TrimSpace(snapshot.ClientRequestID); clientRequestID != "" { run.clientRequestID = clientRequestID m.chatStore.chatRunByClientRequest[clientRequestID] = requestID @@ -316,7 +302,7 @@ func (m *Manager) RemoveChatRunByConversation(conversationID string) { run := m.chatStore.chatRuns[requestID] if run == nil { for candidateRequestID, candidateRun := range m.chatStore.chatRuns { - if strings.TrimSpace(candidateRun.conversationID) == conversationID { + if candidateRun.conversationID == conversationID { requestID = candidateRequestID run = candidateRun break @@ -338,10 +324,10 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { seen := make(map[string]int, len(m.chatStore.chatRuns)) summaries := make([]ActiveChatRunSummary, 0, len(m.chatStore.chatRuns)) for _, run := range m.chatStore.chatRuns { - if run == nil || run.done || !ChatRunStateIsActive(run.state) { + if run == nil || run.done || !activeChatRunStates[run.state] { continue } - conversationID := strings.TrimSpace(run.conversationID) + conversationID := run.conversationID if conversationID == "" { continue } @@ -351,8 +337,8 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { } summary := ActiveChatRunSummary{ ConversationID: conversationID, - RequestID: strings.TrimSpace(run.requestID), - Workdir: strings.TrimSpace(run.workdir), + RequestID: run.requestID, + Workdir: run.workdir, FirstSeq: firstSeq, LatestSeq: run.nextSeq, RunEpoch: run.runEpoch, @@ -362,7 +348,7 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { if summaries[index].Workdir == "" { summaries[index].Workdir = summary.Workdir } - currentOwner := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]) + currentOwner := m.chatStore.chatRunByConversation[conversationID] if shouldReplaceActiveChatRunSummary(summary, summaries[index], currentOwner) { summaries[index].RequestID = summary.RequestID summaries[index].FirstSeq = summary.FirstSeq @@ -383,24 +369,42 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { } func shouldReplaceActiveChatRunSummary(candidate ActiveChatRunSummary, current ActiveChatRunSummary, currentOwner string) bool { - candidatePriority := activeChatRunSummaryPriority(candidate, currentOwner) - currentPriority := activeChatRunSummaryPriority(current, currentOwner) - if candidatePriority != currentPriority { - return candidatePriority > currentPriority + candidateIsOwner := currentOwner != "" && candidate.RequestID == currentOwner + currentIsOwner := currentOwner != "" && current.RequestID == currentOwner + if candidateIsOwner != currentIsOwner { + return candidateIsOwner } return candidate.UpdatedAt > current.UpdatedAt } -func activeChatRunSummaryPriority(summary ActiveChatRunSummary, currentOwner string) int { - requestID := strings.TrimSpace(summary.RequestID) - priority := 0 - if requestID != "" { - priority += 3 +func (m *Manager) ConversationRunSummary(conversationID string) (ActiveChatRunSummary, bool) { + conversationID = strings.TrimSpace(conversationID) + if conversationID == "" { + return ActiveChatRunSummary{}, false + } + m.chatStore.chatMu.Lock() + defer m.chatStore.chatMu.Unlock() + requestID := m.chatStore.chatRunByConversation[conversationID] + if requestID == "" { + return ActiveChatRunSummary{}, false + } + run := m.chatStore.chatRuns[requestID] + if run == nil { + return ActiveChatRunSummary{}, false } - if currentOwner != "" && requestID == currentOwner { - priority += 4 + firstSeq := run.snapshot().FirstSeq + if firstSeq <= 0 { + firstSeq = run.nextSeq + 1 } - return priority + return ActiveChatRunSummary{ + ConversationID: conversationID, + RequestID: run.requestID, + Workdir: run.workdir, + FirstSeq: firstSeq, + LatestSeq: run.nextSeq, + RunEpoch: run.runEpoch, + UpdatedAt: run.updatedAt.UnixMilli(), + }, true } func (m *Manager) FailStartingChatRun(requestID string, message string) bool { @@ -486,30 +490,22 @@ func (m *Manager) failChatRunIf( run.expiresAt = now.Add(chatRunDoneRetention) chatEvent := &gatewayv1.ChatEvent{ Type: gatewayv1.ChatEvent_ERROR, - ConversationId: strings.TrimSpace(run.conversationID), + ConversationId: run.conversationID, Data: string(data), } broadcast = &ChatBroadcastEvent{ RequestID: requestID, Event: chatEvent, Seq: run.nextSeq, - Workdir: strings.TrimSpace(run.workdir), + Workdir: run.workdir, } run.appendEvent(broadcast) persist = chatRunEventAppendSnapshot(run, broadcast, now) - runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } + runSubscribers = run.collectSubscribers() m.chatStore.chatMu.Unlock() m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } + notifySubscribers(runSubscribers, broadcast) return true, sessionEpoch } @@ -552,7 +548,7 @@ func (m *Manager) SubscribeChatRun( m.pruneExpiredChatRunsLocked(now) if conversationReplayRequested && conversationID != "" { - if liveRequestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); liveRequestID != "" { + if liveRequestID := m.chatStore.chatRunByConversation[conversationID]; liveRequestID != "" { requestID = liveRequestID } } else if requestID == "" && conversationID != "" { @@ -563,12 +559,12 @@ func (m *Manager) SubscribeChatRun( run = m.upsertChatRunSnapshotLocked(persistedSnapshot, m.currentSessionEpoch(), now) if run != nil { for _, event := range persistedReplay { - if strings.TrimSpace(event.RequestID) == strings.TrimSpace(run.requestID) { + if event.RequestID == run.requestID { run.appendEvent(event) } } } - } else if run != nil && persistedFound && strings.TrimSpace(run.requestID) == strings.TrimSpace(persistedSnapshot.RequestID) { + } else if run != nil && persistedFound && run.requestID == persistedSnapshot.RequestID { m.applyChatRunSnapshotLocked(run, persistedSnapshot, now) } if run == nil { @@ -578,31 +574,19 @@ func (m *Manager) SubscribeChatRun( } replay := make([]*ChatBroadcastEvent, 0) + var buffered []*ChatBroadcastEvent + if conversationReplayRequested && conversationID != "" { + buffered = m.collectConversationEventsLocked(conversationID, afterSeq) + } else { + buffered = collectBufferedEventsAfterSeq(run, afterSeq) + } if persistedFound { for _, event := range persistedReplay { replay = append(replay, cloneChatBroadcastEvent(event)) } - replay = mergeChatReplayEvents(replay, m.collectBufferedChatReplayLocked(run, conversationID, afterSeq, conversationReplayRequested)) - } else if conversationReplayRequested { - for _, candidate := range m.chatStore.chatRuns { - if candidate == nil || strings.TrimSpace(candidate.conversationID) != conversationID { - continue - } - for _, event := range candidate.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - sort.SliceStable(replay, func(i, j int) bool { - return replay[i].Seq < replay[j].Seq - }) + replay = mergeChatReplayEvents(replay, buffered) } else { - for _, event := range run.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } + replay = buffered } bufferSize := len(replay) + 128 @@ -652,30 +636,7 @@ func (m *Manager) SubscribeChatRun( return ch, done, cleanup, run.snapshot(), nil } -func (m *Manager) collectBufferedChatReplayLocked( - run *chatRun, - conversationID string, - afterSeq int64, - conversationReplayRequested bool, -) []*ChatBroadcastEvent { - if conversationReplayRequested { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return nil - } - replay := make([]*ChatBroadcastEvent, 0) - for _, candidate := range m.chatStore.chatRuns { - if candidate == nil || strings.TrimSpace(candidate.conversationID) != conversationID { - continue - } - for _, event := range candidate.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - return replay - } +func collectBufferedEventsAfterSeq(run *chatRun, afterSeq int64) []*ChatBroadcastEvent { if run == nil { return nil } @@ -688,36 +649,47 @@ func (m *Manager) collectBufferedChatReplayLocked( return replay } +func (m *Manager) collectConversationEventsLocked(conversationID string, afterSeq int64) []*ChatBroadcastEvent { + var replay []*ChatBroadcastEvent + for _, run := range m.chatStore.chatRuns { + if run == nil || run.conversationID != conversationID { + continue + } + for _, event := range run.events { + if event.Seq > afterSeq { + replay = append(replay, cloneChatBroadcastEvent(event)) + } + } + } + return replay +} + func mergeChatReplayEvents( persisted []*ChatBroadcastEvent, buffered []*ChatBroadcastEvent, ) []*ChatBroadcastEvent { - if len(persisted) == 0 && len(buffered) == 0 { - return nil + if len(persisted) == 0 { + return buffered } - merged := make([]*ChatBroadcastEvent, 0, len(persisted)+len(buffered)) - seen := make(map[string]struct{}, len(persisted)+len(buffered)) - appendEvent := func(event *ChatBroadcastEvent) { - if event == nil || event.Seq <= 0 { - return - } - key := strings.TrimSpace(event.RequestID) + "\x00" + strconv.FormatInt(event.Seq, 10) - if _, ok := seen[key]; ok { - return - } - seen[key] = struct{}{} - merged = append(merged, cloneChatBroadcastEvent(event)) + if len(buffered) == 0 { + return persisted } - for _, event := range persisted { - appendEvent(event) + seen := make(map[int64]struct{}, len(persisted)) + for _, e := range persisted { + if e != nil && e.Seq > 0 { + seen[e.Seq] = struct{}{} + } } - for _, event := range buffered { - appendEvent(event) + merged := make([]*ChatBroadcastEvent, 0, len(persisted)+len(buffered)) + merged = append(merged, persisted...) + for _, e := range buffered { + if e != nil && e.Seq > 0 { + if _, dup := seen[e.Seq]; !dup { + merged = append(merged, cloneChatBroadcastEvent(e)) + } + } } sort.SliceStable(merged, func(i, j int) bool { - if merged[i].Seq == merged[j].Seq { - return strings.TrimSpace(merged[i].RequestID) < strings.TrimSpace(merged[j].RequestID) - } return merged[i].Seq < merged[j].Seq }) return merged @@ -745,7 +717,6 @@ func (m *Manager) ChatRunSnapshot( } func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot, bool) { - conversationID = strings.TrimSpace(conversationID) if conversationID == "" { return ChatRunSnapshot{}, false } @@ -754,7 +725,7 @@ func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot defer m.chatStore.chatMu.Unlock() m.pruneExpiredChatRunsLocked(time.Now()) - if requestID := strings.TrimSpace(m.chatStore.chatRunByConversation[conversationID]); requestID != "" { + if requestID := m.chatStore.chatRunByConversation[conversationID]; requestID != "" { if run := m.chatStore.chatRuns[requestID]; chatRunIsRunningForConversation(run, conversationID) { return run.snapshot(), true } @@ -768,9 +739,9 @@ func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot } if best == nil || run.updatedAt.After(best.updatedAt) || - (run.updatedAt.Equal(best.updatedAt) && strings.TrimSpace(requestID) > bestRequestID) { + (run.updatedAt.Equal(best.updatedAt) && requestID > bestRequestID) { best = run - bestRequestID = strings.TrimSpace(requestID) + bestRequestID = requestID } } if best == nil { @@ -782,7 +753,7 @@ func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot func chatRunIsRunningForConversation(run *chatRun, conversationID string) bool { return run != nil && !run.done && - strings.TrimSpace(run.conversationID) == conversationID && + run.conversationID == conversationID && normalizeChatRunState(run.state) == ChatRunStateRunning } @@ -833,37 +804,14 @@ func (m *Manager) MarkChatRunPayloads( m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) run := m.chatStore.chatRuns[requestID] - if run == nil { - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: m.currentSessionEpoch(), - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - } - if run.done { + if run == nil || run.done { m.chatStore.chatMu.Unlock() - return nil - } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID + if run == nil { + log.Printf("MarkChatRunPayloads: no run for requestID=%s", requestID) } + return nil } + m.updateRunConversationLocked(run, requestID, conversationID, false) for _, payload := range payloads { broadcast := m.appendChatPayloadLocked(run, payload, now) if broadcast == nil { @@ -874,21 +822,18 @@ func (m *Manager) MarkChatRunPayloads( persists = append(persists, chatRunEventAppendSnapshot(run, broadcast, now)) } } - runSubscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } + runSubscribers := run.collectSubscribers() m.chatStore.chatMu.Unlock() if len(broadcasts) == 0 { return nil } m.persistChatBroadcasts(persists) - for _, subscriber := range runSubscribers { + for _, s := range runSubscribers { for _, broadcast := range broadcasts { select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): + case <-s.done: + case s.ch <- cloneChatBroadcastEvent(broadcast): } } } @@ -975,12 +920,7 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh return } previousState := normalizeChatRunState(run.state) - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID + m.updateRunConversationLocked(run, requestID, conversationID, state == ChatRunStateRunning) if clientRequestID != "" { run.clientRequestID = clientRequestID m.chatStore.chatRunByClientRequest[clientRequestID] = requestID @@ -996,39 +936,28 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh if snapshotRevision > 0 { run.runtimeSnapshotRevision = snapshotRevision } - if state == ChatRunStateRunning || m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } broadcast = m.appendChatPayloadLocked(run, payload, now) if broadcast != nil { persist = chatRunEventAppendSnapshot(run, broadcast, now) } - runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } + runSubscribers = run.collectSubscribers() if state == ChatRunStateRunning && (created || previousState != ChatRunStateRunning) { activityKind = "running" } else if terminalState { activityKind = "idle" } - activityConversationID = strings.TrimSpace(run.conversationID) - activityWorkdir = strings.TrimSpace(run.workdir) + activityConversationID = run.conversationID + activityWorkdir = run.workdir m.chatStore.chatMu.Unlock() if broadcast == nil { return } m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } + notifySubscribers(runSubscribers, broadcast) if terminalState { - for _, subscriber := range runSubscribers { - subscriber.close() + for _, s := range runSubscribers { + s.close() } } if activityKind != "" { @@ -1054,8 +983,6 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven requestID = strings.TrimSpace(requestID) conversationID := strings.TrimSpace(event.GetConversationId()) now := time.Now() - sessionEpoch := m.currentSessionEpoch() - m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) broadcast := &ChatBroadcastEvent{ @@ -1070,6 +997,7 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven var activityWorkdir string run := m.chatStore.chatRuns[requestID] if run == nil && requestID != "" { + sessionEpoch := m.currentSessionEpoch() m.chatStore.nextChatRunEpoch += 1 latestSeq := m.latestConversationSeqLocked(conversationID) run = &chatRun{ @@ -1084,85 +1012,51 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven } run.applyState(ChatRunStateQueued) m.chatStore.chatRuns[requestID] = run - if conversationID != "" { - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } + if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { + m.chatStore.chatRunByConversation[conversationID] = requestID } } - if run != nil { - if run.done { - m.chatStore.chatMu.Unlock() - return - } - previousState := normalizeChatRunState(run.state) - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - if !run.done && normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { - run.applyState(ChatRunStateRunning) - } - run.nextSeq += 1 - run.updatedAt = now - broadcast.Seq = run.nextSeq - broadcast.Workdir = strings.TrimSpace(run.workdir) - if isTerminalChatEvent(event) { - if event.GetType() == gatewayv1.ChatEvent_DONE { - run.applyState(ChatRunStateCompleted) - } else { - run.applyState(ChatRunStateFailed) - if run.errorCode == "" { - run.errorCode = "desktop_error" - } + if run == nil || run.done { + m.chatStore.chatMu.Unlock() + return + } + previousState := normalizeChatRunState(run.state) + m.updateRunConversationLocked(run, requestID, conversationID, false) + if normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { + run.applyState(ChatRunStateRunning) + } + run.nextSeq += 1 + run.updatedAt = now + broadcast.Seq = run.nextSeq + broadcast.Workdir = run.workdir + if isTerminalChatEvent(event) { + if event.GetType() == gatewayv1.ChatEvent_DONE { + run.applyState(ChatRunStateCompleted) + } else { + run.applyState(ChatRunStateFailed) + if run.errorCode == "" { + run.errorCode = "desktop_error" } - run.expiresAt = now.Add(chatRunDoneRetention) - } - nextState := normalizeChatRunState(run.state) - if isTerminalChatEvent(event) { - activityKind = "idle" - } else if previousState != ChatRunStateRunning && nextState == ChatRunStateRunning { - activityKind = "running" - } - if activityKind != "" { - activityConversationID = strings.TrimSpace(run.conversationID) - activityWorkdir = strings.TrimSpace(run.workdir) - } - run.appendEvent(broadcast) - if !isEphemeralChatBroadcastEvent(broadcast) { - persist = chatRunEventAppendSnapshot(run, broadcast, now) - } - if isFirstDeltaChatEvent(event) && !run.firstDeltaLogged { - run.firstDeltaLogged = true - firstDelta = cloneChatBroadcastEvent(broadcast) - } - runSubscribers = make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) } + run.expiresAt = now.Add(chatRunDoneRetention) } + nextState := normalizeChatRunState(run.state) + activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) + run.appendEvent(broadcast) + if !isEphemeralChatBroadcastEvent(broadcast) { + persist = chatRunEventAppendSnapshot(run, broadcast, now) + } + if isFirstDeltaChatEvent(event) && !run.firstDeltaLogged { + run.firstDeltaLogged = true + firstDelta = cloneChatBroadcastEvent(broadcast) + } + runSubscribers = run.collectSubscribers() m.chatStore.chatMu.Unlock() if firstDelta != nil { logChatRunSpan("first_delta", firstDelta) } - m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } + m.finalizeChatRunBroadcast(broadcast, persist, runSubscribers, activityKind, activityConversationID, activityWorkdir, now) } func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.ChatControlEvent) { @@ -1177,7 +1071,7 @@ func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.Chat controlType := strings.TrimSpace(control.GetType()) state := normalizeChatRunState(control.GetState()) if state == "" { - state = chatRunStateForControlType(controlType) + state = controlTypeToState[controlType] } errorCode := strings.TrimSpace(control.GetErrorCode()) message := strings.TrimSpace(control.GetMessage()) @@ -1190,8 +1084,6 @@ func (m *Manager) markChatRunStateSilent( state string, now time.Time, ) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) state = normalizeChatRunState(state) if requestID == "" || state == "" { return @@ -1203,17 +1095,7 @@ func (m *Manager) markChatRunStateSilent( if run == nil || run.done { return } - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if state == ChatRunStateRunning || m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } + m.updateRunConversationLocked(run, requestID, conversationID, state == ChatRunStateRunning) run.applyState(state) run.updatedAt = now if isTerminalChatRunState(state) { @@ -1230,16 +1112,16 @@ func (m *Manager) markChatRunControl( message string, now time.Time, ) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) if requestID == "" { return } state = normalizeChatRunState(state) - controlType = strings.TrimSpace(controlType) if controlType == "" { - controlType = chatControlTypeForState(state) + controlType = stateToControlType[normalizeChatRunState(state)] + if controlType == "" { + controlType = "progress" + } } var persist ChatRunEventAppend @@ -1249,73 +1131,27 @@ func (m *Manager) markChatRunControl( m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) run := m.chatStore.chatRuns[requestID] - if run == nil { - m.chatStore.nextChatRunEpoch += 1 - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: m.currentSessionEpoch(), - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) - m.chatStore.chatRuns[requestID] = run - } - if run.done { + if run == nil || run.done { m.chatStore.chatMu.Unlock() + if run == nil { + log.Printf("markChatRunControl: no run for requestID=%s controlType=%s", requestID, controlType) + } return } previousState := normalizeChatRunState(run.state) - if conversationID != "" { - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if chatRunControlCanClaimConversation(controlType, state) || - m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } + canClaim := chatRunControlCanClaimConversation(controlType, state) + m.updateRunConversationLocked(run, requestID, conversationID, canClaim) broadcast := m.appendChatControlLocked(run, controlType, errorCode, message, now) nextState := normalizeChatRunState(run.state) - if isTerminalChatRunState(nextState) { - activityKind = "idle" - } else if previousState != ChatRunStateRunning && nextState == ChatRunStateRunning { - activityKind = "running" - } - if activityKind != "" { - activityConversationID = strings.TrimSpace(run.conversationID) - activityWorkdir = strings.TrimSpace(run.workdir) - } + activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) persist = chatRunEventAppendSnapshot(run, broadcast, now) - runSubscribers := make([]*chatRunSubscriber, 0, len(run.subscribers)) - for _, subscriber := range run.subscribers { - runSubscribers = append(runSubscribers, subscriber) - } + runSubscribers := run.collectSubscribers() m.chatStore.chatMu.Unlock() - if broadcast == nil { - return - } if span := chatControlSpanName(broadcast.Control); span != "" { logChatRunSpan(span, broadcast) } - m.persistChatBroadcast(persist) - for _, subscriber := range runSubscribers { - select { - case <-subscriber.done: - case subscriber.ch <- cloneChatBroadcastEvent(broadcast): - } - } - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } + m.finalizeChatRunBroadcast(broadcast, persist, runSubscribers, activityKind, activityConversationID, activityWorkdir, now) } func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { @@ -1391,11 +1227,10 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent } func (r *chatRun) snapshot() ChatRunSnapshot { - firstSeq := int64(0) + var firstSeq int64 if len(r.events) > 0 { firstSeq = r.events[0].Seq } - state := normalizeChatRunState(r.state) return ChatRunSnapshot{ RequestID: r.requestID, ConversationID: r.conversationID, @@ -1404,8 +1239,8 @@ func (r *chatRun) snapshot() ChatRunSnapshot { FirstSeq: firstSeq, LatestSeq: r.nextSeq, RunEpoch: r.runEpoch, - State: state, - ErrorCode: strings.TrimSpace(r.errorCode), + State: r.state, + ErrorCode: r.errorCode, Done: r.done, } } @@ -1464,16 +1299,7 @@ func (r *chatRun) shouldPrune(now time.Time) bool { if r.done { return !r.expiresAt.IsZero() && now.After(r.expiresAt) } - if chatRunUpdatedBefore(r.updatedAt, now, chatRunStaleRetention) { - return true - } - return normalizeChatRunState(r.state) != ChatRunStateRunning && - normalizeChatRunState(r.state) != ChatRunStateDesktopQueued && - chatRunUpdatedBefore(r.updatedAt, now, chatRunStartRetention) -} - -func chatRunUpdatedBefore(updatedAt time.Time, now time.Time, retention time.Duration) bool { - return !updatedAt.IsZero() && retention > 0 && now.Sub(updatedAt) > retention + return !r.updatedAt.IsZero() && now.Sub(r.updatedAt) > chatRunStaleRetention } func (s *chatRunSubscriber) close() { @@ -1482,6 +1308,38 @@ func (s *chatRunSubscriber) close() { }) } +func (m *Manager) updateRunConversationLocked(run *chatRun, requestID string, conversationID string, canClaim bool) { + if conversationID == "" { + return + } + if run.conversationID != "" && run.conversationID != conversationID { + if m.chatStore.chatRunByConversation[run.conversationID] == requestID { + delete(m.chatStore.chatRunByConversation, run.conversationID) + } + } + run.conversationID = conversationID + if canClaim || m.chatRunCanClaimConversationLocked(conversationID, requestID) { + m.chatStore.chatRunByConversation[conversationID] = requestID + } +} + +func (r *chatRun) collectSubscribers() []*chatRunSubscriber { + subs := make([]*chatRunSubscriber, 0, len(r.subscribers)) + for _, s := range r.subscribers { + subs = append(subs, s) + } + return subs +} + +func notifySubscribers(subscribers []*chatRunSubscriber, broadcast *ChatBroadcastEvent) { + for _, s := range subscribers { + select { + case <-s.done: + case s.ch <- cloneChatBroadcastEvent(broadcast): + } + } +} + func (m *Manager) pruneExpiredChatRunsLocked(now time.Time) { for requestID, run := range m.chatStore.chatRuns { if run == nil { @@ -1535,114 +1393,95 @@ func cloneChatPayloadMap(input map[string]any) map[string]any { if len(input) == 0 { return nil } - raw, err := json.Marshal(input) - if err != nil { - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out - } - var out map[string]any - if err := json.Unmarshal(raw, &out); err != nil { - out = make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } + out := make(map[string]any, len(input)) + for k, v := range input { + out[k] = v } return out } -func normalizeChatRunState(state string) string { - switch strings.TrimSpace(state) { - case ChatRunStateQueued: - return ChatRunStateQueued - case ChatRunStateDelivered: - return ChatRunStateDelivered - case ChatRunStateClaimed: - return ChatRunStateClaimed - case ChatRunStateStarting: - return ChatRunStateStarting - case ChatRunStateDesktopQueued: - return ChatRunStateDesktopQueued - case ChatRunStateRunning: - return ChatRunStateRunning - case ChatRunStateCompleted: - return ChatRunStateCompleted - case ChatRunStateFailed: - return ChatRunStateFailed - case ChatRunStateCancelled: - return ChatRunStateCancelled - default: - return "" - } +var validChatRunStates = map[string]bool{ + ChatRunStateQueued: true, + ChatRunStateDelivered: true, + ChatRunStateClaimed: true, + ChatRunStateStarting: true, + ChatRunStateDesktopQueued: true, + ChatRunStateRunning: true, + ChatRunStateCompleted: true, + ChatRunStateFailed: true, + ChatRunStateCancelled: true, } -func isTerminalChatRunState(state string) bool { - switch normalizeChatRunState(state) { - case ChatRunStateCompleted, ChatRunStateFailed, ChatRunStateCancelled: - return true - default: - return false +func normalizeChatRunState(state string) string { + if validChatRunStates[state] { + return state } + return "" } -func ChatRunStateIsActive(state string) bool { - switch normalizeChatRunState(state) { - case ChatRunStateQueued, ChatRunStateDelivered, ChatRunStateClaimed, ChatRunStateStarting, ChatRunStateRunning: - return true - default: - return false - } +var terminalChatRunStates = map[string]bool{ + ChatRunStateCompleted: true, + ChatRunStateFailed: true, + ChatRunStateCancelled: true, } -func chatRunStateForControlType(controlType string) string { - switch strings.TrimSpace(controlType) { - case "accepted": - return ChatRunStateQueued - case "delivered": - return ChatRunStateDelivered - case "claimed": - return ChatRunStateClaimed - case "starting": - return ChatRunStateStarting - case "queued_in_gui": - return ChatRunStateDesktopQueued - case "started": - return ChatRunStateRunning - case "completed": - return ChatRunStateCompleted - case "failed": - return ChatRunStateFailed - case "cancelled": - return ChatRunStateCancelled - default: - return "" +func isTerminalChatRunState(state string) bool { + return terminalChatRunStates[normalizeChatRunState(state)] +} + +var activeChatRunStates = map[string]bool{ + ChatRunStateQueued: true, + ChatRunStateDelivered: true, + ChatRunStateClaimed: true, + ChatRunStateStarting: true, + ChatRunStateRunning: true, +} + +var controlTypeToState = map[string]string{ + "accepted": ChatRunStateQueued, + "delivered": ChatRunStateDelivered, + "claimed": ChatRunStateClaimed, + "starting": ChatRunStateStarting, + "queued_in_gui": ChatRunStateDesktopQueued, + "started": ChatRunStateRunning, + "completed": ChatRunStateCompleted, + "failed": ChatRunStateFailed, + "cancelled": ChatRunStateCancelled, +} + +var stateToControlType = map[string]string{ + ChatRunStateQueued: "accepted", + ChatRunStateDelivered: "delivered", + ChatRunStateClaimed: "claimed", + ChatRunStateStarting: "starting", + ChatRunStateDesktopQueued: "queued_in_gui", + ChatRunStateRunning: "started", + ChatRunStateCompleted: "completed", + ChatRunStateFailed: "failed", + ChatRunStateCancelled: "cancelled", +} + +func detectRunActivity(run *chatRun, previousState, nextState string) (kind, conversationID, workdir string) { + if isTerminalChatRunState(nextState) { + kind = "idle" + } else if previousState != ChatRunStateRunning && nextState == ChatRunStateRunning { + kind = "running" } + if kind != "" { + conversationID = run.conversationID + workdir = run.workdir + } + return } -func chatControlTypeForState(state string) string { - switch normalizeChatRunState(state) { - case ChatRunStateQueued: - return "accepted" - case ChatRunStateDelivered: - return "delivered" - case ChatRunStateClaimed: - return "claimed" - case ChatRunStateStarting: - return "starting" - case ChatRunStateDesktopQueued: - return "queued_in_gui" - case ChatRunStateRunning: - return "started" - case ChatRunStateCompleted: - return "completed" - case ChatRunStateFailed: - return "failed" - case ChatRunStateCancelled: - return "cancelled" - default: - return "progress" +func (m *Manager) finalizeChatRunBroadcast(broadcast *ChatBroadcastEvent, persist ChatRunEventAppend, subscribers []*chatRunSubscriber, activityKind, activityConversationID, activityWorkdir string, now time.Time) { + if broadcast == nil { + return + } + m.persistChatBroadcast(persist) + notifySubscribers(subscribers, broadcast) + if activityKind != "" { + m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) } } @@ -1656,8 +1495,7 @@ func (m *Manager) appendChatControlLocked( if run == nil { return nil } - controlType = strings.TrimSpace(controlType) - state := chatRunStateForControlType(controlType) + state := controlTypeToState[controlType] if state == "" { state = normalizeChatRunState(run.state) } @@ -1665,7 +1503,7 @@ func (m *Manager) appendChatControlLocked( state = ChatRunStateQueued } run.applyState(state) - if errorCode = strings.TrimSpace(errorCode); errorCode != "" { + if errorCode != "" { run.errorCode = errorCode } run.updatedAt = now @@ -1675,24 +1513,27 @@ func (m *Manager) appendChatControlLocked( run.nextSeq += 1 seq := run.nextSeq if controlType == "" { - controlType = chatControlTypeForState(state) + controlType = stateToControlType[normalizeChatRunState(state)] + if controlType == "" { + controlType = "progress" + } } control := &gatewayv1.ChatControlEvent{ - RequestId: strings.TrimSpace(run.requestID), - ClientRequestId: strings.TrimSpace(run.clientRequestID), - ConversationId: strings.TrimSpace(run.conversationID), + RequestId: run.requestID, + ClientRequestId: run.clientRequestID, + ConversationId: run.conversationID, RunEpoch: run.runEpoch, Type: controlType, - State: normalizeChatRunState(run.state), - ErrorCode: strings.TrimSpace(run.errorCode), - Message: strings.TrimSpace(message), + State: run.state, + ErrorCode: run.errorCode, + Message: message, Seq: seq, } broadcast := &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(run.requestID), + RequestID: run.requestID, Control: control, Seq: seq, - Workdir: strings.TrimSpace(run.workdir), + Workdir: run.workdir, } run.appendEvent(broadcast) return broadcast @@ -1713,20 +1554,17 @@ func (m *Manager) appendChatPayloadLocked( if nextPayload == nil { nextPayload = make(map[string]any) } - if eventType, _ := nextPayload["type"].(string); strings.TrimSpace(eventType) != "" { - nextPayload["type"] = strings.TrimSpace(eventType) - } - nextPayload["request_id"] = strings.TrimSpace(run.requestID) - nextPayload["client_request_id"] = strings.TrimSpace(run.clientRequestID) - nextPayload["conversation_id"] = strings.TrimSpace(run.conversationID) + nextPayload["request_id"] = run.requestID + nextPayload["client_request_id"] = run.clientRequestID + nextPayload["conversation_id"] = run.conversationID nextPayload["run_epoch"] = run.runEpoch - nextPayload["state"] = normalizeChatRunState(run.state) + nextPayload["state"] = run.state nextPayload["seq"] = seq broadcast := &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(run.requestID), + RequestID: run.requestID, Payload: nextPayload, Seq: seq, - Workdir: strings.TrimSpace(run.workdir), + Workdir: run.workdir, } run.appendEvent(broadcast) return broadcast @@ -1741,13 +1579,13 @@ func chatRunEventAppendSnapshot( return ChatRunEventAppend{} } return ChatRunEventAppend{ - RequestID: strings.TrimSpace(run.requestID), - ConversationID: strings.TrimSpace(run.conversationID), - ClientRequestID: strings.TrimSpace(run.clientRequestID), - Workdir: strings.TrimSpace(run.workdir), + RequestID: run.requestID, + ConversationID: run.conversationID, + ClientRequestID: run.clientRequestID, + Workdir: run.workdir, RunEpoch: run.runEpoch, - State: normalizeChatRunState(run.state), - ErrorCode: strings.TrimSpace(run.errorCode), + State: run.state, + ErrorCode: run.errorCode, Done: run.done, Event: cloneChatBroadcastEvent(broadcast), CreatedAt: now, @@ -1805,7 +1643,7 @@ func isEphemeralChatPayload(payload map[string]any) bool { return false } eventType, _ := payload["type"].(string) - return strings.TrimSpace(eventType) == "tool_call_delta" + return eventType == "tool_call_delta" } func isEphemeralChatEvent(event *gatewayv1.ChatEvent) bool { @@ -1834,37 +1672,29 @@ func runtimeSnapshotRevisionFromPayload(payload map[string]any) int64 { return 0 } eventType, _ := payload["type"].(string) - if strings.TrimSpace(eventType) != "runtime_snapshot" { + if eventType != "runtime_snapshot" { return 0 } - switch value := payload["revision"].(type) { + return toPositiveInt64(payload["revision"]) +} + +func toPositiveInt64(v any) int64 { + switch n := v.(type) { case int64: - if value > 0 { - return value - } - case int: - if value > 0 { - return int64(value) - } - case int32: - if value > 0 { - return int64(value) - } - case uint64: - if value > 0 && value <= uint64(^uint64(0)>>1) { - return int64(value) + if n > 0 { + return n } case float64: - if value > 0 { - return int64(value) + if n > 0 { + return int64(n) } case json.Number: - if parsed, err := value.Int64(); err == nil && parsed > 0 { + if parsed, err := n.Int64(); err == nil && parsed > 0 { return parsed } - case string: - if parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && parsed > 0 { - return parsed + case int: + if n > 0 { + return int64(n) } } return 0 @@ -1874,7 +1704,7 @@ func chatControlSpanName(control *gatewayv1.ChatControlEvent) string { if control == nil { return "" } - switch strings.TrimSpace(control.GetType()) { + switch control.GetType() { case "claimed": return "runtime_claimed" case "started": @@ -1894,25 +1724,25 @@ func logChatRunSpan(span string, event *ChatBroadcastEvent) { if event == nil { return } - runID := strings.TrimSpace(event.RequestID) + runID := event.RequestID conversationID := "" clientRequestID := "" if event.Control != nil { - conversationID = strings.TrimSpace(event.Control.GetConversationId()) - clientRequestID = strings.TrimSpace(event.Control.GetClientRequestId()) + conversationID = event.Control.GetConversationId() + clientRequestID = event.Control.GetClientRequestId() } else if event.Payload != nil { if value, ok := event.Payload["conversation_id"].(string); ok { - conversationID = strings.TrimSpace(value) + conversationID = value } if value, ok := event.Payload["client_request_id"].(string); ok { - clientRequestID = strings.TrimSpace(value) + clientRequestID = value } } else if event.Event != nil { - conversationID = strings.TrimSpace(event.Event.GetConversationId()) + conversationID = event.Event.GetConversationId() } log.Printf( "chat_run_span span=%s run_id=%q conversation_id=%q client_request_id=%q seq=%d", - strings.TrimSpace(span), + span, runID, conversationID, clientRequestID, diff --git a/crates/agent-gateway/internal/session/manager_test.go b/crates/agent-gateway/internal/session/manager_test.go index 567a7df4..652fe5a4 100644 --- a/crates/agent-gateway/internal/session/manager_test.go +++ b/crates/agent-gateway/internal/session/manager_test.go @@ -157,18 +157,18 @@ func TestChatRunShouldPruneRetainsRunningUntilStale(t *testing.T) { now := time.Now() running := &chatRun{ state: ChatRunStateRunning, - updatedAt: now.Add(-(chatRunStartRetention + time.Second)), + updatedAt: now.Add(-time.Hour), } if running.shouldPrune(now) { - t.Fatal("running chat should survive the start retention window") + t.Fatal("running chat should survive well before stale retention") } - queued := &chatRun{ + stale := &chatRun{ state: ChatRunStateQueued, - updatedAt: now.Add(-(chatRunStartRetention + time.Second)), + updatedAt: now.Add(-(chatRunStaleRetention + time.Second)), } - if !queued.shouldPrune(now) { - t.Fatal("unstarted queued chat should prune after start retention") + if !stale.shouldPrune(now) { + t.Fatal("non-done chat should prune after stale retention") } done := &chatRun{ @@ -192,3 +192,34 @@ func TestPruneExpiredChatRunsDropsNilEntries(t *testing.T) { t.Fatal("nil chat run should be deleted during pruning") } } + +func TestConversationRunSummaryReturnsCompletedRun(t *testing.T) { + manager := NewManager() + + snapshot, created, _, err := manager.StartAcceptedChatCommandRun("run-1", "conv-1", "client-1", "/workspace", nil) + if err != nil || !created { + t.Fatalf("StartAcceptedChatCommandRun failed: err=%v created=%v", err, created) + } + _ = snapshot + + manager.MarkChatRunControl("run-1", "conv-1", "started", "", "") + + summary, ok := manager.ConversationRunSummary("conv-1") + if !ok || summary.RequestID != "run-1" { + t.Fatalf("expected running run summary, got ok=%v summary=%+v", ok, summary) + } + + manager.MarkChatRunControl("run-1", "conv-1", "completed", "", "") + + summary, ok = manager.ConversationRunSummary("conv-1") + if !ok || summary.RequestID != "run-1" { + t.Fatalf("expected completed run summary via ConversationRunSummary, got ok=%v summary=%+v", ok, summary) + } + + actives := manager.ActiveChatRunSummaries() + for _, s := range actives { + if s.ConversationID == "conv-1" { + t.Fatal("completed run should not appear in ActiveChatRunSummaries") + } + } +} diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 18aa4a46..8ad3dd13 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -81,6 +81,8 @@ import type { HistoryDetail, HistoryShareStatus, HistoryWorkdirSummary, + LiveConversationStreamSnapshot, + LiveConversationStreamStore, } from "@/lib/gatewayTypes"; import { filterConversationSummariesForScope, @@ -88,31 +90,243 @@ import { } from "@/lib/chat/historyListScope"; import { buildOptimisticConversationTitle, + pushChatEvent, resolveConversationBrowserTitle, type ChatEntry, } from "@/lib/chatUi"; import { parseHistoryMessagesJsonAsync } from "@/lib/historyParser"; -import { - isChatStreamNotAvailableEvent, - isChatStreamNotAvailableMessage, - isRecoverableChatStreamTransportMessage, - resolveChatStreamUnavailableRecoveryAction, - shouldHydrateRestoredConversationSnapshot, -} from "@/lib/chatStreamRecovery"; + +const CHAT_STREAM_NOT_AVAILABLE_RE = /\bchat stream not available\b/i; +const RECOVERABLE_TRANSPORT_MESSAGE_RE = + /\b(?:502\s+bad\s+gateway|503\s+service\s+unavailable|504\s+gateway\s+timeout|bad\s+gateway|gateway\s+timeout|service\s+unavailable|temporarily\s+unavailable|failed\s+to\s+fetch|networkerror|network\s+error|load\s+failed|connection\s+(?:reset|closed|lost)|socket\s+hang\s+up|err_(?:network|internet_disconnected|connection_(?:reset|closed|refused)))\b/i; + +function isChatStreamNotAvailableMessage(value: unknown) { + const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); + return CHAT_STREAM_NOT_AVAILABLE_RE.test(message.trim()); +} +function isRecoverableChatStreamTransportMessage(value: unknown) { + const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); + return RECOVERABLE_TRANSPORT_MESSAGE_RE.test(message.trim()); +} +function isChatStreamNotAvailableEvent(event: ChatEvent) { + return event.type === "error" && isChatStreamNotAvailableMessage(event.message); +} +function collectAssistantLikeText(entries: ChatEntry[]) { + return entries.map((e) => (e.kind === "assistant" || e.kind === "thinking" || e.kind === "error" ? e.text : "")).join("\n").trim(); +} +function shouldHydrateRestoredConversationSnapshot(params: { + currentEntries: ChatEntry[]; + historyEntries: ChatEntry[]; + liveEntries?: ChatEntry[]; +}) { + if (params.historyEntries.length === 0) return false; + const liveEntries = params.liveEntries ?? []; + if (params.currentEntries.length === 0 && liveEntries.length === 0) return true; + const currentText = collectAssistantLikeText([...params.currentEntries, ...liveEntries]); + const historyText = collectAssistantLikeText(params.historyEntries); + if (historyText.length === 0) return liveEntries.length === 0 && params.historyEntries.length > params.currentEntries.length; + if (currentText.length === 0) return true; + return historyText.length > currentText.length; +} import { memoryDeleteProject } from "@/lib/memory/api"; -import { - appendCommittedLiveEntries, - hasEquivalentTailEntries, - mergeHistorySnapshotEntries, -} from "@/lib/liveConversationCommit"; -import { - createLocalDraftConversationId, - isLocalDraftConversationId, -} from "@/lib/localDraftConversation"; -import { - createLiveConversationStreamStore, - type LiveConversationStreamStore, -} from "@/lib/liveConversationStreamStore"; +function chatEntryContentKey(entry: ChatEntry) { + if (entry.kind === "user") { + return JSON.stringify({ kind: entry.kind, text: entry.text }); + } + const { id: _id, ...rest } = entry; + return JSON.stringify(rest); +} + +function hasEquivalentTailEntries(existing: ChatEntry[], tail: ChatEntry[]) { + if (tail.length === 0 || existing.length < tail.length) return false; + const offset = existing.length - tail.length; + for (let i = 0; i < tail.length; i++) { + if (chatEntryContentKey(existing[offset + i]) !== chatEntryContentKey(tail[i])) return false; + } + return true; +} + +function findEntryOverlap(existing: ChatEntry[], incoming: ChatEntry[]) { + const max = Math.min(existing.length, incoming.length); + for (let overlap = max; overlap > 0; overlap--) { + let matches = true; + const start = existing.length - overlap; + for (let i = 0; i < overlap; i++) { + if (chatEntryContentKey(existing[start + i]) !== chatEntryContentKey(incoming[i])) { + matches = false; + break; + } + } + if (matches) return overlap; + } + return 0; +} + +function mergeHistorySnapshotEntries( + existing: ChatEntry[], + incoming: ChatEntry[], + options?: { isFullSnapshot?: boolean }, +) { + const isFullSnapshot = options?.isFullSnapshot === true; + if (incoming.length === 0) return isFullSnapshot ? [] : existing; + if (hasEquivalentTailEntries(existing, incoming)) return existing; + if (existing.length === 0) return incoming.map((e) => structuredClone(e)); + const overlap = findEntryOverlap(existing, incoming); + if (overlap > 0) { + return [...existing.slice(0, existing.length - overlap), ...incoming.map((e) => structuredClone(e))]; + } + if (incoming.length >= existing.length || isFullSnapshot) { + return incoming.map((e) => structuredClone(e)); + } + return existing; +} + +function appendCommittedLiveEntries(existing: ChatEntry[], live: ChatEntry[]) { + if (live.length === 0 || hasEquivalentTailEntries(existing, live)) return existing; + const overlap = findEntryOverlap(existing, live); + const base = overlap > 0 ? existing.slice(0, existing.length - overlap) : existing; + const toCommit = overlap > 0 ? live.slice(overlap) : live; + if (toCommit.length === 0) return base; + return [...base, ...toCommit.map((e, i) => ({ ...structuredClone(e), id: `committed-live-${e.kind}-${base.length + i}` }))]; +} +const EMPTY_LIVE_SNAPSHOT: LiveConversationStreamSnapshot = { + revision: 0, + entries: [], + toolStatus: null, + toolStatusIsCompaction: false, +}; + +function isSnapshotChatEntry(value: unknown): value is ChatEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const v = value as Record; + if (typeof v.id !== "string" || typeof v.kind !== "string") return false; + switch (v.kind) { + case "user": return typeof v.text === "string" && Array.isArray(v.attachments); + case "assistant": case "thinking": case "error": return typeof v.text === "string"; + case "tool_call": return v.toolCall != null && typeof v.toolCall === "object"; + case "tool_result": return v.toolResult != null && typeof v.toolResult === "object"; + case "hosted_search": return v.hostedSearch != null && typeof v.hostedSearch === "object"; + default: return false; + } +} + +function parseSnapshotEntries(json: string | undefined): ChatEntry[] { + const raw = typeof json === "string" ? json.trim() : ""; + if (!raw) return []; + try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; } + catch { return []; } +} + +function isTerminalStreamEvent(event: ChatEvent) { + if (event.type === "done" || event.type === "error") return true; + if (event.type !== "completed" && event.type !== "failed" && event.type !== "cancelled") return false; + return event.state === "completed" || event.state === "failed" || event.state === "cancelled"; +} + +function isControlStreamEvent(event: ChatEvent) { + switch (event.type) { + case "accepted": case "user_message": case "rebased": case "projection_updated": + case "delivered": case "claimed": case "starting": case "queued_in_gui": + case "started": case "progress": case "completed": case "failed": case "cancelled": + return true; + default: return false; + } +} + +function createLiveConversationStreamStore(): LiveConversationStreamStore { + let snapshot = EMPTY_LIVE_SNAPSHOT; + let draft = EMPTY_LIVE_SNAPSHOT; + let rafId: number | null = null; + let latestSnapshotRev = 0; + let latestSnapshotSeq = 0; + const seenSeqs = new Set(); + const listeners = new Set<() => void>(); + + const emit = () => { listeners.forEach((l) => l()); }; + const cancel = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }; + const commit = () => { rafId = null; if (snapshot !== draft) { snapshot = draft; emit(); } }; + const schedule = (flush?: boolean) => { + if (flush) { cancel(); snapshot = draft; emit(); return; } + if (rafId === null) rafId = requestAnimationFrame(commit); + }; + const update = (next: LiveConversationStreamSnapshot, flush?: boolean) => { + draft = { ...next, revision: draft.revision + 1 }; + schedule(flush); + }; + + const readSeq = (event: { seq?: number }) => { + const s = event.seq; + return typeof s === "number" && Number.isFinite(s) && s > 0 ? Math.floor(s) : null; + }; + + return { + getSnapshot: () => snapshot, + subscribe: (l) => { listeners.add(l); return () => { listeners.delete(l); }; }, + appendEvent: (event, opts) => { + const seq = readSeq(event); + if (seq !== null) { + if (latestSnapshotSeq > 0 && seq <= latestSnapshotSeq) return; + if (seenSeqs.has(seq)) return; + seenSeqs.add(seq); + } + if (event.type === "tool_status") { + const status = typeof event.status === "string" && event.status.trim() ? event.status.trim() : null; + const isCompaction = Boolean(status) && event.isCompaction === true; + if (draft.toolStatus === status && draft.toolStatusIsCompaction === isCompaction) return; + update({ ...draft, toolStatus: status, toolStatusIsCompaction: isCompaction }, opts?.flush); + return; + } + const terminal = isTerminalStreamEvent(event); + const shouldAppend = event.type === "user_message" || event.type === "error" || event.type === "failed" || (!terminal && !isControlStreamEvent(event)); + const nextEntries = shouldAppend ? pushChatEvent(draft.entries, event) : draft.entries; + const clearStatus = terminal; + if (nextEntries === draft.entries && (!clearStatus || (draft.toolStatus === null && !draft.toolStatusIsCompaction))) return; + update({ + ...draft, + entries: nextEntries, + toolStatus: clearStatus ? null : draft.toolStatus, + toolStatusIsCompaction: clearStatus ? false : draft.toolStatusIsCompaction, + }, opts?.flush); + }, + applySnapshot: (event, opts) => { + const seq = readSeq(event); + const rev = typeof event.revision === "number" && event.revision > 0 ? Math.floor(event.revision) : 0; + if (rev > 0 && latestSnapshotRev > 0 && rev < latestSnapshotRev) return; + if (rev > 0 && rev === latestSnapshotRev && seq !== null && latestSnapshotSeq > 0 && seq <= latestSnapshotSeq) return; + const entries = parseSnapshotEntries(event.entries_json); + const toolStatus = typeof event.tool_status === "string" && event.tool_status.trim() ? event.tool_status.trim() : null; + const toolStatusIsCompaction = Boolean(toolStatus) && event.tool_status_is_compaction === true; + latestSnapshotRev = Math.max(latestSnapshotRev, rev); + if (seq !== null) { latestSnapshotSeq = Math.max(latestSnapshotSeq, seq); seenSeqs.add(seq); } + update({ ...draft, entries, toolStatus, toolStatusIsCompaction }, opts?.flush); + }, + setToolStatus: (toolStatus, isCompaction = false, opts) => { + const status = typeof toolStatus === "string" && toolStatus.trim() ? toolStatus.trim() : null; + const nextIsCompaction = Boolean(status) && isCompaction; + if (draft.toolStatus === status && draft.toolStatusIsCompaction === nextIsCompaction) return; + update({ ...draft, toolStatus: status, toolStatusIsCompaction: nextIsCompaction }, opts?.flush); + }, + reset: () => { + if (draft.entries.length === 0 && draft.toolStatus === null && !draft.toolStatusIsCompaction) { cancel(); return; } + draft = { ...EMPTY_LIVE_SNAPSHOT, revision: draft.revision + 1 }; + seenSeqs.clear(); + latestSnapshotRev = 0; + latestSnapshotSeq = 0; + cancel(); + snapshot = draft; + emit(); + }, + flush: () => { cancel(); commit(); }, + }; +} + +const LOCAL_DRAFT_PREFIX = "__local_draft__:"; +function createLocalDraftConversationId() { + return `${LOCAL_DRAFT_PREFIX}${crypto.randomUUID()}`; +} +function isLocalDraftConversationId(id: string) { + return id.trim().startsWith(LOCAL_DRAFT_PREFIX); +} import { applyGatewayHistoryEvent, normalizeRunningConversations, @@ -202,7 +416,6 @@ import { } from "./historyUtils"; import type { ConversationRuntimeEntry, - LiveConversationStreamMeta, ModelProviderSource, OverlayState, PendingDraftConversationMigration, @@ -272,9 +485,6 @@ export default function GatewayApp() { const [projectActivityUpdatedAtOverrides, setProjectActivityUpdatedAtOverrides] = useState< ReadonlyMap >(() => new Map()); - const [liveConversationStreamMeta, setLiveConversationStreamMetaState] = useState< - Record - >({}); const [selectedHistoryId, setSelectedHistoryId] = useState(""); const [selectedHistory, setSelectedHistory] = useState(null); const [selectedHistoryEntries, setSelectedHistoryEntries] = useState([]); @@ -398,20 +608,16 @@ export default function GatewayApp() { const liveConversationStreamStoresRef = useRef>( new Map(), ); - const liveConversationStreamMetaRef = useRef>({}); const conversationRuntimeCacheRef = useRef>(new Map()); const displayedConversationWorkdirRef = useRef(""); const conversationAbortControllersRef = useRef>(new Map()); - const conversationEventStreamControllersRef = useRef>(new Map()); - const conversationEventStreamRunIdsRef = useRef>(new Map()); - const completedLiveStreamConversationAtRef = useRef>(new Map()); - const completedLiveStreamCleanupTimersRef = useRef>(new Map()); + const conversationEventStreamsRef = useRef>(new Map()); + const completedLiveStreamMarkersRef = useRef>(new Map()); const pendingHistoryRefreshAfterLiveCompletionRef = useRef>(new Set()); const optimisticTitleConversationIdsRef = useRef>(new Set()); const titlePositionLockedConversationIdsRef = useRef>(new Set()); const titlePositionLockTimeoutsRef = useRef>(new Map()); const blockedHistoryHydrationConversationIdsRef = useRef>(new Set()); - const editResendGenerationRef = useRef(0); const activeEditResendTransactionsRef = useRef< Map >(new Map()); @@ -631,20 +837,19 @@ export default function GatewayApp() { titlePositionLockTimeoutsRef.current.set(conversationId, timeoutId); }, []); - const getHistoryPositionLockedConversationIds = useCallback(() => { - const conversationIds = new Set(); - const appendConversationIds = (ids: Iterable) => { - for (const id of ids) { - const conversationId = id.trim(); - if (conversationId) { - conversationIds.add(conversationId); - } - } - }; + const isHydrationBlocked = (conversationId: string) => + blockedHistoryHydrationConversationIdsRef.current.has(conversationId) || + getConversationAbortController(conversationId) !== null; - appendConversationIds(optimisticTitleConversationIdsRef.current); - appendConversationIds(titlePositionLockedConversationIdsRef.current); - appendConversationIds(blockedHistoryHydrationConversationIdsRef.current); + const getPendingDraftId = () => + pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + + const getHistoryPositionLockedConversationIds = useCallback(() => { + const conversationIds = new Set([ + ...optimisticTitleConversationIdsRef.current, + ...titlePositionLockedConversationIdsRef.current, + ...blockedHistoryHydrationConversationIdsRef.current, + ]); return conversationIds; }, []); @@ -798,70 +1003,6 @@ export default function GatewayApp() { return created; }, []); - const updateLiveConversationStreamMeta = useCallback( - ( - targetConversationId: string, - updater: (previous: LiveConversationStreamMeta) => LiveConversationStreamMeta, - ) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - const previous = liveConversationStreamMetaRef.current[conversationIdValue] ?? { - hasStream: false, - toolStatus: null, - toolStatusIsCompaction: false, - }; - const next = updater(previous); - if ( - previous.hasStream === next.hasStream && - previous.toolStatus === next.toolStatus && - previous.toolStatusIsCompaction === next.toolStatusIsCompaction - ) { - return; - } - const nextRecord = { - ...liveConversationStreamMetaRef.current, - [conversationIdValue]: next, - }; - liveConversationStreamMetaRef.current = nextRecord; - setLiveConversationStreamMetaState(nextRecord); - }, - [], - ); - - const markLiveConversationStreamActive = useCallback( - (targetConversationId: string) => { - updateLiveConversationStreamMeta(targetConversationId, (previous) => - previous.hasStream ? previous : { ...previous, hasStream: true }, - ); - }, - [updateLiveConversationStreamMeta], - ); - - const setLiveConversationStreamStatus = useCallback( - (targetConversationId: string, nextStatus: string | null | undefined, isCompaction = false) => { - const status = normalizeOptionalStatus(nextStatus); - updateLiveConversationStreamMeta(targetConversationId, (previous) => ({ - ...previous, - toolStatus: status, - toolStatusIsCompaction: Boolean(status) && isCompaction, - })); - }, - [updateLiveConversationStreamMeta], - ); - - const clearLiveConversationStreamMeta = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue || !liveConversationStreamMetaRef.current[conversationIdValue]) { - return; - } - const nextRecord = { ...liveConversationStreamMetaRef.current }; - delete nextRecord[conversationIdValue]; - liveConversationStreamMetaRef.current = nextRecord; - setLiveConversationStreamMetaState(nextRecord); - }, []); - const buildVisibleRuntimeEntry = useCallback( () => createConversationRuntimeEntry({ @@ -1042,17 +1183,14 @@ export default function GatewayApp() { liveConversationStreamStoresRef.current.get(conversationIdValue)?.reset(); liveConversationStreamStoresRef.current.delete(conversationIdValue); pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); - clearLiveConversationStreamMeta(conversationIdValue); }, - [clearLiveConversationStreamMeta], + [], ); const clearAllConversationLiveStreams = useCallback(() => { liveConversationStreamStoresRef.current.forEach((store) => store.reset()); liveConversationStreamStoresRef.current.clear(); pendingHistoryRefreshAfterLiveCompletionRef.current.clear(); - liveConversationStreamMetaRef.current = {}; - setLiveConversationStreamMetaState({}); }, []); const hasRetainedConversationLiveStream = useCallback((targetConversationId: string) => { @@ -1061,10 +1199,11 @@ export default function GatewayApp() { return false; } const store = liveConversationStreamStoresRef.current.get(conversationIdValue); - return ( - liveConversationStreamMetaRef.current[conversationIdValue]?.hasStream === true || - (store?.getSnapshot().entries.length ?? 0) > 0 - ); + if (!store) { + return false; + } + return store.getSnapshot().entries.length > 0 || + conversationEventStreamsRef.current.has(conversationIdValue); }, []); const commitConversationLiveStreamToRuntime = useCallback( @@ -1117,53 +1256,73 @@ export default function GatewayApp() { [clearConversationLiveStream, updateConversationRuntimeEntry], ); + const fetchVisibleHistoryGuarded = async ( + targetConversationId: string, + currentApi: typeof api, + options?: { allowDuringEditTransaction?: boolean }, + ): Promise<{ detail: HistoryDetail; entries: ChatEntry[]; conversationId: string } | null> => { + const conversationIdValue = targetConversationId.trim(); + if (!currentApi || !conversationIdValue) return null; + if ( + options?.allowDuringEditTransaction !== true && + activeEditResendTransactionsRef.current.has(conversationIdValue) + ) { + return null; + } + + const isStillVisible = () => + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue; + + if (!isStillVisible()) return null; + + const refreshSeq = + (visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) ?? 0) + 1; + visibleHistorySnapshotRefreshSeqRef.current.set(conversationIdValue, refreshSeq); + + let detail: HistoryDetail; + let entries: ChatEntry[]; + try { + detail = await currentApi.getHistory(conversationIdValue, { + maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, + }); + entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + } catch { + return null; + } + + if ( + visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) !== refreshSeq || + !isStillVisible() + ) { + return null; + } + + const detailConversationId = detail.conversation_id.trim(); + if (detailConversationId !== "" && detailConversationId !== conversationIdValue) return null; + + return { detail, entries, conversationId: conversationIdValue }; + }; + const refreshVisibleConversationHistorySnapshot = useCallback( async ( targetConversationId: string, currentApi = api, options?: { allowIdle?: boolean; allowDuringEditTransaction?: boolean }, ) => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) { - return; - } + const cid = targetConversationId.trim(); if ( - options?.allowDuringEditTransaction !== true && - activeEditResendTransactionsRef.current.has(conversationIdValue) + getConversationAbortController(cid) !== null || + localRunningConversationIdsRef.current.has(cid) ) { return; } - const isStillVisible = () => - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - conversationIdValue; + const result = await fetchVisibleHistoryGuarded(targetConversationId, currentApi, options); + if (!result) return; + const { detail, entries, conversationId: conversationIdValue } = result; if ( - !isStillVisible() || - getConversationAbortController(conversationIdValue) !== null || - localRunningConversationIdsRef.current.has(conversationIdValue) - ) { - return; - } - - const refreshSeq = - (visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) ?? 0) + 1; - visibleHistorySnapshotRefreshSeqRef.current.set(conversationIdValue, refreshSeq); - - let detail: HistoryDetail; - let entries: ChatEntry[]; - try { - detail = await currentApi.getHistory(conversationIdValue, { - maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, - }); - entries = await parseHistoryMessagesJsonAsync(detail.messages_json); - } catch { - return; - } - - if ( - visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) !== refreshSeq || - !isStillVisible() || getConversationAbortController(conversationIdValue) !== null || localRunningConversationIdsRef.current.has(conversationIdValue) || (options?.allowIdle !== true && @@ -1173,37 +1332,14 @@ export default function GatewayApp() { return; } - const detailConversationId = detail.conversation_id.trim(); - if (detailConversationId !== "" && detailConversationId !== conversationIdValue) { - return; - } - - // `has_more === false` tells us the server returned the full - // conversation — any local entries that don't reconcile with it (e.g. - // a peer truncated the conversation and resent) must yield to the - // server, otherwise the stale tail collides with the new live stream - // and produces duplicate assistant content in the transcript. const isFullSnapshot = detail.has_more === false; const mergeOptions = { isFullSnapshot }; const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); const liveSnapshotEntries = liveStore?.getSnapshot().entries ?? []; - // Only treat a non-matching live snapshot as stale when it's the - // post-`done` retention from a previously completed stream on THIS - // client. For a second webui that's catching up to a peer's in-flight - // stream via SSE replay, the live snapshot legitimately holds events - // the server has not persisted yet — wiping it here would destroy the - // in-flight content and leave the joiner staring at a blank transcript - // until the stream ends. - // - // Inlined instead of calling `hasRecentlyCompletedLiveStream` because - // that helper is declared later in this component (TDZ); the marker - // ref it reads has its own setTimeout-based expiry so we don't need - // the helper's lazy-cleanup side effect here. - const liveStreamCompletedAt = - completedLiveStreamConversationAtRef.current.get(conversationIdValue); + const marker = completedLiveStreamMarkersRef.current.get(conversationIdValue); const liveSnapshotIsCompletedRetention = - typeof liveStreamCompletedAt === "number" && - Date.now() - liveStreamCompletedAt <= LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS; + marker !== undefined && + Date.now() - marker.at <= LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS; const liveStreamConflictsWithSnapshot = isFullSnapshot && liveSnapshotEntries.length > 0 && @@ -1229,12 +1365,6 @@ export default function GatewayApp() { }; }); - // Once we've replaced the runtime from an authoritative full snapshot, - // discard any retained live snapshot that no longer fits the new - // server-side timeline. Without this the post-`done` retention from the - // previous turn lives on alongside the freshly fetched history, which - // is exactly what produces the duplicated assistant bubble after a - // peer's edit-and-resend. if (liveStreamConflictsWithSnapshot) { clearConversationLiveStream(conversationIdValue); } @@ -1261,16 +1391,6 @@ export default function GatewayApp() { liveConversationStreamStoresRef.current.delete(previousId); liveConversationStreamStoresRef.current.set(nextId, previousStore); } - - const previousMeta = liveConversationStreamMetaRef.current[previousId]; - if (!previousMeta) { - return; - } - const nextRecord = { ...liveConversationStreamMetaRef.current }; - delete nextRecord[previousId]; - nextRecord[nextId] = previousMeta; - liveConversationStreamMetaRef.current = nextRecord; - setLiveConversationStreamMetaState(nextRecord); }, [], ); @@ -1285,7 +1405,7 @@ export default function GatewayApp() { return historyLoadSequenceRef.current; }, []); - const migrateConversationRuntime = useCallback( + const migrateConversation = useCallback( (previousConversationId: string, nextConversationId: string) => { const previousId = previousConversationId.trim(); const nextId = nextConversationId.trim(); @@ -1334,17 +1454,6 @@ export default function GatewayApp() { if (blockedHistoryHydrationConversationIdsRef.current.delete(previousId)) { blockedHistoryHydrationConversationIdsRef.current.add(nextId); } - }, - [buildVisibleRuntimeEntry, migrateConversationLiveStream], - ); - - const migrateConversationSummary = useCallback( - (previousConversationId: string, nextConversationId: string) => { - const previousId = previousConversationId.trim(); - const nextId = nextConversationId.trim(); - if (!previousId || !nextId || previousId === nextId) { - return; - } const shouldPreserveOptimisticTitle = optimisticTitleConversationIdsRef.current.delete(previousId); @@ -1387,15 +1496,20 @@ export default function GatewayApp() { }); }); }, - [lockHistoryTitlePosition, unlockHistoryTitlePosition, updateHistoryItems], + [ + buildVisibleRuntimeEntry, + lockHistoryTitlePosition, + migrateConversationLiveStream, + unlockHistoryTitlePosition, + updateHistoryItems, + ], ); - const getActivePendingDraftMigration = useCallback(() => { + const getActivePendingDraftMigration = () => { const pending = pendingDraftConversationMigrationRef.current; if (!pending) { return null; } - const draftId = pending.draftConversationId.trim(); if ( !draftId || @@ -1406,54 +1520,23 @@ export default function GatewayApp() { ) { return null; } - - const isDraftInFlight = - localRunningConversationIdsRef.current.has(draftId) || - getConversationAbortController(draftId) !== null || - blockedHistoryHydrationConversationIdsRef.current.has(draftId); - if (!isDraftInFlight) { - return null; - } - - return pending; - }, [getConversationAbortController]); - - const isRecentPendingDraftConversation = useCallback( - ( - conversation: Pick | undefined, - pending: PendingDraftConversationMigration, - ) => { - const createdAt = normalizeGatewayTimestampMs(conversation?.created_at); - const updatedAt = normalizeGatewayTimestampMs(conversation?.updated_at); - const conversationTimestamp = createdAt || updatedAt; - return ( - conversationTimestamp <= 0 || - conversationTimestamp >= pending.startedAt - DRAFT_HISTORY_ADOPTION_WINDOW_MS - ); - }, - [], - ); - - const shouldSuppressPendingDraftBroadcast = useCallback( - (targetConversationId: string) => { - const targetId = targetConversationId.trim(); - const pending = getActivePendingDraftMigration(); - if (!pending || !targetId || isLocalDraftConversationId(targetId)) { - return false; - } - if (targetId === pending.draftConversationId.trim()) { - return false; - } - - const existing = pickConversationSummary(historyItemsRef.current, targetId); - if (existing && !isRecentPendingDraftConversation(existing, pending)) { - return false; - } - - return true; - }, - [getActivePendingDraftMigration, isRecentPendingDraftConversation], - ); + return localRunningConversationIdsRef.current.has(draftId) || isHydrationBlocked(draftId) + ? pending + : null; + }; + + const isRecentPendingDraftConversation = ( + conversation: Pick | undefined, + pending: PendingDraftConversationMigration, + ) => { + const conversationTimestamp = + normalizeGatewayTimestampMs(conversation?.created_at) || + normalizeGatewayTimestampMs(conversation?.updated_at); + return ( + conversationTimestamp <= 0 || + conversationTimestamp >= pending.startedAt - DRAFT_HISTORY_ADOPTION_WINDOW_MS + ); + }; const maybeAdoptActiveDraftConversation = useCallback( (conversation: ConversationSummary | undefined) => { @@ -1473,28 +1556,10 @@ export default function GatewayApp() { } pendingDraftConversationMigrationRef.current = null; - migrateConversationRuntime(draftId, nextId); - migrateConversationSummary(draftId, nextId); + migrateConversation(draftId, nextId); return true; }, - [ - getActivePendingDraftMigration, - isRecentPendingDraftConversation, - migrateConversationRuntime, - migrateConversationSummary, - ], - ); - - const adoptPendingDraftConversationFromHistoryList = useCallback( - (conversations: ConversationSummary[]) => { - for (const conversation of conversations) { - if (maybeAdoptActiveDraftConversation(conversation)) { - return conversation.id.trim(); - } - } - return ""; - }, - [maybeAdoptActiveDraftConversation], + [migrateConversation], ); const ensureTunnelToolTab = useCallback( @@ -1959,22 +2024,18 @@ export default function GatewayApp() { : ""; const previousRunKey = resolveRunningConversationRunKey(existingRuntime) || - (conversationEventStreamRunIdsRef.current.get(conversationIdValue) ?? ""); - if (isRunning && nextRunKey && previousRunKey && previousRunKey !== nextRunKey) { - const existingController = - conversationEventStreamControllersRef.current.get(conversationIdValue); - if (existingController) { - conversationEventStreamControllersRef.current.delete(conversationIdValue); - conversationEventStreamRunIdsRef.current.delete(conversationIdValue); - existingController.abort(); + (conversationEventStreamsRef.current.get(conversationIdValue)?.runId ?? ""); + if (isRunning && nextRunKey && previousRunKey !== nextRunKey) { + const existingStream = conversationEventStreamsRef.current.get(conversationIdValue); + if (existingStream) { + conversationEventStreamsRef.current.delete(conversationIdValue); + existingStream.controller.abort(); } - const completedTimeoutId = - completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); - if (completedTimeoutId !== undefined) { - window.clearTimeout(completedTimeoutId); - completedLiveStreamCleanupTimersRef.current.delete(conversationIdValue); + const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); + if (existingMarker) { + window.clearTimeout(existingMarker.timeoutId); + completedLiveStreamMarkersRef.current.delete(conversationIdValue); } - completedLiveStreamConversationAtRef.current.delete(conversationIdValue); clearConversationLiveStream(conversationIdValue); } recordProjectActivity(runtimeWorkdir, runtimeUpdatedAt); @@ -2034,20 +2095,18 @@ export default function GatewayApp() { if (!conversationIdValue) { return; } - const controller = conversationEventStreamControllersRef.current.get(conversationIdValue); - if (!controller) { + const stream = conversationEventStreamsRef.current.get(conversationIdValue); + if (!stream) { return; } - conversationEventStreamControllersRef.current.delete(conversationIdValue); - conversationEventStreamRunIdsRef.current.delete(conversationIdValue); - controller.abort(); + conversationEventStreamsRef.current.delete(conversationIdValue); + stream.controller.abort(); }, []); const stopAllConversationEventStreamSubscriptions = useCallback(() => { - const controllers = [...conversationEventStreamControllersRef.current.values()]; - conversationEventStreamControllersRef.current.clear(); - conversationEventStreamRunIdsRef.current.clear(); - for (const controller of controllers) { + const streams = [...conversationEventStreamsRef.current.values()]; + conversationEventStreamsRef.current.clear(); + for (const { controller } of streams) { controller.abort(); } }, []); @@ -2057,37 +2116,18 @@ export default function GatewayApp() { if (!conversationIdValue) { return; } - completedLiveStreamConversationAtRef.current.delete(conversationIdValue); - const timeoutId = completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - completedLiveStreamCleanupTimersRef.current.delete(conversationIdValue); + const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); + if (existingMarker) { + window.clearTimeout(existingMarker.timeoutId); } + completedLiveStreamMarkersRef.current.delete(conversationIdValue); }, []); const clearAllCompletedLiveStreamMarkers = useCallback(() => { - for (const timeoutId of completedLiveStreamCleanupTimersRef.current.values()) { + for (const { timeoutId } of completedLiveStreamMarkersRef.current.values()) { window.clearTimeout(timeoutId); } - completedLiveStreamCleanupTimersRef.current.clear(); - completedLiveStreamConversationAtRef.current.clear(); - }, []); - - const markCompletedLiveStream = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - const existingTimeoutId = completedLiveStreamCleanupTimersRef.current.get(conversationIdValue); - if (existingTimeoutId !== undefined) { - window.clearTimeout(existingTimeoutId); - } - completedLiveStreamConversationAtRef.current.set(conversationIdValue, Date.now()); - const timeoutId = window.setTimeout(() => { - completedLiveStreamConversationAtRef.current.delete(conversationIdValue); - completedLiveStreamCleanupTimersRef.current.delete(conversationIdValue); - }, LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS); - completedLiveStreamCleanupTimersRef.current.set(conversationIdValue, timeoutId); + completedLiveStreamMarkersRef.current.clear(); }, []); const hasRecentlyCompletedLiveStream = useCallback( @@ -2096,11 +2136,11 @@ export default function GatewayApp() { if (!conversationIdValue) { return false; } - const completedAt = completedLiveStreamConversationAtRef.current.get(conversationIdValue); - if (typeof completedAt !== "number") { + const marker = completedLiveStreamMarkersRef.current.get(conversationIdValue); + if (!marker) { return false; } - if (Date.now() - completedAt > LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS) { + if (Date.now() - marker.at > LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS) { clearCompletedLiveStreamMarker(conversationIdValue); return false; } @@ -2126,7 +2166,6 @@ export default function GatewayApp() { liveConversationStreamStoresRef.current .get(conversationIdValue) ?.setToolStatus(null, false, { flush: true }); - setLiveConversationStreamStatus(conversationIdValue, null); const expectedRemoteRunKey = resolveRunningConversationRunKey({ runId: options?.remoteRunId, }); @@ -2159,82 +2198,77 @@ export default function GatewayApp() { [ setConversationAbortController, setConversationRunningState, - setLiveConversationStreamStatus, setRemoteConversationRunningState, updateConversationRuntimeEntry, ], ); - const commitTerminalConversationLiveStream = useCallback( - (targetConversationId: string) => { + const finalizeTerminalLiveStream = useCallback( + (targetConversationId: string, currentApi = api) => { const conversationIdValue = targetConversationId.trim(); if (!conversationIdValue) { return; } + const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); + if (existingMarker) { + window.clearTimeout(existingMarker.timeoutId); + } + const timeoutId = window.setTimeout(() => { + completedLiveStreamMarkersRef.current.delete(conversationIdValue); + }, LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS); + completedLiveStreamMarkersRef.current.set(conversationIdValue, { at: Date.now(), timeoutId }); + const isVisibleConversation = resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === conversationIdValue; const shouldKeepBottom = isVisibleConversation && isTranscriptAtBottom(); if (!isVisibleConversation) { - // Background broadcasts do not include the persisted user turn. Let - // history.get be the source of truth when the user opens this chat. flushSync(() => { clearConversationLiveStream(conversationIdValue); clearConversationStreamingState(conversationIdValue); }); - return; - } - - preserveTranscriptScrollPosition( - () => { - flushSync(() => { - commitConversationLiveStreamToRuntime(conversationIdValue, { - clearLiveStream: false, + } else { + preserveTranscriptScrollPosition( + () => { + flushSync(() => { + commitConversationLiveStreamToRuntime(conversationIdValue, { + clearLiveStream: false, + }); + clearConversationStreamingState(conversationIdValue); }); - clearConversationStreamingState(conversationIdValue); - }); - }, - { stickToBottom: shouldKeepBottom }, - ); + }, + { stickToBottom: shouldKeepBottom }, + ); - if (shouldKeepBottom) { - stickTranscriptToBottom(); - } else { - refreshTranscriptScrollState(); + if (shouldKeepBottom) { + stickTranscriptToBottom(); + } else { + refreshTranscriptScrollState(); + } + } + + if (currentApi && pendingHistoryRefreshAfterLiveCompletionRef.current.has(conversationIdValue)) { + pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); + void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi, { + allowIdle: true, + }); } }, [ + api, clearConversationLiveStream, clearConversationStreamingState, commitConversationLiveStreamToRuntime, isTranscriptAtBottom, preserveTranscriptScrollPosition, refreshTranscriptScrollState, + refreshVisibleConversationHistorySnapshot, stickTranscriptToBottom, ], ); - const refreshHistoryAfterCompletedLiveStream = useCallback( - (targetConversationId: string, currentApi = api) => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) { - return false; - } - if (!pendingHistoryRefreshAfterLiveCompletionRef.current.has(conversationIdValue)) { - return false; - } - - pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); - void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi, { - allowIdle: true, - }); - return true; - }, - [api, refreshVisibleConversationHistorySnapshot], - ); - const applyChatRuntimeSnapshotEvent = useCallback( ( event: ChatRuntimeSnapshotEvent, @@ -2253,11 +2287,6 @@ export default function GatewayApp() { } liveStore.applySnapshot(event, { flush: true }); - const normalizedStatus = normalizeOptionalStatus(event.tool_status); - const isCompaction = - normalizedStatus !== null && event.tool_status_is_compaction === true; - setLiveConversationStreamStatus(conversationIdValue, normalizedStatus, isCompaction); - markLiveConversationStreamActive(conversationIdValue); const state = event.state ?? "running"; const terminalState = @@ -2269,21 +2298,15 @@ export default function GatewayApp() { }); if (terminalState) { - markCompletedLiveStream(conversationIdValue); - commitTerminalConversationLiveStream(conversationIdValue); - refreshHistoryAfterCompletedLiveStream(conversationIdValue, currentApi); + finalizeTerminalLiveStream(conversationIdValue, currentApi); } return terminalState; }, [ api, - commitTerminalConversationLiveStream, + finalizeTerminalLiveStream, getConversationLiveStreamStore, - markCompletedLiveStream, - markLiveConversationStreamActive, - refreshHistoryAfterCompletedLiveStream, - setLiveConversationStreamStatus, setRemoteConversationRunningState, ], ); @@ -2339,27 +2362,22 @@ export default function GatewayApp() { if (!nextRunKey) { return; } - const existingController = - conversationEventStreamControllersRef.current.get(conversationIdValue); - if (existingController) { - const existingRunKey = - conversationEventStreamRunIdsRef.current.get(conversationIdValue) ?? ""; - if (!nextRunKey || existingRunKey === nextRunKey) { + const existingStream = + conversationEventStreamsRef.current.get(conversationIdValue); + if (existingStream) { + if (!nextRunKey || existingStream.runId === nextRunKey) { return; } - conversationEventStreamControllersRef.current.delete(conversationIdValue); - conversationEventStreamRunIdsRef.current.delete(conversationIdValue); - existingController.abort(); + conversationEventStreamsRef.current.delete(conversationIdValue); + existingStream.controller.abort(); } const controller = new AbortController(); - conversationEventStreamControllersRef.current.set(conversationIdValue, controller); - conversationEventStreamRunIdsRef.current.set(conversationIdValue, nextRunKey); + conversationEventStreamsRef.current.set(conversationIdValue, { controller, runId: nextRunKey }); clearCompletedLiveStreamMarker(conversationIdValue); const liveStore = getConversationLiveStreamStore(conversationIdValue); if (!liveStore) { - conversationEventStreamControllersRef.current.delete(conversationIdValue); - conversationEventStreamRunIdsRef.current.delete(conversationIdValue); + conversationEventStreamsRef.current.delete(conversationIdValue); return; } void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi); @@ -2405,7 +2423,6 @@ export default function GatewayApp() { const normalizedStatus = normalizeOptionalStatus(event.status); const isCompaction = normalizedStatus !== null && event.isCompaction === true; liveStore.setToolStatus(normalizedStatus, isCompaction); - setLiveConversationStreamStatus(conversationIdValue, normalizedStatus, isCompaction); continue; } @@ -2421,13 +2438,10 @@ export default function GatewayApp() { if (terminalEvent) { terminalEventSeen = true; - markCompletedLiveStream(conversationIdValue); - commitTerminalConversationLiveStream(conversationIdValue); - refreshHistoryAfterCompletedLiveStream(conversationIdValue, currentApi); + finalizeTerminalLiveStream(conversationIdValue, currentApi); return; } - markLiveConversationStreamActive(conversationIdValue); } } catch (error) { if (!isAbortError(error)) { @@ -2451,16 +2465,13 @@ export default function GatewayApp() { { flush: true }, ); terminalEventSeen = true; - markCompletedLiveStream(conversationIdValue); - commitTerminalConversationLiveStream(conversationIdValue); - refreshHistoryAfterCompletedLiveStream(conversationIdValue, currentApi); + finalizeTerminalLiveStream(conversationIdValue, currentApi); } } finally { if ( - conversationEventStreamControllersRef.current.get(conversationIdValue) === controller + conversationEventStreamsRef.current.get(conversationIdValue)?.controller === controller ) { - conversationEventStreamControllersRef.current.delete(conversationIdValue); - conversationEventStreamRunIdsRef.current.delete(conversationIdValue); + conversationEventStreamsRef.current.delete(conversationIdValue); } if (!terminalEventSeen && controller.signal.aborted) { liveStore.flush(); @@ -2471,7 +2482,7 @@ export default function GatewayApp() { window.setTimeout(() => { if ( remoteRunningConversationIdsRef.current.has(conversationIdValue) && - !conversationEventStreamControllersRef.current.has(conversationIdValue) + !conversationEventStreamsRef.current.has(conversationIdValue) ) { subscribeVisibleConversationEventStream(conversationIdValue, currentApi); } @@ -2479,7 +2490,6 @@ export default function GatewayApp() { } else if (!terminalEventSeen && !controller.signal.aborted) { liveStore.flush(); clearConversationStreamingState(conversationIdValue); - setLiveConversationStreamStatus(conversationIdValue, null); } } })(); @@ -2489,17 +2499,13 @@ export default function GatewayApp() { applyLiveConversationTitle, clearCompletedLiveStreamMarker, clearConversationStreamingState, - commitTerminalConversationLiveStream, + finalizeTerminalLiveStream, getConversationAbortController, getConversationLiveStreamStore, applyChatRuntimeSnapshotEvent, handleTunnelManagerChatEvent, - markCompletedLiveStream, - markLiveConversationStreamActive, recoverUnavailableConversationStream, - refreshHistoryAfterCompletedLiveStream, refreshVisibleConversationHistorySnapshot, - setLiveConversationStreamStatus, ], ); @@ -2521,11 +2527,20 @@ export default function GatewayApp() { return; } - if ( - (event.kind === "running" || event.kind === "idle") && - shouldSuppressPendingDraftBroadcast(targetConversationId) - ) { - return; + if (event.kind === "running" || event.kind === "idle") { + const pending = getActivePendingDraftMigration(); + if (pending) { + const targetId = targetConversationId; + if ( + !isLocalDraftConversationId(targetId) && + targetId !== pending.draftConversationId.trim() + ) { + const existing = pickConversationSummary(historyItemsRef.current, targetId); + if (!existing || isRecentPendingDraftConversation(existing, pending)) { + return; + } + } + } } if (event.kind === "running" || event.kind === "idle") { @@ -2539,15 +2554,13 @@ export default function GatewayApp() { }) : ""; if (event.kind === "idle" && !eventRunKey) { + const activeStream = conversationEventStreamsRef.current.get(targetConversationId); const activeRunKey = - conversationEventStreamRunIdsRef.current.get(targetConversationId) ?? + activeStream?.runId ?? resolveRunningConversationRunKey( remoteRunningConversationRuntimeRef.current.get(targetConversationId), ); - if ( - activeRunKey && - conversationEventStreamControllersRef.current.has(targetConversationId) - ) { + if (activeRunKey && activeStream) { void refreshHistoryWorkdirs(api); return; } @@ -2574,16 +2587,13 @@ export default function GatewayApp() { selectedHistoryIdRef.current, conversationIdRef.current, ); - const isHistoryHydrationBlocked = - blockedHistoryHydrationConversationIdsRef.current.has(targetConversationId) || - getConversationAbortController(targetConversationId) !== null; const hasLocalDraft = pendingUploadedFilesRef.current.length > 0 || (composerRef.current?.hasContent() ?? false); const hasRetainedLiveTranscript = hasRetainedConversationLiveStream(targetConversationId); if ( visibleConversationId === targetConversationId && - !isHistoryHydrationBlocked && + !isHydrationBlocked(targetConversationId) && !chatBusyRef.current && !hasLocalDraft && !hasRetainedLiveTranscript @@ -2664,9 +2674,7 @@ export default function GatewayApp() { ); const isRemoteConversationRunning = remoteRunningConversationIdsRef.current.has(targetConversationId); - const isHistoryHydrationBlocked = - blockedHistoryHydrationConversationIdsRef.current.has(targetConversationId) || - getConversationAbortController(targetConversationId) !== null; + const hydrationBlocked = isHydrationBlocked(targetConversationId); const hasLocalDraft = pendingUploadedFilesRef.current.length > 0 || (composerRef.current?.hasContent() ?? false); if ( @@ -2676,9 +2684,6 @@ export default function GatewayApp() { hasRecentlyCompletedLiveStream(targetConversationId) ) { pendingHistoryRefreshAfterLiveCompletionRef.current.delete(targetConversationId); - // The visible transcript already contains the committed live stream; - // refresh the persisted snapshot so remote observers also receive the - // user turn that is not part of chat stream events. void refreshVisibleConversationHistorySnapshot(targetConversationId, api, { allowIdle: true, }); @@ -2688,7 +2693,7 @@ export default function GatewayApp() { event.kind === "upsert" && visibleConversationId === targetConversationId && isRemoteConversationRunning && - !isHistoryHydrationBlocked && + !hydrationBlocked && !localRunningConversationIdsRef.current.has(targetConversationId) ) { pendingHistoryRefreshAfterLiveCompletionRef.current.add(targetConversationId); @@ -2699,7 +2704,7 @@ export default function GatewayApp() { event.kind === "upsert" && visibleConversationId === targetConversationId && !isRemoteConversationRunning && - !isHistoryHydrationBlocked && + !hydrationBlocked && !chatBusyRef.current && !hasLocalDraft && !hasRetainedConversationLiveStream(targetConversationId) @@ -2732,7 +2737,6 @@ export default function GatewayApp() { refreshVisibleConversationHistorySnapshot, refreshHistoryWorkdirs, setRemoteConversationRunningState, - shouldSuppressPendingDraftBroadcast, stopAllConversationEventStreamSubscriptions, stopConversationEventStreamSubscription, unlockHistoryTitlePosition, @@ -2818,8 +2822,7 @@ export default function GatewayApp() { setSelectedHistoryEntries(entries); if (options?.syncChat) { const shouldPreserveLiveRuntime = - blockedHistoryHydrationConversationIdsRef.current.has(detail.conversation_id) || - getConversationAbortController(detail.conversation_id) !== null || + isHydrationBlocked(detail.conversation_id) || remoteRunningConversationIdsRef.current.has(detail.conversation_id); if (shouldPreserveLiveRuntime) { return; @@ -2882,10 +2885,7 @@ export default function GatewayApp() { setSelectedHistory(fallbackDetail); setSelectedHistoryEntries(fallbackEntries); if (options?.syncChat) { - const shouldPreserveLiveRuntime = - blockedHistoryHydrationConversationIdsRef.current.has(conversationIdValue) || - getConversationAbortController(conversationIdValue) !== null; - if (shouldPreserveLiveRuntime) { + if (isHydrationBlocked(conversationIdValue)) { return; } if (options.clearLiveStream) { @@ -2940,19 +2940,12 @@ export default function GatewayApp() { }); } const runningConversationIds = runningConversations.map((item) => item.conversation_id); - const retainedConversationIds = new Set(); - for (const id of blockedHistoryHydrationConversationIdsRef.current) { - retainedConversationIds.add(id); - } - for (const id of localRunningConversationIdsRef.current) { - retainedConversationIds.add(id); - } - for (const id of remoteRunningConversationIdsRef.current) { - retainedConversationIds.add(id); - } - for (const id of runningConversationIds) { - retainedConversationIds.add(id); - } + const retainedConversationIds = new Set([ + ...blockedHistoryHydrationConversationIdsRef.current, + ...localRunningConversationIdsRef.current, + ...remoteRunningConversationIdsRef.current, + ...runningConversationIds, + ]); for (const [id, store] of liveConversationStreamStoresRef.current) { if (store.getSnapshot().entries.length > 0) { retainedConversationIds.add(id); @@ -2978,20 +2971,15 @@ export default function GatewayApp() { : refreshedNextPage; commitHistoryListState(conversations, response.total_count, nextPage); - const adoptedPendingDraftConversationId = - options?.adoptPendingDraftConversation === true - ? adoptPendingDraftConversationFromHistoryList(conversations) - : ""; - if ( - options?.adoptPendingDraftConversation === true && - adoptedPendingDraftConversationId !== "" - ) { - // `chat stream not available` recovery clears the draft's running state - // before reloading history. If the draft is then adopted into a real - // conversation, only the temporary hydration block survives migration; - // release it here so this same reload/select cycle can hydrate the - // recovered history snapshot immediately. - blockedHistoryHydrationConversationIdsRef.current.delete(adoptedPendingDraftConversationId); + let adoptedPendingDraftConversationId = ""; + if (options?.adoptPendingDraftConversation === true) { + for (const conversation of conversations) { + if (maybeAdoptActiveDraftConversation(conversation)) { + adoptedPendingDraftConversationId = conversation.id.trim(); + blockedHistoryHydrationConversationIdsRef.current.delete(adoptedPendingDraftConversationId); + break; + } + } } if (options?.skipSelectionSync) { @@ -3228,10 +3216,7 @@ export default function GatewayApp() { clearConversationStreamingState(effectiveConversationId); } - if ( - resolveChatStreamUnavailableRecoveryAction(effectiveConversationId) === - "refresh-history-snapshot" - ) { + if (!isLocalDraftConversationId(effectiveConversationId)) { recoverUnavailableConversationStream(effectiveConversationId, currentApi); return; } @@ -3253,48 +3238,9 @@ export default function GatewayApp() { const recoverCompletedVisibleConversationFromHistorySnapshot = useCallback( async (targetConversationId: string, currentApi = api) => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) { - return false; - } - if (activeEditResendTransactionsRef.current.has(conversationIdValue)) { - return false; - } - - const isStillVisible = () => - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - conversationIdValue; - - if (!isStillVisible()) { - return false; - } - - const refreshSeq = - (visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) ?? 0) + 1; - visibleHistorySnapshotRefreshSeqRef.current.set(conversationIdValue, refreshSeq); - - let detail: HistoryDetail; - let entries: ChatEntry[]; - try { - detail = await currentApi.getHistory(conversationIdValue, { - maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, - }); - entries = await parseHistoryMessagesJsonAsync(detail.messages_json); - } catch { - return false; - } - - if ( - visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) !== refreshSeq || - !isStillVisible() - ) { - return false; - } - - const detailConversationId = detail.conversation_id.trim(); - if (detailConversationId !== "" && detailConversationId !== conversationIdValue) { - return false; - } + const result = await fetchVisibleHistoryGuarded(targetConversationId, currentApi); + if (!result) return false; + const { detail, entries, conversationId: conversationIdValue } = result; const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); liveStore?.flush(); @@ -3450,9 +3396,8 @@ export default function GatewayApp() { } const hasLocalRunningState = - getConversationAbortController(visibleConversationId) !== null || - localRunningConversationIdsRef.current.has(visibleConversationId) || - blockedHistoryHydrationConversationIdsRef.current.has(visibleConversationId); + isHydrationBlocked(visibleConversationId) || + localRunningConversationIdsRef.current.has(visibleConversationId); const hasRetainedLiveStream = hasRetainedConversationLiveStream(visibleConversationId); const isRemoteRunning = remoteRunningConversationIdsRef.current.has(visibleConversationId); @@ -3609,9 +3554,8 @@ export default function GatewayApp() { setSelectedHistoryId(activeConversationId); } const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); - const pendingDraftConversationId = - pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; - if (pendingDraftConversationId && pendingDraftConversationId !== activeConversationId) { + const pendingDraft = getPendingDraftId(); + if (pendingDraft && pendingDraft !== activeConversationId) { const message = "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, @@ -3851,8 +3795,7 @@ export default function GatewayApp() { ) { pendingDraftConversationMigrationRef.current = null; } - migrateConversationRuntime(previousConversationId, nextConversationId); - migrateConversationSummary(previousConversationId, nextConversationId); + migrateConversation(previousConversationId, nextConversationId); activeConversationId = nextConversationId; lockedConversationIds.add(activeConversationId); if (runActive) { @@ -3922,10 +3865,6 @@ export default function GatewayApp() { } if (event.type === "user_message") { markRunPreparing(); - getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { - flush: true, - }); - markLiveConversationStreamActive(activeConversationId); continue; } if (isTerminalChatControlEvent(event)) { @@ -3940,9 +3879,7 @@ export default function GatewayApp() { flush: true, }); } - markCompletedLiveStream(activeConversationId); - commitTerminalConversationLiveStream(activeConversationId); - refreshHistoryAfterCompletedLiveStream(activeConversationId, api); + finalizeTerminalLiveStream(activeConversationId, api); } else if (isRuntimeStartedChatControlEvent(event)) { markRuntimeStarted(); updateConversationRuntimeEntry(activeConversationId, (current) => ({ @@ -3968,7 +3905,6 @@ export default function GatewayApp() { normalizedStatus, isCompaction, ); - setLiveConversationStreamStatus(activeConversationId, normalizedStatus, isCompaction); updateConversationRuntimeEntry(activeConversationId, (current) => ({ ...current, toolStatus: normalizedStatus, @@ -3987,11 +3923,7 @@ export default function GatewayApp() { activeConversationId, preserveRemoteRunCleanupOptions(), ); - markCompletedLiveStream(activeConversationId); - commitTerminalConversationLiveStream(activeConversationId); - refreshHistoryAfterCompletedLiveStream(activeConversationId, api); - } else { - markLiveConversationStreamActive(activeConversationId); + finalizeTerminalLiveStream(activeConversationId, api); } } const liveTitle = readChatEventTitle(event); @@ -4082,7 +4014,7 @@ export default function GatewayApp() { } const controller = getConversationAbortController(activeConversationId); const cancelRunId = - conversationEventStreamRunIdsRef.current.get(activeConversationId) ?? + conversationEventStreamsRef.current.get(activeConversationId)?.runId ?? remoteRunningConversationRuntimeRef.current.get(activeConversationId)?.runId ?? ""; const isVisibleConversation = @@ -4411,13 +4343,11 @@ export default function GatewayApp() { protectedConversationRef.current = PROTECTED_DRAFT_CONVERSATION; chatStartLocksRef.current.clear(); submitInFlightRef.current = false; - const pendingDraftConversationId = - pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; + const pendingDraft = getPendingDraftId(); const pendingDraftStillActive = - pendingDraftConversationId !== "" && - (localRunningConversationIdsRef.current.has(pendingDraftConversationId) || - getConversationAbortController(pendingDraftConversationId) !== null || - blockedHistoryHydrationConversationIdsRef.current.has(pendingDraftConversationId)); + pendingDraft !== "" && + (localRunningConversationIdsRef.current.has(pendingDraft) || + isHydrationBlocked(pendingDraft)); if (!pendingDraftStillActive) { pendingDraftConversationMigrationRef.current = null; } @@ -5137,8 +5067,8 @@ export default function GatewayApp() { ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; const clientRequestId = `webui-chat.edit_resend-${activeConversationId}-${randomPart}`; - const generation = editResendGenerationRef.current + 1; - editResendGenerationRef.current = generation; + const prevGen = activeEditResendTransactionsRef.current.get(activeConversationId)?.generation ?? 0; + const generation = prevGen + 1; activeEditResendTransactionsRef.current.set(activeConversationId, { generation, clientRequestId, @@ -5779,10 +5709,8 @@ export default function GatewayApp() { }, [api, displayedConversationId, hasDetachedSelection]); const liveTranscriptStore = displayedConversationId !== "" ? getConversationLiveStreamStore(displayedConversationId) : null; - const liveTranscriptMeta = - displayedConversationId !== "" - ? liveConversationStreamMeta[displayedConversationId] - : undefined; + const liveTranscriptSnapshot = liveTranscriptStore?.getSnapshot(); + const liveTranscriptHasStream = (liveTranscriptSnapshot?.entries.length ?? 0) > 0; const isLocallyStreamingDisplayedConversation = chatBusy && conversationId.trim() !== "" && displayedConversationId === conversationId.trim(); const isObservingRemoteLiveConversation = Boolean( @@ -5796,7 +5724,7 @@ export default function GatewayApp() { useEffect(() => { const nextDisplayedConversationId = displayedConversationId.trim(); for (const conversationIdValue of [ - ...conversationEventStreamControllersRef.current.keys(), + ...conversationEventStreamsRef.current.keys(), ]) { if (conversationIdValue !== nextDisplayedConversationId) { stopConversationEventStreamSubscription(conversationIdValue); @@ -5831,12 +5759,12 @@ export default function GatewayApp() { document.title = browserTitle; }, [browserTitle]); const transcriptToolStatus = isObservingRemoteLiveConversation - ? (liveTranscriptMeta?.toolStatus ?? null) + ? (liveTranscriptSnapshot?.toolStatus ?? null) : hasDetachedSelection ? null : chatToolStatus; const transcriptToolStatusIsCompaction = isObservingRemoteLiveConversation - ? (liveTranscriptMeta?.toolStatusIsCompaction ?? false) + ? (liveTranscriptSnapshot?.toolStatusIsCompaction ?? false) : hasDetachedSelection ? false : chatToolStatusIsCompaction; @@ -5912,7 +5840,7 @@ export default function GatewayApp() { !targetConversationId || historyDetailLoading || displayedConversationId.trim() !== targetConversationId || - (visibleTranscriptEntries.length === 0 && liveTranscriptMeta?.hasStream !== true) + (visibleTranscriptEntries.length === 0 && !liveTranscriptHasStream) ) { return; } @@ -5923,7 +5851,7 @@ export default function GatewayApp() { }, [ displayedConversationId, historyDetailLoading, - liveTranscriptMeta?.hasStream, + liveTranscriptHasStream, refreshTranscriptScrollState, stickTranscriptToBottom, visibleTranscriptEntries.length, @@ -5984,13 +5912,13 @@ export default function GatewayApp() { ]); useLayoutEffect(() => { - if (transcriptBusy || liveTranscriptMeta?.hasStream === true) { + if (transcriptBusy || liveTranscriptHasStream) { syncTranscriptAutoScroll(); } refreshTranscriptScrollState(); }, [ chatError, - liveTranscriptMeta?.hasStream, + liveTranscriptHasStream, refreshTranscriptScrollState, syncTranscriptAutoScroll, transcriptBusy, @@ -6343,7 +6271,7 @@ export default function GatewayApp() { conversationId={displayedConversationId} entries={visibleTranscriptEntries} liveStore={liveTranscriptStore} - hasLiveStream={liveTranscriptMeta?.hasStream === true} + hasLiveStream={liveTranscriptHasStream} error={transcriptError} toolStatus={transcriptToolStatus} toolStatusIsCompaction={transcriptToolStatusIsCompaction} @@ -6477,12 +6405,10 @@ export default function GatewayApp() { return; } const uploadConversationId = getDisplayedConversationId(); - const pendingDraftConversationId = - pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? - ""; + const pendingDraft = getPendingDraftId(); if ( - pendingDraftConversationId && - pendingDraftConversationId !== uploadConversationId + pendingDraft && + pendingDraft !== uploadConversationId ) { const message = "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; diff --git a/crates/agent-gateway/web/src/app/historyUtils.ts b/crates/agent-gateway/web/src/app/historyUtils.ts index a6c08a42..bf149f48 100644 --- a/crates/agent-gateway/web/src/app/historyUtils.ts +++ b/crates/agent-gateway/web/src/app/historyUtils.ts @@ -9,7 +9,9 @@ import { type WorkspaceProject, } from "@/lib/settings"; import { formatConversationTitle } from "@/lib/chatUi"; -import { isLocalDraftConversationId } from "@/lib/localDraftConversation"; +function isLocalDraftConversationId(id: string) { + return id.trim().startsWith("__local_draft__:"); +} import { fallbackWorkspaceProjectName, } from "@/lib/workspaceProjects"; diff --git a/crates/agent-gateway/web/src/app/types.ts b/crates/agent-gateway/web/src/app/types.ts index baa2cbca..a754e482 100644 --- a/crates/agent-gateway/web/src/app/types.ts +++ b/crates/agent-gateway/web/src/app/types.ts @@ -13,12 +13,6 @@ export type ReloadHistoryOptions = { export type OverlayState = "closed" | "entering" | "open" | "leaving"; -export type LiveConversationStreamMeta = { - hasStream: boolean; - toolStatus: string | null; - toolStatusIsCompaction: boolean; -}; - export type ConversationRuntimeEntry = { messages: ChatEntry[]; error: string | null; diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 4ab188c4..cb8937f7 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -51,11 +51,36 @@ import { type GatewayTranscriptItem, type GatewayTranscriptRound, } from "../lib/chatUi"; -import { omitEquivalentTailEntries } from "../lib/liveConversationCommit"; +function entryMatchesForDedup(a: ChatEntry, b: ChatEntry): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === "user" && b.kind === "user") { + return a.text === b.text; + } + const { id: _a, ...restA } = a; + const { id: _b, ...restB } = b; + return JSON.stringify(restA) === JSON.stringify(restB); +} + +function omitEquivalentTailEntries(existing: ChatEntry[], live: ChatEntry[]) { + if (live.length === 0) return existing; + const maxOverlap = Math.min(existing.length, live.length); + for (let overlap = maxOverlap; overlap > 0; overlap--) { + const start = existing.length - overlap; + let matches = true; + for (let i = 0; i < overlap; i++) { + if (!entryMatchesForDedup(existing[start + i], live[i])) { + matches = false; + break; + } + } + if (matches) return existing.slice(0, start); + } + return existing; +} import type { LiveConversationStreamSnapshot, LiveConversationStreamStore, -} from "../lib/liveConversationStreamStore"; +} from "../lib/gatewayTypes"; import type { SectionId } from "../pages/settings/types"; type GatewayTranscriptProps = { @@ -645,7 +670,7 @@ function GatewayUserMessageBubbleBody(props: { ); return ( -
+
{ - if (entry.kind === "assistant" || entry.kind === "thinking" || entry.kind === "error") { - return entry.text; - } - return ""; - }) - .join("\n") - .trim(); -} - -export function shouldHydrateRestoredConversationSnapshot(params: { - currentEntries: ChatEntry[]; - historyEntries: ChatEntry[]; - liveEntries?: ChatEntry[]; -}) { - const historyEntries = params.historyEntries; - if (historyEntries.length === 0) { - return false; - } - - const currentEntries = params.currentEntries; - const liveEntries = params.liveEntries ?? []; - if (currentEntries.length === 0 && liveEntries.length === 0) { - return true; - } - - const currentAssistantText = collectAssistantLikeText([...currentEntries, ...liveEntries]); - const historyAssistantText = collectAssistantLikeText(historyEntries); - if (historyAssistantText.length === 0) { - return liveEntries.length === 0 && historyEntries.length > currentEntries.length; - } - if (currentAssistantText.length === 0) { - return true; - } - return historyAssistantText.length > currentAssistantText.length; -} diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 3add7039..d74b1e36 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -33,10 +33,16 @@ import type { SftpTransferResponse, } from "@/lib/sftp/types"; import { BrowserGatewayTerminalStreamClient } from "@/lib/terminal/gatewayTerminalStreamClient"; -import { - isRecoverableChatStreamTransportMessage, - isRecoverableChatStreamTransportStatus, -} from "@/lib/chatStreamRecovery"; +const RECOVERABLE_TRANSPORT_STATUS_CODES = new Set([408, 425, 429, 502, 503, 504, 520, 521, 522, 523, 524]); +const RECOVERABLE_TRANSPORT_MESSAGE_RE = + /\b(?:502\s+bad\s+gateway|503\s+service\s+unavailable|504\s+gateway\s+timeout|bad\s+gateway|gateway\s+timeout|service\s+unavailable|temporarily\s+unavailable|failed\s+to\s+fetch|networkerror|network\s+error|load\s+failed|connection\s+(?:reset|closed|lost)|socket\s+hang\s+up|err_(?:network|internet_disconnected|connection_(?:reset|closed|refused)))\b/i; +function isRecoverableChatStreamTransportStatus(status: number) { + return Number.isInteger(status) && RECOVERABLE_TRANSPORT_STATUS_CODES.has(status); +} +function isRecoverableChatStreamTransportMessage(value: unknown) { + const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); + return RECOVERABLE_TRANSPORT_MESSAGE_RE.test(message.trim()); +} import type { AgentStatus, diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index 4f07a172..47466d2c 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -1,3 +1,4 @@ +import type { ChatEntry } from "@/lib/chatUi"; import type { CodexRequestFormat, ChatRuntimeControls, @@ -324,3 +325,24 @@ export type GatewayHistoryEvent = run_epoch?: number; updated_at?: number; }; + +export type LiveConversationStreamSnapshot = { + revision: number; + entries: ChatEntry[]; + toolStatus: string | null; + toolStatusIsCompaction: boolean; +}; + +export type LiveConversationStreamStore = { + getSnapshot: () => LiveConversationStreamSnapshot; + subscribe: (listener: () => void) => () => void; + applySnapshot: (event: ChatRuntimeSnapshotEvent, options?: { flush?: boolean }) => void; + appendEvent: (event: ChatEvent, options?: { flush?: boolean }) => void; + setToolStatus: ( + toolStatus: string | null | undefined, + isCompaction?: boolean, + options?: { flush?: boolean }, + ) => void; + reset: () => void; + flush: () => void; +}; diff --git a/crates/agent-gateway/web/src/lib/liveConversationCommit.ts b/crates/agent-gateway/web/src/lib/liveConversationCommit.ts deleted file mode 100644 index e7357cee..00000000 --- a/crates/agent-gateway/web/src/lib/liveConversationCommit.ts +++ /dev/null @@ -1,326 +0,0 @@ -import type { ChatEntry } from "./chatUi"; -import { normalizeUploadedFileForDisplayComparison } from "./chat/uploadedFiles"; - -function cloneValue(value: T): T { - if (value == null) { - return value; - } - if (typeof structuredClone === "function") { - return structuredClone(value); - } - return JSON.parse(JSON.stringify(value)) as T; -} - -function hashText(value: string) { - let hash = 2166136261; - for (let index = 0; index < value.length; index += 1) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(36); -} - -function buildComparableEntry(entry: ChatEntry) { - const clone = cloneValue(entry); - const { id: _id, ...rest } = clone; - return rest; -} - -function areEntriesEquivalent(left: ChatEntry, right: ChatEntry) { - return JSON.stringify(buildComparableEntry(left)) === JSON.stringify(buildComparableEntry(right)); -} - -function buildVisualComparableEntry(entry: ChatEntry) { - const comparable = buildComparableEntry(entry) as Record; - if (entry.kind === "assistant") { - const { meta: _meta, ...rest } = comparable; - return rest; - } - if (entry.kind === "user") { - // `messageRef` is the server-assigned edit handle; locally created user - // entries do not have it yet. The field is invisible to the rendered - // bubble, so it must not cause visual equivalence to fail (otherwise the - // post-stream history refresh would replace every user entry with a fresh - // id, all article keys would change, and the `chat-bubble-enter` - // animation would re-run for every user bubble at once). - const { messageRef: _messageRef, attachments, ...rest } = comparable; - return { - ...rest, - attachments: Array.isArray(attachments) - ? attachments.map(normalizeUploadedFileForDisplayComparison) - : [], - }; - } - return comparable; -} - -function areEntriesVisuallyEquivalent(left: ChatEntry, right: ChatEntry) { - return ( - JSON.stringify(buildVisualComparableEntry(left)) === - JSON.stringify(buildVisualComparableEntry(right)) - ); -} - -function assistantRoundKey(entry: ChatEntry) { - return entry.kind === "assistant" ? String(entry.round ?? "__default__") : ""; -} - -function mergeDefinedRecordValues(left: T, right: T) { - let changed = false; - const next: Record = { ...(left as Record) }; - for (const [key, value] of Object.entries(right as Record)) { - if (value === undefined) { - continue; - } - if (JSON.stringify(next[key]) !== JSON.stringify(value)) { - next[key] = value; - changed = true; - } - } - return changed ? next as T : left; -} - -function mergeAssistantMetadataIntoExisting(existing: ChatEntry[], incoming: ChatEntry[]) { - const existingMetaIndexByRound = new Map(); - existing.forEach((entry, index) => { - if (entry.kind === "assistant" && entry.meta && !existingMetaIndexByRound.has(assistantRoundKey(entry))) { - existingMetaIndexByRound.set(assistantRoundKey(entry), index); - } - }); - - let next: ChatEntry[] | null = null; - const ensureNext = () => { - next ??= existing.slice(); - return next; - }; - - incoming.forEach((incomingEntry, index) => { - if (incomingEntry.kind !== "assistant" || !incomingEntry.meta) { - return; - } - const existingEntry = existing[index]; - const roundKey = assistantRoundKey(incomingEntry); - const targetIndex = - existingEntry?.kind === "assistant" && existingEntry.meta - ? index - : existingMetaIndexByRound.get(roundKey) ?? index; - const targetEntry = existing[targetIndex]; - if (!targetEntry || targetEntry.kind !== "assistant") { - return; - } - const mergedMeta = targetEntry.meta - ? mergeDefinedRecordValues(targetEntry.meta, incomingEntry.meta) - : incomingEntry.meta; - if (mergedMeta === targetEntry.meta) { - return; - } - ensureNext()[targetIndex] = { - ...targetEntry, - meta: cloneValue(mergedMeta), - }; - existingMetaIndexByRound.set(roundKey, targetIndex); - }); - - return next ?? existing; -} - -function mergeUserMessageRefIntoExisting(existing: ChatEntry[], incoming: ChatEntry[]) { - let next: ChatEntry[] | null = null; - const ensureNext = () => { - next ??= existing.slice(); - return next; - }; - - incoming.forEach((incomingEntry, index) => { - if (incomingEntry.kind !== "user" || !incomingEntry.messageRef) { - return; - } - const existingEntry = existing[index]; - if (!existingEntry || existingEntry.kind !== "user") { - return; - } - if ( - existingEntry.messageRef && - JSON.stringify(existingEntry.messageRef) === JSON.stringify(incomingEntry.messageRef) - ) { - return; - } - ensureNext()[index] = { - ...existingEntry, - messageRef: cloneValue(incomingEntry.messageRef), - }; - }); - - return next ?? existing; -} - -function mergeReconciledMetadataIntoExisting(existing: ChatEntry[], incoming: ChatEntry[]) { - const withAssistantMeta = mergeAssistantMetadataIntoExisting(existing, incoming); - return mergeUserMessageRefIntoExisting(withAssistantMeta, incoming); -} - -function areEntryArraysVisuallyEquivalent(existing: ChatEntry[], incoming: ChatEntry[]) { - if (existing.length !== incoming.length) { - return false; - } - for (let index = 0; index < existing.length; index += 1) { - const existingEntry = existing[index]; - const incomingEntry = incoming[index]; - if (!existingEntry || !incomingEntry || !areEntriesVisuallyEquivalent(existingEntry, incomingEntry)) { - return false; - } - } - return true; -} - -function hasTailEntries( - existing: ChatEntry[], - liveEntries: ChatEntry[], - areEquivalent: (left: ChatEntry, right: ChatEntry) => boolean, -) { - if (liveEntries.length === 0 || existing.length < liveEntries.length) { - return false; - } - - const offset = existing.length - liveEntries.length; - for (let index = 0; index < liveEntries.length; index += 1) { - const existingEntry = existing[offset + index]; - const liveEntry = liveEntries[index]; - if (!existingEntry || !liveEntry || !areEquivalent(existingEntry, liveEntry)) { - return false; - } - } - - return true; -} - -export function hasEquivalentTailEntries(existing: ChatEntry[], liveEntries: ChatEntry[]) { - return hasTailEntries(existing, liveEntries, areEntriesEquivalent); -} - -function hasVisuallyEquivalentTailEntries(existing: ChatEntry[], liveEntries: ChatEntry[]) { - return hasTailEntries(existing, liveEntries, areEntriesVisuallyEquivalent); -} - -export function omitEquivalentTailEntries(existing: ChatEntry[], liveEntries: ChatEntry[]) { - if (!hasVisuallyEquivalentTailEntries(existing, liveEntries)) { - const overlap = findLargestOverlap(existing, liveEntries, areEntriesVisuallyEquivalent); - return overlap > 0 ? existing.slice(0, existing.length - overlap) : existing; - } - return existing.slice(0, existing.length - liveEntries.length); -} - -function findLargestOverlap( - existing: ChatEntry[], - incoming: ChatEntry[], - areEquivalent: (left: ChatEntry, right: ChatEntry) => boolean = areEntriesEquivalent, -) { - const maxOverlap = Math.min(existing.length, incoming.length); - for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { - let matches = true; - const existingStart = existing.length - overlap; - for (let index = 0; index < overlap; index += 1) { - const existingEntry = existing[existingStart + index]; - const incomingEntry = incoming[index]; - if (!existingEntry || !incomingEntry || !areEquivalent(existingEntry, incomingEntry)) { - matches = false; - break; - } - } - if (matches) { - return overlap; - } - } - return 0; -} - -function buildCommittedEntryId(entry: ChatEntry, ordinal: number) { - const payloadHash = hashText(JSON.stringify(buildComparableEntry(entry))); - return `committed-live-${entry.kind}-${ordinal}-${payloadHash}`; -} - -export type MergeHistorySnapshotOptions = { - // Hint that `incoming` represents the full server-side conversation rather - // than a paginated tail. When true, a shorter incoming list without an - // overlap match is treated as a server-side truncation (e.g. another client - // edited and resent an earlier message) and replaces the existing entries - // instead of being preserved as stale state. - isFullSnapshot?: boolean; -}; - -export function mergeHistorySnapshotEntries( - existing: ChatEntry[], - incoming: ChatEntry[], - options?: MergeHistorySnapshotOptions, -) { - const isFullSnapshot = options?.isFullSnapshot === true; - - if (incoming.length === 0) { - return isFullSnapshot ? [] : existing; - } - if (hasEquivalentTailEntries(existing, incoming)) { - return existing; - } - if (areEntryArraysVisuallyEquivalent(existing, incoming)) { - return mergeReconciledMetadataIntoExisting(existing, incoming); - } - if (existing.length === 0) { - return incoming.map((entry) => cloneValue(entry)); - } - - const overlap = findLargestOverlap(existing, incoming); - if (overlap > 0) { - return [ - ...existing.slice(0, existing.length - overlap), - ...incoming.map((entry) => cloneValue(entry)), - ]; - } - - const visualOverlap = findLargestOverlap(existing, incoming, areEntriesVisuallyEquivalent); - if (visualOverlap > 0) { - const existingPrefix = existing.slice(0, existing.length - visualOverlap); - const existingOverlap = existing.slice(existing.length - visualOverlap); - const incomingOverlap = incoming.slice(0, visualOverlap); - const incomingSuffix = incoming.slice(visualOverlap); - return [ - ...existingPrefix, - ...mergeReconciledMetadataIntoExisting(existingOverlap, incomingOverlap), - ...incomingSuffix.map((entry) => cloneValue(entry)), - ]; - } - - if (incoming.length >= existing.length || isFullSnapshot) { - return incoming.map((entry) => cloneValue(entry)); - } - return existing; -} - -export function appendCommittedLiveEntries(existing: ChatEntry[], liveEntries: ChatEntry[]) { - if (liveEntries.length === 0 || hasEquivalentTailEntries(existing, liveEntries)) { - return existing; - } - - const visualOverlap = findLargestOverlap(existing, liveEntries, areEntriesVisuallyEquivalent); - const baseEntries = - visualOverlap > 0 - ? [ - ...existing.slice(0, existing.length - visualOverlap), - ...mergeReconciledMetadataIntoExisting( - existing.slice(existing.length - visualOverlap), - liveEntries.slice(0, visualOverlap), - ), - ] - : existing; - const entriesToCommit = visualOverlap > 0 ? liveEntries.slice(visualOverlap) : liveEntries; - if (entriesToCommit.length === 0) { - return baseEntries; - } - - const baseIndex = baseEntries.length; - const committedEntries = entriesToCommit.map((entry, index) => ({ - ...cloneValue(entry), - id: buildCommittedEntryId(entry, baseIndex + index), - })); - - return [...baseEntries, ...committedEntries]; -} diff --git a/crates/agent-gateway/web/src/lib/liveConversationStreamStore.ts b/crates/agent-gateway/web/src/lib/liveConversationStreamStore.ts deleted file mode 100644 index 4e54243e..00000000 --- a/crates/agent-gateway/web/src/lib/liveConversationStreamStore.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { pushChatEvent, type ChatEntry } from "./chatUi"; -import type { ChatEvent, ChatRuntimeSnapshotEvent } from "./gatewayTypes"; - -export type LiveConversationStreamSnapshot = { - revision: number; - entries: ChatEntry[]; - toolStatus: string | null; - toolStatusIsCompaction: boolean; -}; - -export type LiveConversationStreamStore = { - getSnapshot: () => LiveConversationStreamSnapshot; - subscribe: (listener: () => void) => () => void; - applySnapshot: (event: ChatRuntimeSnapshotEvent, options?: { flush?: boolean }) => void; - appendEvent: (event: ChatEvent, options?: { flush?: boolean }) => void; - setToolStatus: ( - toolStatus: string | null | undefined, - isCompaction?: boolean, - options?: { flush?: boolean }, - ) => void; - reset: () => void; - flush: () => void; -}; - -const EMPTY_SNAPSHOT: LiveConversationStreamSnapshot = { - revision: 0, - entries: [], - toolStatus: null, - toolStatusIsCompaction: false, -}; -const LIVE_STREAM_COMMIT_INTERVAL_MS = 48; -const LIVE_STREAM_LONG_TEXT_COMMIT_INTERVAL_MS = 80; -const LIVE_STREAM_BACKGROUND_COMMIT_INTERVAL_MS = 160; -const LIVE_STREAM_RAF_FALLBACK_MS = 250; -const LIVE_STREAM_LONG_TEXT_LENGTH = 6000; - -function normalizeOptionalStatus(value: string | null | undefined) { - const text = typeof value === "string" ? value.trim() : ""; - return text || null; -} - -function canUseAnimationFrame() { - return ( - typeof window !== "undefined" && - typeof window.requestAnimationFrame === "function" && - typeof window.cancelAnimationFrame === "function" - ); -} - -function canUseTimeout() { - return ( - typeof window !== "undefined" && - typeof window.setTimeout === "function" && - typeof window.clearTimeout === "function" - ); -} - -function isDocumentVisible() { - return typeof document === "undefined" || document.visibilityState === "visible"; -} - -function shouldUseAnimationFrameForCommit() { - return canUseAnimationFrame() && isDocumentVisible(); -} - -function getLatestLiveTextLength(snapshot: LiveConversationStreamSnapshot) { - for (let index = snapshot.entries.length - 1; index >= 0; index -= 1) { - const entry = snapshot.entries[index]; - if (!entry) { - continue; - } - if (entry.kind === "assistant" || entry.kind === "thinking") { - return entry.text.length; - } - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - } - return 0; -} - -function readChatEventSeq(event: ChatEvent) { - const seq = event.seq; - return typeof seq === "number" && Number.isFinite(seq) && seq > 0 - ? Math.floor(seq) - : null; -} - -function readRuntimeSnapshotRevision(event: ChatRuntimeSnapshotEvent) { - return typeof event.revision === "number" && Number.isFinite(event.revision) && event.revision > 0 - ? Math.floor(event.revision) - : 0; -} - -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function isSnapshotChatEntry(value: unknown): value is ChatEntry { - if (!isRecord(value) || typeof value.id !== "string" || typeof value.kind !== "string") { - return false; - } - switch (value.kind) { - case "user": - return typeof value.text === "string" && Array.isArray(value.attachments); - case "assistant": - case "thinking": - case "error": - return typeof value.text === "string"; - case "tool_call": - return isRecord(value.toolCall); - case "tool_result": - return isRecord(value.toolResult); - case "hosted_search": - return isRecord(value.hostedSearch); - default: - return false; - } -} - -function parseRuntimeSnapshotEntries(entriesJson: string | undefined) { - const raw = typeof entriesJson === "string" ? entriesJson.trim() : ""; - if (!raw) { - return []; - } - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; - } catch { - return []; - } -} - -function isTerminalChatEvent(event: ChatEvent) { - if (event.type === "done" || event.type === "error") { - return true; - } - if (event.type !== "completed" && event.type !== "failed" && event.type !== "cancelled") { - return false; - } - return ( - event.state === "completed" || - event.state === "failed" || - event.state === "cancelled" - ); -} - -function isChatControlEvent(event: ChatEvent) { - switch (event.type) { - case "accepted": - case "user_message": - case "rebased": - case "projection_updated": - case "delivered": - case "claimed": - case "starting": - case "queued_in_gui": - case "started": - case "progress": - case "completed": - case "failed": - case "cancelled": - return true; - default: - return false; - } -} - -function shouldAppendChatEvent(event: ChatEvent) { - if (event.type === "user_message") { - return true; - } - if (event.type === "error" || event.type === "failed") { - return true; - } - return event.type !== "done" && !isChatControlEvent(event); -} - -function resolveCommitInterval(snapshot: LiveConversationStreamSnapshot) { - if (typeof document !== "undefined" && document.visibilityState !== "visible") { - return LIVE_STREAM_BACKGROUND_COMMIT_INTERVAL_MS; - } - return getLatestLiveTextLength(snapshot) >= LIVE_STREAM_LONG_TEXT_LENGTH - ? LIVE_STREAM_LONG_TEXT_COMMIT_INTERVAL_MS - : LIVE_STREAM_COMMIT_INTERVAL_MS; -} - -export function createLiveConversationStreamStore(): LiveConversationStreamStore { - let draft = EMPTY_SNAPSHOT; - let snapshot = EMPTY_SNAPSHOT; - let rafId: number | null = null; - let timeoutId: number | null = null; - let rafFallbackTimeoutId: number | null = null; - let lastCommitAt = 0; - let latestRuntimeSnapshotRevision = 0; - let latestRuntimeSnapshotSeq = 0; - const seenEventSeqs = new Set(); - const listeners = new Set<() => void>(); - - const emitChange = () => { - listeners.forEach((listener) => listener()); - }; - - const cancelScheduledCommit = () => { - if (rafId !== null && canUseAnimationFrame()) { - window.cancelAnimationFrame(rafId); - } - rafId = null; - if (timeoutId !== null && canUseTimeout()) { - window.clearTimeout(timeoutId); - } - timeoutId = null; - if (rafFallbackTimeoutId !== null && canUseTimeout()) { - window.clearTimeout(rafFallbackTimeoutId); - } - rafFallbackTimeoutId = null; - }; - - const commit = () => { - rafId = null; - timeoutId = null; - rafFallbackTimeoutId = null; - if (snapshot === draft) { - return; - } - snapshot = draft; - lastCommitAt = Date.now(); - emitChange(); - }; - - const scheduleCommit = () => { - if ( - rafId !== null || - timeoutId !== null || - rafFallbackTimeoutId !== null || - snapshot === draft - ) { - return; - } - - const elapsed = Date.now() - lastCommitAt; - const delay = Math.max(0, resolveCommitInterval(draft) - elapsed); - const scheduleFrame = () => { - timeoutId = null; - if (!shouldUseAnimationFrameForCommit()) { - commit(); - return; - } - rafId = window.requestAnimationFrame(() => { - rafId = null; - if (rafFallbackTimeoutId !== null && canUseTimeout()) { - window.clearTimeout(rafFallbackTimeoutId); - } - rafFallbackTimeoutId = null; - commit(); - }); - if (canUseTimeout()) { - rafFallbackTimeoutId = window.setTimeout(() => { - rafFallbackTimeoutId = null; - if (rafId !== null && canUseAnimationFrame()) { - window.cancelAnimationFrame(rafId); - } - rafId = null; - commit(); - }, LIVE_STREAM_RAF_FALLBACK_MS); - } - }; - if (delay <= 0 || !canUseTimeout()) { - scheduleFrame(); - } else { - timeoutId = window.setTimeout(scheduleFrame, delay); - } - }; - - const updateDraft = ( - updater: (previous: LiveConversationStreamSnapshot) => LiveConversationStreamSnapshot, - options?: { flush?: boolean }, - ) => { - const next = updater(draft); - if (next === draft) { - if (options?.flush) { - cancelScheduledCommit(); - commit(); - } - return; - } - draft = { - ...next, - revision: draft.revision + 1, - }; - if (options?.flush) { - cancelScheduledCommit(); - commit(); - } else { - scheduleCommit(); - } - }; - - return { - getSnapshot: () => snapshot, - subscribe: (listener) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - appendEvent: (event, options) => { - const eventSeq = readChatEventSeq(event); - if (eventSeq !== null) { - if (latestRuntimeSnapshotSeq > 0 && eventSeq <= latestRuntimeSnapshotSeq) { - return; - } - if (seenEventSeqs.has(eventSeq)) { - return; - } - seenEventSeqs.add(eventSeq); - } - - if (event.type === "tool_status") { - updateDraft( - (previous) => { - const status = normalizeOptionalStatus(event.status); - const isCompaction = Boolean(status) && event.isCompaction === true; - if ( - previous.toolStatus === status && - previous.toolStatusIsCompaction === isCompaction - ) { - return previous; - } - return { - ...previous, - toolStatus: status, - toolStatusIsCompaction: isCompaction, - }; - }, - options, - ); - return; - } - - updateDraft( - (previous) => { - const terminal = isTerminalChatEvent(event); - const nextEntries = shouldAppendChatEvent(event) - ? pushChatEvent(previous.entries, event) - : previous.entries; - const shouldClearStatus = terminal; - if ( - nextEntries === previous.entries && - (!shouldClearStatus || - (previous.toolStatus === null && !previous.toolStatusIsCompaction)) - ) { - return previous; - } - return { - ...previous, - entries: nextEntries, - toolStatus: shouldClearStatus ? null : previous.toolStatus, - toolStatusIsCompaction: shouldClearStatus - ? false - : previous.toolStatusIsCompaction, - }; - }, - options, - ); - }, - applySnapshot: (event, options) => { - const eventSeq = readChatEventSeq(event); - const snapshotRevision = readRuntimeSnapshotRevision(event); - if ( - snapshotRevision > 0 && - latestRuntimeSnapshotRevision > 0 && - snapshotRevision < latestRuntimeSnapshotRevision - ) { - return; - } - if ( - snapshotRevision > 0 && - latestRuntimeSnapshotRevision > 0 && - snapshotRevision === latestRuntimeSnapshotRevision && - eventSeq !== null && - latestRuntimeSnapshotSeq > 0 && - eventSeq <= latestRuntimeSnapshotSeq - ) { - return; - } - - const entries = parseRuntimeSnapshotEntries(event.entries_json); - const toolStatus = normalizeOptionalStatus(event.tool_status); - const toolStatusIsCompaction = Boolean(toolStatus) && event.tool_status_is_compaction === true; - - latestRuntimeSnapshotRevision = Math.max(latestRuntimeSnapshotRevision, snapshotRevision); - if (eventSeq !== null) { - latestRuntimeSnapshotSeq = Math.max(latestRuntimeSnapshotSeq, eventSeq); - seenEventSeqs.add(eventSeq); - } - updateDraft( - (previous) => { - if ( - previous.entries === entries && - previous.toolStatus === toolStatus && - previous.toolStatusIsCompaction === toolStatusIsCompaction - ) { - return previous; - } - return { - ...previous, - entries, - toolStatus, - toolStatusIsCompaction, - }; - }, - options, - ); - }, - setToolStatus: (toolStatus, isCompaction = false, options) => { - updateDraft( - (previous) => { - const status = normalizeOptionalStatus(toolStatus); - const nextIsCompaction = Boolean(status) && isCompaction; - if ( - previous.toolStatus === status && - previous.toolStatusIsCompaction === nextIsCompaction - ) { - return previous; - } - return { - ...previous, - toolStatus: status, - toolStatusIsCompaction: nextIsCompaction, - }; - }, - options, - ); - }, - reset: () => { - if ( - draft.entries.length === 0 && - draft.toolStatus === null && - !draft.toolStatusIsCompaction - ) { - cancelScheduledCommit(); - return; - } - draft = { - ...EMPTY_SNAPSHOT, - revision: draft.revision + 1, - }; - seenEventSeqs.clear(); - latestRuntimeSnapshotRevision = 0; - latestRuntimeSnapshotSeq = 0; - cancelScheduledCommit(); - commit(); - }, - flush: () => { - cancelScheduledCommit(); - commit(); - }, - }; -} diff --git a/crates/agent-gateway/web/src/lib/localDraftConversation.ts b/crates/agent-gateway/web/src/lib/localDraftConversation.ts deleted file mode 100644 index ea60d47d..00000000 --- a/crates/agent-gateway/web/src/lib/localDraftConversation.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const LOCAL_DRAFT_CONVERSATION_PREFIX = "__local_draft__:"; - -export function createLocalDraftConversationId() { - return `${LOCAL_DRAFT_CONVERSATION_PREFIX}${crypto.randomUUID()}`; -} - -export function isLocalDraftConversationId(conversationId: string) { - return conversationId.trim().startsWith(LOCAL_DRAFT_CONVERSATION_PREFIX); -} diff --git a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeBatcher.ts b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeBatcher.ts index 96732ced..1cf959c0 100644 --- a/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeBatcher.ts +++ b/crates/agent-gui/src/pages/chat/gateway/useGatewayBridgeBatcher.ts @@ -25,13 +25,6 @@ type PendingGatewayBridgeEventBatch = BatchableGatewayBridgeEvent & { microtaskQueued: boolean; }; -type DeferredToolCallDeltaSend = { - requestId: string; - batchKey: string; - event: Record; - options?: GatewayBridgeSendOptions; -}; - type GatewayBridgeSendOptions = { workerId?: string; }; @@ -136,9 +129,6 @@ export function useGatewayBridgeBatcher() { const pendingGatewayBridgeEventBatchesRef = useRef( new Map(), ); - const inFlightToolCallDeltaBatchesRef = useRef(new Set()); - const deferredToolCallDeltaSendsRef = useRef(new Map()); - const sendGatewayBridgeEventForRequest = useCallback( (requestId: string, event: Record, options?: GatewayBridgeSendOptions) => { const workerId = options?.workerId?.trim() || undefined; @@ -161,50 +151,6 @@ export function useGatewayBridgeBatcher() { [], ); - const sendToolCallDeltaForRequest = useCallback( - ( - batchKey: string, - requestId: string, - event: Record, - options?: GatewayBridgeSendOptions, - ) => { - if (inFlightToolCallDeltaBatchesRef.current.has(batchKey)) { - deferredToolCallDeltaSendsRef.current.set(batchKey, { - requestId, - batchKey, - event, - options, - }); - return; - } - - inFlightToolCallDeltaBatchesRef.current.add(batchKey); - sendGatewayBridgeEventForRequest(requestId, event, options).finally(() => { - inFlightToolCallDeltaBatchesRef.current.delete(batchKey); - const deferred = deferredToolCallDeltaSendsRef.current.get(batchKey); - if (!deferred) { - return; - } - deferredToolCallDeltaSendsRef.current.delete(batchKey); - sendToolCallDeltaForRequest( - deferred.batchKey, - deferred.requestId, - deferred.event, - deferred.options, - ); - }); - }, - [sendGatewayBridgeEventForRequest], - ); - - const discardDeferredToolCallDeltasForRequest = useCallback((requestId: string) => { - for (const [batchKey, deferred] of deferredToolCallDeltaSendsRef.current.entries()) { - if (deferred.requestId === requestId) { - deferredToolCallDeltaSendsRef.current.delete(batchKey); - } - } - }, []); - const flushGatewayBridgeEventBatchForRequest = useCallback( (batchKey: string) => { const pending = pendingGatewayBridgeEventBatchesRef.current.get(batchKey); @@ -241,16 +187,11 @@ export function useGatewayBridgeBatcher() { ...(pending.round !== null ? { round: pending.round } : {}), }; - const options = { + sendGatewayBridgeEventForRequest(pending.requestId, event, { workerId: pending.workerId, - }; - if (pending.type === "tool_call_delta") { - sendToolCallDeltaForRequest(batchKey, pending.requestId, event, options); - } else { - sendGatewayBridgeEventForRequest(pending.requestId, event, options); - } + }); }, - [sendGatewayBridgeEventForRequest, sendToolCallDeltaForRequest], + [sendGatewayBridgeEventForRequest], ); const flushGatewayBridgeEventBatchesForRequest = useCallback( @@ -330,7 +271,6 @@ export function useGatewayBridgeBatcher() { const batchable = toBatchableGatewayBridgeEvent(event); if (!batchable) { flushGatewayBridgeEventBatchesForRequest(requestId); - discardDeferredToolCallDeltasForRequest(requestId); return sendGatewayBridgeEventForRequest(requestId, event, options); } @@ -368,7 +308,6 @@ export function useGatewayBridgeBatcher() { scheduleGatewayBridgeEventBatchFlush(batchKey); }, [ - discardDeferredToolCallDeltasForRequest, flushGatewayBridgeEventBatchesForRequest, flushGatewayBridgeEventBatchForRequest, scheduleGatewayBridgeEventBatchFlush, @@ -395,8 +334,6 @@ export function useGatewayBridgeBatcher() { pending.microtaskQueued = false; } pendingGatewayBridgeEventBatchesRef.current.clear(); - deferredToolCallDeltaSendsRef.current.clear(); - inFlightToolCallDeltaBatchesRef.current.clear(); }, [], ); From 94b8fec42ef878cf3c3c4be3b0384256a6072bfa Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Tue, 30 Jun 2026 12:21:14 +0800 Subject: [PATCH 12/64] fix(chat): stabilize gateway run handoff and idle recovery --- .../agent-gateway/internal/config/config.go | 5 + .../internal/server/chat_payloads.go | 149 +++++++++++++++++- .../internal/server/http_chat.go | 1 + .../internal/server/websocket.go | 108 ++++++++++++- .../internal/session/manager_chat_queue.go | 2 +- .../internal/session/manager_history_sync.go | 2 +- .../internal/session/manager_settings_sync.go | 2 +- .../test/session/manager_test.go | 12 +- .../agent-gateway/web/src/app/GatewayApp.tsx | 74 ++++++--- .../web/src/lib/gatewaySocket.ts | 8 +- 10 files changed, 320 insertions(+), 43 deletions(-) diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index 5b50b12f..28ab0252 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -22,6 +22,7 @@ type Config struct { HeartbeatPeriod time.Duration WebSocketHeartbeatPeriod time.Duration WebSocketWriteTimeout time.Duration + WebSocketWriteQueueSize int GRPCMaxMessageBytes int ChatEventStorePath string } @@ -40,6 +41,7 @@ func Load() *Config { flag.DurationVar(&cfg.HeartbeatPeriod, "heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_HEARTBEAT_PERIOD", 30*time.Second), "ping interval for agent connection") flag.DurationVar(&cfg.WebSocketHeartbeatPeriod, "websocket-heartbeat-period", getenvDuration("LIVEAGENT_GATEWAY_WS_HEARTBEAT_PERIOD", 15*time.Second), "ping interval for browser WebSocket connections") flag.DurationVar(&cfg.WebSocketWriteTimeout, "websocket-write-timeout", getenvDuration("LIVEAGENT_GATEWAY_WS_WRITE_TIMEOUT", 10*time.Second), "write timeout for browser WebSocket connections") + flag.IntVar(&cfg.WebSocketWriteQueueSize, "websocket-write-queue-size", getenvInt("LIVEAGENT_GATEWAY_WS_WRITE_QUEUE_SIZE", 512), "write queue buffer size for browser WebSocket connections") flag.IntVar(&cfg.GRPCMaxMessageBytes, "grpc-max-message-bytes", getenvInt("LIVEAGENT_GATEWAY_GRPC_MAX_MESSAGE_BYTES", DefaultGRPCMaxMessageBytes), "maximum gRPC message size in bytes") flag.StringVar(&cfg.ChatEventStorePath, "chat-event-store", getenv("LIVEAGENT_GATEWAY_CHAT_EVENT_STORE", defaultChatEventStorePath()), "SQLite path for durable chat command/event replay state") flag.Parse() @@ -68,6 +70,9 @@ func Load() *Config { if cfg.WebSocketWriteTimeout <= 0 { cfg.WebSocketWriteTimeout = 10 * time.Second } + if cfg.WebSocketWriteQueueSize <= 0 { + cfg.WebSocketWriteQueueSize = 512 + } return cfg } diff --git a/crates/agent-gateway/internal/server/chat_payloads.go b/crates/agent-gateway/internal/server/chat_payloads.go index e8d78a7f..e53b5b3c 100644 --- a/crates/agent-gateway/internal/server/chat_payloads.go +++ b/crates/agent-gateway/internal/server/chat_payloads.go @@ -48,8 +48,9 @@ func chatControlPayload( } func chatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { + protoType := chatEventType(event.GetType()) payload := map[string]any{ - "type": chatEventType(event.GetType()), + "type": protoType, } if seq > 0 { payload["seq"] = seq @@ -76,9 +77,155 @@ func chatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...str payload["conversation_id"] = conversationID } + trimLargeToolContentForSSE(payload, protoType) + return payload } +const sseToolContentMaxChars = 200 + +var toolFieldsToTrim = map[string][]string{ + "Write": {"content"}, + "Edit": {"old_string", "new_string"}, + "NotebookEdit": {"new_source"}, +} + +func trimLargeToolContentForSSE(payload map[string]any, protoType string) { + eventType, _ := payload["type"].(string) + + if eventType == "tool_call" || eventType == "tool_call_delta" || + protoType == "tool_call" || protoType == "tool_call_delta" { + trimToolCallPayload(payload) + return + } + if eventType == "tool_result" || protoType == "tool_result" { + trimToolResultPayload(payload) + } +} + +func trimToolCallPayload(payload map[string]any) { + toolName := firstString(payload["name"], payload["tool_name"]) + fields, ok := toolFieldsToTrim[toolName] + if !ok { + return + } + + args := firstMap(payload["arguments"], payload["input"], payload["args"]) + if args == nil { + args = tryParseJSONStringArg(payload, "arguments", "input", "args") + if args == nil { + return + } + } + + for _, field := range fields { + trimStringFieldWithPreview(args, field, sseToolContentMaxChars) + } +} + +func tryParseJSONStringArg(payload map[string]any, keys ...string) map[string]any { + for _, key := range keys { + s, ok := payload[key].(string) + if !ok || s == "" { + continue + } + var parsed map[string]any + if err := json.Unmarshal([]byte(s), &parsed); err == nil && len(parsed) > 0 { + payload[key] = parsed + return parsed + } + } + return nil +} + +func trimToolResultPayload(payload map[string]any) { + switch content := payload["content"].(type) { + case string: + if len(content) > sseToolContentMaxChars { + lines := countLines(content) + payload["content"] = content[:sseToolContentMaxChars] + ensurePreviewMeta(payload, "content", len(content), lines, true) + } + case []any: + for _, item := range content { + block, ok := item.(map[string]any) + if !ok { + continue + } + if text, ok := block["text"].(string); ok && len(text) > sseToolContentMaxChars { + lines := countLines(text) + block["text"] = text[:sseToolContentMaxChars] + ensurePreviewMeta(block, "text", len(text), lines, true) + } + } + } +} + +func trimStringFieldWithPreview(args map[string]any, field string, maxChars int) { + text, ok := args[field].(string) + if !ok || len(text) <= maxChars { + return + } + lines := countLines(text) + args[field] = text[:maxChars] + ensurePreviewMeta(args, field, len(text), lines, true) +} + +func ensurePreviewMeta(container map[string]any, fieldName string, chars int, lines int, truncated bool) { + const metaKey = "__liveagent_stream_preview" + meta, _ := container[metaKey].(map[string]any) + if meta == nil { + meta = map[string]any{} + container[metaKey] = meta + } + fields, _ := meta["fields"].(map[string]any) + if fields == nil { + fields = map[string]any{} + meta["fields"] = fields + } + fields[fieldName] = map[string]any{ + "chars": chars, + "lines": lines, + "truncated": truncated, + } +} + +func countLines(s string) int { + if len(s) == 0 { + return 0 + } + n := 1 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + n++ + } else if s[i] == '\r' { + n++ + if i+1 < len(s) && s[i+1] == '\n' { + i++ + } + } + } + return n +} + +func firstString(candidates ...any) string { + for _, c := range candidates { + if s, ok := c.(string); ok && s != "" { + return s + } + } + return "" +} + +func firstMap(candidates ...any) map[string]any { + for _, c := range candidates { + if m, ok := c.(map[string]any); ok && len(m) > 0 { + return m + } + } + return nil +} + func chatEventType(eventType gatewayv1.ChatEvent_ChatEventType) string { switch eventType { case gatewayv1.ChatEvent_TOKEN: diff --git a/crates/agent-gateway/internal/server/http_chat.go b/crates/agent-gateway/internal/server/http_chat.go index 35041b23..759bc3fd 100644 --- a/crates/agent-gateway/internal/server/http_chat.go +++ b/crates/agent-gateway/internal/server/http_chat.go @@ -297,6 +297,7 @@ func chatBroadcastPayload(event *session.ChatBroadcastEvent) (map[string]any, bo payload["workdir"] = workdir } } + trimLargeToolContentForSSE(payload, "") return publicChatPayload(payload), isTerminalChatPayload(payload) } if event.Control != nil { diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 0d313fc7..403d0857 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -89,6 +89,12 @@ type websocketGitRequestPayload struct { Args json.RawMessage `json:"args,omitempty"` } +const ( + websocketWriteQueueDefault = 512 + websocketMaxWriteRetries = 2 + websocketRetryBackoff = 100 * time.Millisecond +) + type websocketConnection struct { cfg *config.Config sm *session.Manager @@ -97,11 +103,15 @@ type websocketConnection struct { req *http.Request writeMu sync.Mutex writeTimeout time.Duration + outbox chan websocketEnvelope closeOnce sync.Once done chan struct{} authorized bool + lastPongAt time.Time + lastPongMu sync.Mutex + historyEvents <-chan *gatewayv1.HistorySyncEvent historyEventsCleanup func() settingsEvents <-chan *gatewayv1.SettingsSyncEvent @@ -134,12 +144,17 @@ func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { } conn.SetReadLimit(webSocketReadLimit(cfg)) + queueSize := websocketWriteQueueDefault + if cfg.WebSocketWriteQueueSize > 0 { + queueSize = cfg.WebSocketWriteQueueSize + } state := &websocketConnection{ cfg: cfg, sm: sm, conn: conn, req: r, writeTimeout: cfg.WebSocketWriteTimeout, + outbox: make(chan websocketEnvelope, queueSize), done: make(chan struct{}), terminalInterest: newWebsocketTerminalInterestTracker(), } @@ -168,6 +183,10 @@ func (c *websocketConnection) serve() { req.ID = strings.TrimSpace(req.ID) req.Type = strings.TrimSpace(req.Type) if req.Type == "pong" { + c.lastPongMu.Lock() + c.lastPongAt = time.Now() + c.lastPongMu.Unlock() + _ = c.conn.SetReadDeadline(time.Now().Add(c.pongTimeout())) continue } if req.ID == "" { @@ -235,6 +254,8 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { } c.authorized = true + go c.writeLoop() + _ = c.conn.SetReadDeadline(time.Now().Add(c.pongTimeout())) c.startHistorySyncForwarder() c.startSettingsSyncForwarder() c.startTerminalEventForwarder() @@ -275,8 +296,17 @@ func (c *websocketConnection) startHistorySyncForwarder() { } } } + if payload["kind"] == "idle" && payload["run_id"] == nil { + conversationID := historySyncPayloadConversationID(payload, event) + if conversationID != "" { + if summary, ok := c.sm.ConversationRunSummary(conversationID); ok { + if rid := strings.TrimSpace(summary.RequestID); rid != "" { + payload["run_id"] = rid + } + } + } + } if err := c.writeEvent("history.event", payload); err != nil { - c.close() return } } @@ -307,7 +337,6 @@ func (c *websocketConnection) startSettingsSyncForwarder() { return } if err := c.writeEvent("settings.event", payload); err != nil { - c.close() return } } @@ -337,7 +366,6 @@ func (c *websocketConnection) startTerminalEventForwarder() { continue } if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(event)); err != nil { - c.close() return } } @@ -367,7 +395,6 @@ func (c *websocketConnection) startSftpEventForwarder() { continue } if err := c.writeEvent("sftp.event", websocketSftpEventPayload(event)); err != nil { - c.close() return } } @@ -394,7 +421,6 @@ func (c *websocketConnection) startChatQueueEventForwarder() { return } if err := c.writeEvent("chat_queue.event", websocketChatQueueEventPayload(event)); err != nil { - c.close() return } } @@ -416,7 +442,6 @@ func (c *websocketConnection) replayTerminalSessionSnapshot() { ProjectPathKey: terminalSession.GetProjectPathKey(), Session: terminalSession, })); err != nil { - c.close() return } } @@ -444,6 +469,9 @@ func (c *websocketConnection) startWebSocketHeartbeat() { if period <= 0 { period = 15 * time.Second } + c.lastPongMu.Lock() + c.lastPongAt = time.Now() + c.lastPongMu.Unlock() go func() { ticker := time.NewTicker(period) defer ticker.Stop() @@ -452,13 +480,19 @@ func (c *websocketConnection) startWebSocketHeartbeat() { case <-c.done: return case <-ticker.C: + c.lastPongMu.Lock() + lastPong := c.lastPongAt + c.lastPongMu.Unlock() + if time.Since(lastPong) > c.pongTimeout() { + c.close() + return + } if err := c.writeEnvelope(websocketEnvelope{ Type: "ping", Payload: map[string]any{ "timestamp": time.Now().Unix(), }, }); err != nil { - c.close() return } } @@ -467,6 +501,14 @@ func (c *websocketConnection) startWebSocketHeartbeat() { }) } +func (c *websocketConnection) pongTimeout() time.Duration { + period := c.cfg.WebSocketHeartbeatPeriod + if period <= 0 { + period = 15 * time.Second + } + return period*3 + 5*time.Second +} + func (c *websocketConnection) dispatch(req websocketRequest) { handler := websocketRequestHandlers[req.Type] if handler == nil { @@ -537,6 +579,58 @@ func (c *websocketConnection) writeEvent(eventType string, payload any) error { } func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { + select { + case <-c.done: + return errors.New("connection closed") + case c.outbox <- envelope: + return nil + default: + c.close() + return errors.New("write queue full") + } +} + +func (c *websocketConnection) writeLoop() { + for { + select { + case <-c.done: + return + case envelope := <-c.outbox: + if err := c.writeEnvelopeDirect(envelope); err != nil { + ok := false + for attempt := 0; attempt < websocketMaxWriteRetries; attempt++ { + select { + case <-c.done: + return + case <-time.After(websocketRetryBackoff): + } + if err := c.writeEnvelopeDirect(envelope); err == nil { + ok = true + break + } + } + if !ok { + c.close() + return + } + } + for drained := 0; drained < 64; drained++ { + select { + case extra := <-c.outbox: + if err := c.writeEnvelopeDirect(extra); err != nil { + c.close() + return + } + default: + goto batchDone + } + } + batchDone: + } + } +} + +func (c *websocketConnection) writeEnvelopeDirect(envelope websocketEnvelope) error { c.writeMu.Lock() defer c.writeMu.Unlock() if c.writeTimeout > 0 { diff --git a/crates/agent-gateway/internal/session/manager_chat_queue.go b/crates/agent-gateway/internal/session/manager_chat_queue.go index 23f4b840..40c86c84 100644 --- a/crates/agent-gateway/internal/session/manager_chat_queue.go +++ b/crates/agent-gateway/internal/session/manager_chat_queue.go @@ -24,7 +24,7 @@ func (m *Manager) SubscribeChatQueueEvents() (<-chan *gatewayv1.ChatQueueEvent, for _, conversationID := range conversationIDs { replay = append(replay, cloneChatQueueEvent(m.syncHub.chatQueueSnapshots[conversationID].event)) } - ch := make(chan *gatewayv1.ChatQueueEvent, 64+len(replay)) + ch := make(chan *gatewayv1.ChatQueueEvent, 128+len(replay)) subID := m.syncHub.nextChatQueueSubID m.syncHub.nextChatQueueSubID += 1 m.syncHub.chatQueueSubscribers[subID] = ch diff --git a/crates/agent-gateway/internal/session/manager_history_sync.go b/crates/agent-gateway/internal/session/manager_history_sync.go index 8a553d79..430784be 100644 --- a/crates/agent-gateway/internal/session/manager_history_sync.go +++ b/crates/agent-gateway/internal/session/manager_history_sync.go @@ -8,7 +8,7 @@ import ( ) func (m *Manager) SubscribeHistorySync() (<-chan *gatewayv1.HistorySyncEvent, func()) { - ch := make(chan *gatewayv1.HistorySyncEvent, 32) + ch := make(chan *gatewayv1.HistorySyncEvent, 128) m.syncHub.historyMu.Lock() subID := m.syncHub.nextHistorySubID diff --git a/crates/agent-gateway/internal/session/manager_settings_sync.go b/crates/agent-gateway/internal/session/manager_settings_sync.go index 34b0f530..f5dabf44 100644 --- a/crates/agent-gateway/internal/session/manager_settings_sync.go +++ b/crates/agent-gateway/internal/session/manager_settings_sync.go @@ -8,7 +8,7 @@ import ( ) func (m *Manager) SubscribeSettingsSync() (<-chan *gatewayv1.SettingsSyncEvent, func()) { - ch := make(chan *gatewayv1.SettingsSyncEvent, 32) + ch := make(chan *gatewayv1.SettingsSyncEvent, 64) m.syncHub.settingsMu.Lock() subID := m.syncHub.nextSettingsSubID diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 376aeefb..761476c3 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -1343,10 +1343,10 @@ func TestQueuedRunStartedHistoryEventSurvivesFullSubscriberQueue(t *testing.T) { historyEvents, cleanupHistoryEvents := sm.SubscribeHistorySync() defer cleanupHistoryEvents() - for index := 0; index < 40; index += 1 { - conversationID := fmt.Sprintf("filler-%02d", index) + for index := 0; index < 140; index += 1 { + conversationID := fmt.Sprintf("filler-%03d", index) sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: fmt.Sprintf("history-filler-%02d", index), + RequestId: fmt.Sprintf("history-filler-%03d", index), Payload: &gatewayv1.AgentEnvelope_HistorySync{ HistorySync: &gatewayv1.HistorySyncEvent{ Kind: "upsert", @@ -1392,10 +1392,10 @@ func TestCriticalHistoryEventDoesNotDrainEarlierCriticalEvent(t *testing.T) { } dispatchChatControl(sm, "request-a", "conversation-a", "started", session.ChatRunStateRunning) - for index := 0; index < 40; index += 1 { - conversationID := fmt.Sprintf("filler-%02d", index) + for index := 0; index < 140; index += 1 { + conversationID := fmt.Sprintf("filler-%03d", index) sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: fmt.Sprintf("history-filler-%02d", index), + RequestId: fmt.Sprintf("history-filler-%03d", index), Payload: &gatewayv1.AgentEnvelope_HistorySync{ HistorySync: &gatewayv1.HistorySyncEvent{ Kind: "upsert", diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 8ad3dd13..bb0a9e4e 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -2204,7 +2204,15 @@ export default function GatewayApp() { ); const finalizeTerminalLiveStream = useCallback( - (targetConversationId: string, currentApi = api) => { + ( + targetConversationId: string, + currentApi = api, + clearStreamingStateOptions?: { + remoteRunId?: string; + preserveRemoteRun?: boolean; + preserveRemoteRunOnMismatch?: boolean; + }, + ) => { const conversationIdValue = targetConversationId.trim(); if (!conversationIdValue) { return; @@ -2227,7 +2235,7 @@ export default function GatewayApp() { if (!isVisibleConversation) { flushSync(() => { clearConversationLiveStream(conversationIdValue); - clearConversationStreamingState(conversationIdValue); + clearConversationStreamingState(conversationIdValue, clearStreamingStateOptions); }); } else { preserveTranscriptScrollPosition( @@ -2236,7 +2244,7 @@ export default function GatewayApp() { commitConversationLiveStreamToRuntime(conversationIdValue, { clearLiveStream: false, }); - clearConversationStreamingState(conversationIdValue); + clearConversationStreamingState(conversationIdValue, clearStreamingStateOptions); }); }, { stickToBottom: shouldKeepBottom }, @@ -2438,7 +2446,10 @@ export default function GatewayApp() { if (terminalEvent) { terminalEventSeen = true; - finalizeTerminalLiveStream(conversationIdValue, currentApi); + finalizeTerminalLiveStream(conversationIdValue, currentApi, { + remoteRunId: nextRunKey, + preserveRemoteRunOnMismatch: true, + }); return; } @@ -2465,7 +2476,10 @@ export default function GatewayApp() { { flush: true }, ); terminalEventSeen = true; - finalizeTerminalLiveStream(conversationIdValue, currentApi); + finalizeTerminalLiveStream(conversationIdValue, currentApi, { + remoteRunId: nextRunKey, + preserveRemoteRunOnMismatch: true, + }); } } finally { if ( @@ -2547,20 +2561,20 @@ export default function GatewayApp() { if (event.kind === "running" && !event.run_id?.trim()) { return; } - const eventRunKey = - event.kind === "running" - ? resolveRunningConversationRunKey({ - runId: event.run_id, - }) - : ""; - if (event.kind === "idle" && !eventRunKey) { + const eventRunKey = event.run_id?.trim() + ? resolveRunningConversationRunKey({ runId: event.run_id }) + : ""; + if (event.kind === "idle") { const activeStream = conversationEventStreamsRef.current.get(targetConversationId); const activeRunKey = activeStream?.runId ?? resolveRunningConversationRunKey( remoteRunningConversationRuntimeRef.current.get(targetConversationId), ); - if (activeRunKey && activeStream) { + if (activeRunKey && (!eventRunKey || activeRunKey !== eventRunKey)) { + if (!activeStream) { + subscribeVisibleConversationEventStream(targetConversationId, api); + } void refreshHistoryWorkdirs(api); return; } @@ -2939,6 +2953,9 @@ export default function GatewayApp() { updatedAt: runningConversation.updated_at, }); } + for (const runningConversation of runningConversations) { + subscribeVisibleConversationEventStream(runningConversation.conversation_id, currentApi); + } const runningConversationIds = runningConversations.map((item) => item.conversation_id); const retainedConversationIds = new Set([ ...blockedHistoryHydrationConversationIdsRef.current, @@ -3654,6 +3671,7 @@ export default function GatewayApp() { } let terminalEventSeen = false; + let queuedInGuiSeen = false; let recoverableTransportErrorSeen = false; let runActive = false; let runtimeStarted = false; @@ -3843,6 +3861,7 @@ export default function GatewayApp() { if (isChatControlEvent(event)) { if (event.type === "queued_in_gui" || event.state === "desktop_queued") { terminalEventSeen = true; + queuedInGuiSeen = true; clearRuntimeStartingStatusTimer(); clearConversationStreamingState( activeConversationId, @@ -3870,16 +3889,16 @@ export default function GatewayApp() { if (isTerminalChatControlEvent(event)) { terminalEventSeen = true; clearRuntimeStartingStatusTimer(); - clearConversationStreamingState( - activeConversationId, - preserveRemoteRunCleanupOptions(), - ); if (event.type === "failed" || event.state === "failed") { getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { flush: true, }); } - finalizeTerminalLiveStream(activeConversationId, api); + finalizeTerminalLiveStream( + activeConversationId, + api, + preserveRemoteRunCleanupOptions(), + ); } else if (isRuntimeStartedChatControlEvent(event)) { markRuntimeStarted(); updateConversationRuntimeEntry(activeConversationId, (current) => ({ @@ -3919,11 +3938,11 @@ export default function GatewayApp() { if (terminalEvent) { terminalEventSeen = true; clearRuntimeStartingStatusTimer(); - clearConversationStreamingState( + finalizeTerminalLiveStream( activeConversationId, + api, preserveRemoteRunCleanupOptions(), ); - finalizeTerminalLiveStream(activeConversationId, api); } } const liveTitle = readChatEventTitle(event); @@ -3967,13 +3986,24 @@ export default function GatewayApp() { if (remoteRunningConversationIdsRef.current.has(activeConversationId)) { subscribeVisibleConversationEventStream(activeConversationId, api); } - if (status?.online && !terminalEventSeen) { - await reloadHistory(api, { + if (status?.online && (!terminalEventSeen || queuedInGuiSeen)) { + void reloadHistory(api, { preferredConversationId: activeConversationId, skipSelectionSync: true, silent: true, }); } + if (queuedInGuiSeen && status?.online) { + const queuedConvId = activeConversationId; + const delayedReload = () => + void reloadHistory(api, { + preferredConversationId: queuedConvId, + skipSelectionSync: true, + silent: true, + }); + setTimeout(delayedReload, 3000); + setTimeout(delayedReload, 8000); + } if (!options?.editMessageRef) { blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); } diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index d74b1e36..323a7c4d 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -962,10 +962,10 @@ async function* streamGatewayChatEvents(params: { } const RECONNECT_INITIAL_DELAY_MS = 500; -const RECONNECT_MAX_DELAY_MS = 15_000; -const RECONNECT_NOTICE_DELAY_MS = 10_000; -const SOCKET_INBOUND_STALL_MS = 25_000; -const FOREGROUND_SOCKET_RECYCLE_IDLE_MS = 10_000; +const RECONNECT_MAX_DELAY_MS = 10_000; +const RECONNECT_NOTICE_DELAY_MS = 15_000; +const SOCKET_INBOUND_STALL_MS = 45_000; +const FOREGROUND_SOCKET_RECYCLE_IDLE_MS = 20_000; const FOREGROUND_WAKEUP_RECENCY_MS = 15_000; type RuntimeHost = { From 5ae30dcb9062595ecbd53f35513be88879022ed8 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 10:17:15 +0800 Subject: [PATCH 13/64] fix(chat): stream gateway chat through websocket subscriptions --- crates/agent-gateway/cmd/gateway/main.go | 14 +- crates/agent-gateway/go.mod | 8 - crates/agent-gateway/go.sum | 19 - .../agent-gateway/internal/config/config.go | 21 +- .../internal/config/config_test.go | 5 - .../internal/proto/v1/gateway.pb.go | 1099 +++++---- .../internal/server/chat_commands.go | 1 - crates/agent-gateway/internal/server/http.go | 1 - .../internal/server/http_chat.go | 172 -- .../internal/server/http_test.go | 342 +-- .../internal/server/websocket.go | 4 + .../server/websocket_chat_handlers.go | 343 +++ .../internal/server/websocket_routes.go | 5 + .../internal/server/websocket_routes_test.go | 8 +- .../internal/session/command_queue.go | 116 + .../agent-gateway/internal/session/manager.go | 77 +- .../internal/session/manager_chat_runs.go | 858 ++----- .../internal/session/manager_history_sync.go | 2 +- .../internal/session/manager_registry.go | 12 + .../internal/session/manager_state.go | 22 +- .../internal/session/manager_test.go | 23 +- .../session/sqlite_chat_event_store.go | 709 ------ crates/agent-gateway/proto/v1/gateway.proto | 20 + .../test/session/chat_event_store_test.go | 557 ----- .../test/session/manager_test.go | 419 ++-- .../agent-gateway/web/src/app/GatewayApp.tsx | 64 +- .../web/src/components/GatewayTranscript.tsx | 8 +- crates/agent-gateway/web/src/lib/chatUi.ts | 35 + .../web/src/lib/gatewaySocket.ts | 2180 +---------------- .../web/src/lib/gatewaySocket.worker.ts | 1277 ---------- .../src-tauri/src/services/gateway.rs | 482 ++-- 31 files changed, 2166 insertions(+), 6737 deletions(-) create mode 100644 crates/agent-gateway/internal/server/websocket_chat_handlers.go create mode 100644 crates/agent-gateway/internal/session/command_queue.go delete mode 100644 crates/agent-gateway/internal/session/sqlite_chat_event_store.go delete mode 100644 crates/agent-gateway/test/session/chat_event_store_test.go delete mode 100644 crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts diff --git a/crates/agent-gateway/cmd/gateway/main.go b/crates/agent-gateway/cmd/gateway/main.go index 9f0dbb3d..05308165 100644 --- a/crates/agent-gateway/cmd/gateway/main.go +++ b/crates/agent-gateway/cmd/gateway/main.go @@ -26,19 +26,7 @@ const grpcShutdownTimeout = 3 * time.Second func main() { cfg := config.Load() - chatEventStore, err := session.OpenSQLiteChatEventStore(cfg.ChatEventStorePath) - if err != nil { - log.Fatalf("open chat event store: %v", err) - } - defer func() { - if err := chatEventStore.Close(); err != nil { - log.Printf("close chat event store: %v", err) - } - }() - sm, err := session.NewManagerWithChatEventStore(chatEventStore) - if err != nil { - log.Fatalf("initialize session manager: %v", err) - } + sm := session.NewManager() grpcServer, err := newGRPCServer(cfg, sm) if err != nil { diff --git a/crates/agent-gateway/go.mod b/crates/agent-gateway/go.mod index 4582db62..593aa80b 100644 --- a/crates/agent-gateway/go.mod +++ b/crates/agent-gateway/go.mod @@ -14,15 +14,7 @@ require ( ) require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.27.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect - modernc.org/libc v1.72.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.52.0 // indirect ) diff --git a/crates/agent-gateway/go.sum b/crates/agent-gateway/go.sum index 5e43de4a..01a03284 100644 --- a/crates/agent-gateway/go.sum +++ b/crates/agent-gateway/go.sum @@ -1,7 +1,5 @@ github.com/doyensec/safeurl v0.2.5 h1:kKu0JNQy0tJ8jkDyB5h6Aml9vWWniq+mpoa12EGLcOQ= github.com/doyensec/safeurl v0.2.5/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -16,12 +14,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= -github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5ick= github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= @@ -40,9 +32,6 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= @@ -55,11 +44,3 @@ google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= -modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index 28ab0252..e3d1c225 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -24,7 +24,8 @@ type Config struct { WebSocketWriteTimeout time.Duration WebSocketWriteQueueSize int GRPCMaxMessageBytes int - ChatEventStorePath string + RelayBufferSeconds int + CommandQueueTimeout time.Duration } func Load() *Config { @@ -43,13 +44,13 @@ func Load() *Config { flag.DurationVar(&cfg.WebSocketWriteTimeout, "websocket-write-timeout", getenvDuration("LIVEAGENT_GATEWAY_WS_WRITE_TIMEOUT", 10*time.Second), "write timeout for browser WebSocket connections") flag.IntVar(&cfg.WebSocketWriteQueueSize, "websocket-write-queue-size", getenvInt("LIVEAGENT_GATEWAY_WS_WRITE_QUEUE_SIZE", 512), "write queue buffer size for browser WebSocket connections") flag.IntVar(&cfg.GRPCMaxMessageBytes, "grpc-max-message-bytes", getenvInt("LIVEAGENT_GATEWAY_GRPC_MAX_MESSAGE_BYTES", DefaultGRPCMaxMessageBytes), "maximum gRPC message size in bytes") - flag.StringVar(&cfg.ChatEventStorePath, "chat-event-store", getenv("LIVEAGENT_GATEWAY_CHAT_EVENT_STORE", defaultChatEventStorePath()), "SQLite path for durable chat command/event replay state") + flag.IntVar(&cfg.RelayBufferSeconds, "relay-buffer-seconds", getenvInt("LIVEAGENT_GATEWAY_RELAY_BUFFER_SECONDS", 30), "seconds of chat events to buffer for brief reconnections") + flag.DurationVar(&cfg.CommandQueueTimeout, "command-queue-timeout", getenvDuration("LIVEAGENT_GATEWAY_COMMAND_QUEUE_TIMEOUT", 30*time.Second), "timeout for queuing commands when agent is temporarily offline") flag.Parse() cfg.Token = strings.TrimSpace(cfg.Token) cfg.TLSCert = strings.TrimSpace(cfg.TLSCert) cfg.TLSKey = strings.TrimSpace(cfg.TLSKey) - cfg.ChatEventStorePath = strings.TrimSpace(cfg.ChatEventStorePath) if cfg.Token == "" { flag.Usage() @@ -73,18 +74,16 @@ func Load() *Config { if cfg.WebSocketWriteQueueSize <= 0 { cfg.WebSocketWriteQueueSize = 512 } + if cfg.RelayBufferSeconds <= 0 { + cfg.RelayBufferSeconds = 30 + } + if cfg.CommandQueueTimeout <= 0 { + cfg.CommandQueueTimeout = 30 * time.Second + } return cfg } -func defaultChatEventStorePath() string { - configDir, err := os.UserConfigDir() - if err != nil || strings.TrimSpace(configDir) == "" { - return "liveagent-gateway-chat.sqlite3" - } - return configDir + string(os.PathSeparator) + "LiveAgent" + string(os.PathSeparator) + "gateway-chat.sqlite3" -} - func getenv(key, fallback string) string { if value := os.Getenv(key); value != "" { return value diff --git a/crates/agent-gateway/internal/config/config_test.go b/crates/agent-gateway/internal/config/config_test.go index ee0c04c0..57a0616e 100644 --- a/crates/agent-gateway/internal/config/config_test.go +++ b/crates/agent-gateway/internal/config/config_test.go @@ -11,8 +11,6 @@ func TestLoadNormalizesTokenAndTLSPaths(t *testing.T) { t.Setenv("LIVEAGENT_GATEWAY_TOKEN", " secret-token\r\n") t.Setenv("LIVEAGENT_GATEWAY_TLS_CERT", " cert.pem ") t.Setenv("LIVEAGENT_GATEWAY_TLS_KEY", "\tkey.pem\r\n") - t.Setenv("LIVEAGENT_GATEWAY_CHAT_EVENT_STORE", " /tmp/liveagent/gateway-chat.sqlite3 ") - resetFlagsForTest(t) cfg := Load() if cfg.Token != "secret-token" { @@ -24,9 +22,6 @@ func TestLoadNormalizesTokenAndTLSPaths(t *testing.T) { if cfg.TLSKey != "key.pem" { t.Fatalf("TLSKey = %q, want %q", cfg.TLSKey, "key.pem") } - if cfg.ChatEventStorePath != "/tmp/liveagent/gateway-chat.sqlite3" { - t.Fatalf("ChatEventStorePath = %q, want trimmed path", cfg.ChatEventStorePath) - } } func TestLoadUsesRailwayPortForHTTPDefault(t *testing.T) { diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 2b566cc0..ff61e307 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -338,6 +338,7 @@ type GatewayEnvelope struct { // *GatewayEnvelope_TunnelFrame // *GatewayEnvelope_SettingsResetSshKnownHost // *GatewayEnvelope_ChatQueue + // *GatewayEnvelope_ChatEventReplay Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -772,6 +773,15 @@ func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { return nil } +func (x *GatewayEnvelope) GetChatEventReplay() *ChatEventReplayRequest { + if x != nil { + if x, ok := x.Payload.(*GatewayEnvelope_ChatEventReplay); ok { + return x.ChatEventReplay + } + } + return nil +} + type isGatewayEnvelope_Payload interface { isGatewayEnvelope_Payload() } @@ -944,6 +954,10 @@ type GatewayEnvelope_ChatQueue struct { ChatQueue *ChatQueueRequest `protobuf:"bytes,73,opt,name=chat_queue,json=chatQueue,proto3,oneof"` } +type GatewayEnvelope_ChatEventReplay struct { + ChatEventReplay *ChatEventReplayRequest `protobuf:"bytes,74,opt,name=chat_event_replay,json=chatEventReplay,proto3,oneof"` +} + func (*GatewayEnvelope_ChatCommand) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_CronManage) isGatewayEnvelope_Payload() {} @@ -1028,6 +1042,8 @@ func (*GatewayEnvelope_SettingsResetSshKnownHost) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_ChatQueue) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_ChatEventReplay) isGatewayEnvelope_Payload() {} + type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -1084,6 +1100,7 @@ type AgentEnvelope struct { // *AgentEnvelope_RuntimeStatus // *AgentEnvelope_SettingsResetSshKnownHostResp // *AgentEnvelope_ChatRuntimeSnapshot + // *AgentEnvelope_ChatEventReplayResp // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1591,6 +1608,15 @@ func (x *AgentEnvelope) GetChatRuntimeSnapshot() *ChatRuntimeSnapshot { return nil } +func (x *AgentEnvelope) GetChatEventReplayResp() *ChatEventReplayResponse { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_ChatEventReplayResp); ok { + return x.ChatEventReplayResp + } + } + return nil +} + func (x *AgentEnvelope) GetError() *ErrorResponse { if x != nil { if x, ok := x.Payload.(*AgentEnvelope_Error); ok { @@ -1804,6 +1830,10 @@ type AgentEnvelope_ChatRuntimeSnapshot struct { ChatRuntimeSnapshot *ChatRuntimeSnapshot `protobuf:"bytes,77,opt,name=chat_runtime_snapshot,json=chatRuntimeSnapshot,proto3,oneof"` } +type AgentEnvelope_ChatEventReplayResp struct { + ChatEventReplayResp *ChatEventReplayResponse `protobuf:"bytes,78,opt,name=chat_event_replay_resp,json=chatEventReplayResp,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1908,6 +1938,8 @@ func (*AgentEnvelope_SettingsResetSshKnownHostResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_ChatRuntimeSnapshot) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_ChatEventReplayResp) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -5718,6 +5750,186 @@ func (x *RuntimeStatusEvent) GetTimestamp() int64 { return 0 } +type ChatEventReplayRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + AfterSeq int64 `protobuf:"varint,3,opt,name=after_seq,json=afterSeq,proto3" json:"after_seq,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatEventReplayRequest) Reset() { + *x = ChatEventReplayRequest{} + mi := &file_proto_v1_gateway_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatEventReplayRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatEventReplayRequest) ProtoMessage() {} + +func (x *ChatEventReplayRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatEventReplayRequest.ProtoReflect.Descriptor instead. +func (*ChatEventReplayRequest) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} +} + +func (x *ChatEventReplayRequest) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatEventReplayRequest) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatEventReplayRequest) GetAfterSeq() int64 { + if x != nil { + return x.AfterSeq + } + return 0 +} + +type ChatEventReplayResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` + ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` + Events []*ChatReplayEvent `protobuf:"bytes,3,rep,name=events,proto3" json:"events,omitempty"` + Complete bool `protobuf:"varint,4,opt,name=complete,proto3" json:"complete,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatEventReplayResponse) Reset() { + *x = ChatEventReplayResponse{} + mi := &file_proto_v1_gateway_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatEventReplayResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatEventReplayResponse) ProtoMessage() {} + +func (x *ChatEventReplayResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatEventReplayResponse.ProtoReflect.Descriptor instead. +func (*ChatEventReplayResponse) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} +} + +func (x *ChatEventReplayResponse) GetRunId() string { + if x != nil { + return x.RunId + } + return "" +} + +func (x *ChatEventReplayResponse) GetConversationId() string { + if x != nil { + return x.ConversationId + } + return "" +} + +func (x *ChatEventReplayResponse) GetEvents() []*ChatReplayEvent { + if x != nil { + return x.Events + } + return nil +} + +func (x *ChatEventReplayResponse) GetComplete() bool { + if x != nil { + return x.Complete + } + return false +} + +type ChatReplayEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Seq int64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` + EventJson string `protobuf:"bytes,2,opt,name=event_json,json=eventJson,proto3" json:"event_json,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatReplayEvent) Reset() { + *x = ChatReplayEvent{} + mi := &file_proto_v1_gateway_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatReplayEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatReplayEvent) ProtoMessage() {} + +func (x *ChatReplayEvent) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ChatReplayEvent.ProtoReflect.Descriptor instead. +func (*ChatReplayEvent) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} +} + +func (x *ChatReplayEvent) GetSeq() int64 { + if x != nil { + return x.Seq + } + return 0 +} + +func (x *ChatReplayEvent) GetEventJson() string { + if x != nil { + return x.EventJson + } + return "" +} + type CronManageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` @@ -5729,7 +5941,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5741,7 +5953,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5754,7 +5966,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} } func (x *CronManageRequest) GetAction() string { @@ -5788,7 +6000,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5800,7 +6012,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5813,7 +6025,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} } func (x *CronManageResponse) GetAction() string { @@ -5842,7 +6054,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5854,7 +6066,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5867,7 +6079,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} } func (x *HistoryListRequest) GetPage() int32 { @@ -5908,7 +6120,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5920,7 +6132,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5933,7 +6145,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -5970,7 +6182,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5982,7 +6194,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5995,7 +6207,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} } func (x *ConversationSummary) GetId() string { @@ -6092,7 +6304,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6104,7 +6316,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6117,7 +6329,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *HistoryGetRequest) GetConversationId() string { @@ -6148,7 +6360,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6160,7 +6372,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6173,7 +6385,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } func (x *HistoryGetResponse) GetConversationId() string { @@ -6229,7 +6441,7 @@ type HistoryPrefixRequest struct { func (x *HistoryPrefixRequest) Reset() { *x = HistoryPrefixRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6241,7 +6453,7 @@ func (x *HistoryPrefixRequest) String() string { func (*HistoryPrefixRequest) ProtoMessage() {} func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6254,7 +6466,7 @@ func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixRequest.ProtoReflect.Descriptor instead. func (*HistoryPrefixRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *HistoryPrefixRequest) GetConversationId() string { @@ -6292,7 +6504,7 @@ type HistoryPrefixResponse struct { func (x *HistoryPrefixResponse) Reset() { *x = HistoryPrefixResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6304,7 +6516,7 @@ func (x *HistoryPrefixResponse) String() string { func (*HistoryPrefixResponse) ProtoMessage() {} func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6317,7 +6529,7 @@ func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixResponse.ProtoReflect.Descriptor instead. func (*HistoryPrefixResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } func (x *HistoryPrefixResponse) GetConversationId() string { @@ -6372,7 +6584,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6384,7 +6596,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6397,7 +6609,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -6423,7 +6635,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6435,7 +6647,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6448,7 +6660,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -6468,7 +6680,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6480,7 +6692,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6493,7 +6705,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } func (x *HistoryPinRequest) GetConversationId() string { @@ -6519,7 +6731,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6531,7 +6743,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6544,7 +6756,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -6568,7 +6780,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6580,7 +6792,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6593,7 +6805,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } func (x *HistoryShareStatus) GetConversationId() string { @@ -6647,7 +6859,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6659,7 +6871,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6672,7 +6884,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -6691,7 +6903,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6703,7 +6915,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6716,7 +6928,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -6737,7 +6949,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6749,7 +6961,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6762,7 +6974,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -6795,7 +7007,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6807,7 +7019,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6820,7 +7032,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -6839,7 +7051,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6851,7 +7063,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6864,7 +7076,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -6887,7 +7099,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6899,7 +7111,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6912,7 +7124,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -6958,7 +7170,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6970,7 +7182,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6983,7 +7195,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } type HistoryWorkdirSummary struct { @@ -6997,7 +7209,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7009,7 +7221,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7022,7 +7234,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -7055,7 +7267,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7067,7 +7279,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7080,7 +7292,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -7099,7 +7311,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7111,7 +7323,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7124,7 +7336,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -7142,7 +7354,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7154,7 +7366,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7167,7 +7379,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } type HistorySyncEvent struct { @@ -7181,7 +7393,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7193,7 +7405,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7206,7 +7418,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } func (x *HistorySyncEvent) GetKind() string { @@ -7238,7 +7450,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7250,7 +7462,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7263,7 +7475,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } type ProviderListResponse struct { @@ -7275,7 +7487,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7287,7 +7499,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7300,7 +7512,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -7318,7 +7530,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7330,7 +7542,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7343,7 +7555,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } type SettingsGetResponse struct { @@ -7355,7 +7567,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7367,7 +7579,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7380,7 +7592,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -7399,7 +7611,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7411,7 +7623,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7424,7 +7636,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -7444,7 +7656,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7456,7 +7668,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7469,7 +7681,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -7496,7 +7708,7 @@ type SettingsResetSshKnownHostRequest struct { func (x *SettingsResetSshKnownHostRequest) Reset() { *x = SettingsResetSshKnownHostRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7508,7 +7720,7 @@ func (x *SettingsResetSshKnownHostRequest) String() string { func (*SettingsResetSshKnownHostRequest) ProtoMessage() {} func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7521,7 +7733,7 @@ func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsResetSshKnownHostRequest.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *SettingsResetSshKnownHostRequest) GetHost() string { @@ -7547,7 +7759,7 @@ type SettingsResetSshKnownHostResponse struct { func (x *SettingsResetSshKnownHostResponse) Reset() { *x = SettingsResetSshKnownHostResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7559,7 +7771,7 @@ func (x *SettingsResetSshKnownHostResponse) String() string { func (*SettingsResetSshKnownHostResponse) ProtoMessage() {} func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7572,7 +7784,7 @@ func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SettingsResetSshKnownHostResponse.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } func (x *SettingsResetSshKnownHostResponse) GetDeleted() uint32 { @@ -7591,7 +7803,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7603,7 +7815,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7616,7 +7828,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -7634,7 +7846,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7646,7 +7858,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7659,7 +7871,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } type SkillFilesListResponse struct { @@ -7673,7 +7885,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7685,7 +7897,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7698,7 +7910,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -7731,7 +7943,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7743,7 +7955,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7756,7 +7968,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -7776,7 +7988,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7788,7 +8000,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7801,7 +8013,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *SkillMetadataReadResponse) GetName() string { @@ -7829,7 +8041,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7841,7 +8053,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7854,7 +8066,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *SkillTextReadRequest) GetPath() string { @@ -7888,7 +8100,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7900,7 +8112,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7913,7 +8125,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } func (x *SkillTextReadResponse) GetContent() string { @@ -7939,7 +8151,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7951,7 +8163,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7964,7 +8176,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -7983,7 +8195,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7995,7 +8207,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8008,7 +8220,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *SkillManageResponse) GetResultJson() string { @@ -8029,7 +8241,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8041,7 +8253,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8054,7 +8266,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -8088,7 +8300,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8100,7 +8312,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8113,7 +8325,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *FileMentionEntry) GetPath() string { @@ -8140,7 +8352,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8152,7 +8364,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8165,7 +8377,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -8194,7 +8406,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8206,7 +8418,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8219,7 +8431,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *FsRoot) GetId() string { @@ -8258,7 +8470,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8270,7 +8482,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8283,7 +8495,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } type FsRootsResponse struct { @@ -8295,7 +8507,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8307,7 +8519,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8320,7 +8532,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -8340,7 +8552,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8352,7 +8564,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8365,7 +8577,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} } func (x *FsListDirsRequest) GetPath() string { @@ -8392,7 +8604,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8404,7 +8616,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8417,7 +8629,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} } func (x *FsDirEntry) GetPath() string { @@ -8445,7 +8657,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8457,7 +8669,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8470,7 +8682,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} } func (x *FsListDirsResponse) GetPath() string { @@ -8504,7 +8716,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8516,7 +8728,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8529,7 +8741,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -8555,7 +8767,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8567,7 +8779,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8580,7 +8792,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -8603,7 +8815,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8615,7 +8827,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8628,7 +8840,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} } func (x *FsListRequest) GetWorkdir() string { @@ -8676,7 +8888,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8688,7 +8900,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8701,7 +8913,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} } func (x *FsListEntry) GetPath() string { @@ -8734,7 +8946,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8746,7 +8958,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8759,7 +8971,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} } func (x *FsListResponse) GetPath() string { @@ -8828,7 +9040,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8840,7 +9052,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8853,7 +9065,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -8884,7 +9096,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8896,7 +9108,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8909,7 +9121,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -8964,7 +9176,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8976,7 +9188,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8989,7 +9201,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -9020,7 +9232,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9032,7 +9244,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9045,7 +9257,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -9106,7 +9318,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9118,7 +9330,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9131,7 +9343,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -9205,7 +9417,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9217,7 +9429,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9230,7 +9442,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} } func (x *FsWriteTextResponse) GetPath() string { @@ -9292,7 +9504,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9304,7 +9516,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9317,7 +9529,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -9344,7 +9556,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9356,7 +9568,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9369,7 +9581,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} } func (x *FsCreateDirResponse) GetPath() string { @@ -9397,7 +9609,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9409,7 +9621,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9422,7 +9634,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} } func (x *FsRenameRequest) GetWorkdir() string { @@ -9457,7 +9669,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9469,7 +9681,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9482,7 +9694,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} } func (x *FsRenameResponse) GetFromPath() string { @@ -9516,7 +9728,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9528,7 +9740,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9541,7 +9753,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -9568,7 +9780,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9580,7 +9792,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9593,7 +9805,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} } func (x *FsDeleteResponse) GetPath() string { @@ -9619,7 +9831,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9631,7 +9843,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9644,7 +9856,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} } func (x *PingRequest) GetTimestamp() int64 { @@ -9663,7 +9875,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9675,7 +9887,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9688,7 +9900,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} } func (x *PongResponse) GetTimestamp() int64 { @@ -9708,7 +9920,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9720,7 +9932,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9733,7 +9945,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} } func (x *ErrorResponse) GetCode() int32 { @@ -9763,7 +9975,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\xc7\x1c\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\xa3\x1d\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -9816,8 +10028,9 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12z\n" + "\x1dsettings_reset_ssh_known_host\x18H \x01(\v26.liveagent.gateway.v1.SettingsResetSshKnownHostRequestH\x00R\x19settingsResetSshKnownHost\x12G\n" + "\n" + - "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueueB\t\n" + - "\apayload\"\xd4$\n" + + "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueue\x12Z\n" + + "\x11chat_event_replay\x18J \x01(\v2,.liveagent.gateway.v1.ChatEventReplayRequestH\x00R\x0fchatEventReplayB\t\n" + + "\apayload\"\xba%\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -9875,7 +10088,8 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\fchat_control\x18F \x01(\v2&.liveagent.gateway.v1.ChatControlEventH\x00R\vchatControl\x12Q\n" + "\x0eruntime_status\x18G \x01(\v2(.liveagent.gateway.v1.RuntimeStatusEventH\x00R\rruntimeStatus\x12\x84\x01\n" + "\"settings_reset_ssh_known_host_resp\x18H \x01(\v27.liveagent.gateway.v1.SettingsResetSshKnownHostResponseH\x00R\x1dsettingsResetSshKnownHostResp\x12_\n" + - "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12;\n" + + "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12d\n" + + "\x16chat_event_replay_resp\x18N \x01(\v2-.liveagent.gateway.v1.ChatEventReplayResponseH\x00R\x13chatEventReplayResp\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + "\apayload\"|\n" + "\x11ChatSelectedModel\x12,\n" + @@ -10274,7 +10488,20 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x18\n" + "\avisible\x18\x03 \x01(\bR\avisible\x12(\n" + "\x10active_run_count\x18\x04 \x01(\rR\x0eactiveRunCount\x12\x1c\n" + - "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"a\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"u\n" + + "\x16ChatEventReplayRequest\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12\x1b\n" + + "\tafter_seq\x18\x03 \x01(\x03R\bafterSeq\"\xb4\x01\n" + + "\x17ChatEventReplayResponse\x12\x15\n" + + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12=\n" + + "\x06events\x18\x03 \x03(\v2%.liveagent.gateway.v1.ChatReplayEventR\x06events\x12\x1a\n" + + "\bcomplete\x18\x04 \x01(\bR\bcomplete\"B\n" + + "\x0fChatReplayEvent\x12\x10\n" + + "\x03seq\x18\x01 \x01(\x03R\x03seq\x12\x1d\n" + + "\n" + + "event_json\x18\x02 \x01(\tR\teventJson\"a\n" + "\x11CronManageRequest\x12\x16\n" + "\x06action\x18\x01 \x01(\tR\x06action\x12\x17\n" + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x1b\n" + @@ -10582,7 +10809,7 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { } var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 120) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 123) var file_proto_v1_gateway_proto_goTypes = []any{ (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind (ChatEvent_ChatEventType)(0), // 1: liveagent.gateway.v1.ChatEvent.ChatEventType @@ -10634,227 +10861,233 @@ var file_proto_v1_gateway_proto_goTypes = []any{ (*ChatControlEvent)(nil), // 47: liveagent.gateway.v1.ChatControlEvent (*ChatRuntimeSnapshot)(nil), // 48: liveagent.gateway.v1.ChatRuntimeSnapshot (*RuntimeStatusEvent)(nil), // 49: liveagent.gateway.v1.RuntimeStatusEvent - (*CronManageRequest)(nil), // 50: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 51: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 52: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 53: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 54: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 55: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 56: liveagent.gateway.v1.HistoryGetResponse - (*HistoryPrefixRequest)(nil), // 57: liveagent.gateway.v1.HistoryPrefixRequest - (*HistoryPrefixResponse)(nil), // 58: liveagent.gateway.v1.HistoryPrefixResponse - (*HistoryRenameRequest)(nil), // 59: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 60: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 61: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 62: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 63: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 64: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 65: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 66: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 67: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 68: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 69: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 70: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 71: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 72: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 73: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 74: liveagent.gateway.v1.HistoryDeleteResponse - (*HistorySyncEvent)(nil), // 75: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 76: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 77: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 78: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 79: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 80: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 81: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsResetSshKnownHostRequest)(nil), // 82: liveagent.gateway.v1.SettingsResetSshKnownHostRequest - (*SettingsResetSshKnownHostResponse)(nil), // 83: liveagent.gateway.v1.SettingsResetSshKnownHostResponse - (*SettingsSyncEvent)(nil), // 84: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 85: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 86: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 87: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 88: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 89: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 90: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 91: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 92: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 93: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 94: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 95: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 96: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 97: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 98: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 99: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 100: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 101: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 102: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 103: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 104: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 105: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 106: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 107: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 108: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 109: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 110: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 111: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 112: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 113: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 114: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 115: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 116: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 117: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 118: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 119: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 120: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 121: liveagent.gateway.v1.ErrorResponse + (*ChatEventReplayRequest)(nil), // 50: liveagent.gateway.v1.ChatEventReplayRequest + (*ChatEventReplayResponse)(nil), // 51: liveagent.gateway.v1.ChatEventReplayResponse + (*ChatReplayEvent)(nil), // 52: liveagent.gateway.v1.ChatReplayEvent + (*CronManageRequest)(nil), // 53: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 54: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 55: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 56: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 57: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 58: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 59: liveagent.gateway.v1.HistoryGetResponse + (*HistoryPrefixRequest)(nil), // 60: liveagent.gateway.v1.HistoryPrefixRequest + (*HistoryPrefixResponse)(nil), // 61: liveagent.gateway.v1.HistoryPrefixResponse + (*HistoryRenameRequest)(nil), // 62: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 63: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 64: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 65: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 66: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 67: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 68: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 69: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 70: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 71: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 72: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 73: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 74: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 75: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 76: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 77: liveagent.gateway.v1.HistoryDeleteResponse + (*HistorySyncEvent)(nil), // 78: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 79: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 80: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 81: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 82: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 83: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 84: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsResetSshKnownHostRequest)(nil), // 85: liveagent.gateway.v1.SettingsResetSshKnownHostRequest + (*SettingsResetSshKnownHostResponse)(nil), // 86: liveagent.gateway.v1.SettingsResetSshKnownHostResponse + (*SettingsSyncEvent)(nil), // 87: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 88: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 89: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 90: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 91: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 92: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 93: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 94: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 95: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 96: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 97: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 98: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 99: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 100: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 101: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 102: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 103: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 104: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 105: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 106: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 107: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 108: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 109: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 110: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 111: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 112: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 113: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 114: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 115: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 116: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 117: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 118: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 119: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 120: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 121: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 122: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 123: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 124: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ 42, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest - 50, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 52, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 55, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 59, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 73, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 57, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest - 61, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 64, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 66, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 68, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 70, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 76, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 78, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 80, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 85, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 87, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 89, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 93, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 53, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 55, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 58, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 62, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 76, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 60, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest + 64, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 67, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 69, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 71, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 73, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 79, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 81, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 83, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 88, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 90, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 92, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 96, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest 10, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 97, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 99, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 119, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 100, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 102, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 122, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest 12, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest 20, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 91, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 102, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 94, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 105, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest 22, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 104, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 111, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 113, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 115, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 117, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 107, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 114, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 116, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 118, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 120, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest 37, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 107, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 109, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 110, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 112, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest 25, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest 14, // 37: liveagent.gateway.v1.GatewayEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest 15, // 38: liveagent.gateway.v1.GatewayEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse 19, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 82, // 40: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest + 85, // 40: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest 43, // 41: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest - 46, // 42: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 51, // 43: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 53, // 44: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 56, // 45: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 60, // 46: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 74, // 47: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 75, // 48: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 58, // 49: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse - 62, // 50: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 65, // 51: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 67, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 69, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 72, // 54: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 77, // 55: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 79, // 56: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 81, // 57: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 84, // 58: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 86, // 59: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 88, // 60: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 90, // 61: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 95, // 62: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 11, // 63: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 98, // 64: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 120, // 65: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 101, // 66: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 13, // 67: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 21, // 68: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 92, // 69: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 103, // 70: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 34, // 71: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 35, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 106, // 73: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 112, // 74: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 114, // 75: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 116, // 76: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 118, // 77: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 38, // 78: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 108, // 79: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 110, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 28, // 81: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse - 29, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent - 44, // 83: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse - 45, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent - 14, // 85: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest - 15, // 86: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse - 19, // 87: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 47, // 88: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent - 49, // 89: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent - 83, // 90: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse - 48, // 91: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot - 121, // 92: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 9, // 93: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 8, // 94: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 95: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary - 16, // 96: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary - 17, // 97: liveagent.gateway.v1.TunnelSummary.diagnostics:type_name -> liveagent.gateway.v1.TunnelDiagnostic - 0, // 98: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind - 18, // 99: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader - 24, // 100: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata - 26, // 101: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry - 26, // 102: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry - 27, // 103: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 27, // 104: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 32, // 105: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab - 23, // 106: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 23, // 107: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 31, // 108: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 30, // 109: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt - 33, // 110: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 23, // 111: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 33, // 112: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 23, // 113: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession - 6, // 114: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 8, // 115: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 7, // 116: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 39, // 117: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest - 40, // 118: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 41, // 119: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest - 1, // 120: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 54, // 121: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 54, // 122: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 40, // 123: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 54, // 124: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 54, // 125: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 54, // 126: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 63, // 127: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 63, // 128: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 54, // 129: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 71, // 130: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 54, // 131: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 94, // 132: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 96, // 133: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 100, // 134: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 105, // 135: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 5, // 136: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 36, // 137: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame - 2, // 138: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 4, // 139: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 36, // 140: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame - 3, // 141: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 139, // [139:142] is the sub-list for method output_type - 136, // [136:139] is the sub-list for method input_type - 136, // [136:136] is the sub-list for extension type_name - 136, // [136:136] is the sub-list for extension extendee - 0, // [0:136] is the sub-list for field type_name + 50, // 42: liveagent.gateway.v1.GatewayEnvelope.chat_event_replay:type_name -> liveagent.gateway.v1.ChatEventReplayRequest + 46, // 43: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 54, // 44: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 56, // 45: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 59, // 46: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 63, // 47: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 77, // 48: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 78, // 49: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 61, // 50: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse + 65, // 51: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 68, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 70, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 72, // 54: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 75, // 55: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 80, // 56: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 82, // 57: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 84, // 58: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 87, // 59: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 89, // 60: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 91, // 61: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 93, // 62: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 98, // 63: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 11, // 64: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 101, // 65: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 123, // 66: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 104, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 13, // 68: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 21, // 69: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 95, // 70: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 106, // 71: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 34, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 35, // 73: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 109, // 74: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 115, // 75: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 117, // 76: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 119, // 77: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 121, // 78: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 38, // 79: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 111, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 113, // 81: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 28, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse + 29, // 83: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent + 44, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse + 45, // 85: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent + 14, // 86: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest + 15, // 87: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse + 19, // 88: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 47, // 89: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent + 49, // 90: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent + 86, // 91: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse + 48, // 92: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot + 51, // 93: liveagent.gateway.v1.AgentEnvelope.chat_event_replay_resp:type_name -> liveagent.gateway.v1.ChatEventReplayResponse + 124, // 94: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 9, // 95: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 8, // 96: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 16, // 97: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary + 16, // 98: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary + 17, // 99: liveagent.gateway.v1.TunnelSummary.diagnostics:type_name -> liveagent.gateway.v1.TunnelDiagnostic + 0, // 100: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 18, // 101: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 24, // 102: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata + 26, // 103: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry + 26, // 104: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry + 27, // 105: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 27, // 106: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 32, // 107: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab + 23, // 108: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 23, // 109: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 31, // 110: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 30, // 111: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt + 33, // 112: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 23, // 113: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 33, // 114: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 23, // 115: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession + 6, // 116: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 8, // 117: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 7, // 118: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 39, // 119: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest + 40, // 120: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 41, // 121: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest + 1, // 122: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 52, // 123: liveagent.gateway.v1.ChatEventReplayResponse.events:type_name -> liveagent.gateway.v1.ChatReplayEvent + 57, // 124: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 57, // 125: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 40, // 126: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 57, // 127: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 57, // 128: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 57, // 129: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 66, // 130: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 66, // 131: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 57, // 132: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 74, // 133: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 57, // 134: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 97, // 135: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 99, // 136: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 103, // 137: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 108, // 138: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 5, // 139: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 36, // 140: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame + 2, // 141: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 4, // 142: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 36, // 143: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame + 3, // 144: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 142, // [142:145] is the sub-list for method output_type + 139, // [139:142] is the sub-list for method input_type + 139, // [139:139] is the sub-list for extension type_name + 139, // [139:139] is the sub-list for extension extendee + 0, // [0:139] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -10905,6 +11138,7 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_TunnelFrame)(nil), (*GatewayEnvelope_SettingsResetSshKnownHost)(nil), (*GatewayEnvelope_ChatQueue)(nil), + (*GatewayEnvelope_ChatEventReplay)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -10957,16 +11191,17 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_RuntimeStatus)(nil), (*AgentEnvelope_SettingsResetSshKnownHostResp)(nil), (*AgentEnvelope_ChatRuntimeSnapshot)(nil), + (*AgentEnvelope_ChatEventReplayResp)(nil), (*AgentEnvelope_Error)(nil), } - file_proto_v1_gateway_proto_msgTypes[64].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[67].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), NumEnums: 2, - NumMessages: 120, + NumMessages: 123, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/chat_commands.go b/crates/agent-gateway/internal/server/chat_commands.go index e6a82200..c02f9418 100644 --- a/crates/agent-gateway/internal/server/chat_commands.go +++ b/crates/agent-gateway/internal/server/chat_commands.go @@ -103,7 +103,6 @@ func startAcceptedChatCommand( snapshot, created, acceptedSeq, err := sm.StartAcceptedChatCommandRun( requestID, body.ConversationID, - body.ClientRequestID, body.Workdir, initialPayloads, ) diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index f992cd4d..a42bc2fc 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -42,7 +42,6 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { apiMux.HandleFunc("GET /api/status", handler.Status(sm)) apiMux.HandleFunc("POST /api/files/import", handler.ImportReadableFiles(sm, cfg.RequestTimeout)) apiMux.HandleFunc("POST /api/chat/commands", chatCommandsHTTP(cfg, sm, chatLimiter)) - apiMux.HandleFunc("GET /api/chat/events", chatEventsHTTP(cfg, sm, chatLimiter)) rootMux.Handle("/api/", auth.HTTPMiddleware(cfg.Token, apiMux)) webFS, err := fs.Sub(gateway.WebUIAssets, "web/dist") diff --git a/crates/agent-gateway/internal/server/http_chat.go b/crates/agent-gateway/internal/server/http_chat.go index 759bc3fd..65f4d630 100644 --- a/crates/agent-gateway/internal/server/http_chat.go +++ b/crates/agent-gateway/internal/server/http_chat.go @@ -2,9 +2,6 @@ package server import ( "context" - "encoding/json" - "errors" - "fmt" "io" "net" "net/http" @@ -21,7 +18,6 @@ import ( const maxChatCommandBytes = 2 * 1024 * 1024 const maxChatCommandsPerMinute = 120 -const maxChatEventStreamsPerMinute = 240 func chatCommandsHTTP(cfg *config.Config, sm *session.Manager, limiter *chatRateLimiter) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -165,126 +161,6 @@ func handleChatCancelCommandHTTP( writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "run_id": runID, "conversation_id": conversationID}) } -func chatEventsHTTP(_ *config.Config, sm *session.Manager, limiter *chatRateLimiter) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !originAllowed(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden origin"}) - return - } - if limiter != nil && !limiter.allow(chatRateLimitKey(r, "chat.events"), maxChatEventStreamsPerMinute, time.Minute, time.Now()) { - writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "chat event stream rate limit exceeded"}) - return - } - - requestID := strings.TrimSpace(r.URL.Query().Get("run_id")) - conversationID := strings.TrimSpace(r.URL.Query().Get("conversation_id")) - afterSeq := parseAfterSeq(r.URL.Query().Get("after_seq")) - if afterSeq <= 0 { - afterSeq = parseAfterSeq(r.Header.Get("Last-Event-ID")) - } - if requestID == "" && conversationID == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "run_id or conversation_id is required"}) - return - } - - eventCh, eventDone, cleanup, snapshot, err := sm.SubscribeChatRun(requestID, conversationID, afterSeq) - if err != nil { - status := http.StatusNotFound - if !errors.Is(err, session.ErrChatRunNotFound) { - status = http.StatusBadGateway - } - writeJSON(w, status, map[string]any{"error": websocketErrorMessage(err)}) - return - } - defer cleanup() - - w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") - flusher, _ := w.(http.Flusher) - if flusher != nil { - flusher.Flush() - } - - heartbeat := time.NewTicker(15 * time.Second) - defer heartbeat.Stop() - for { - select { - case <-r.Context().Done(): - return - case <-eventDone: - return - case <-heartbeat.C: - if _, err := io.WriteString(w, ": keep-alive\n\n"); err != nil { - return - } - if flusher != nil { - flusher.Flush() - } - case event, ok := <-eventCh: - if !ok { - return - } - terminal, err := writeChatSSE(w, flusher, snapshot.RequestID, event) - if err != nil { - return - } - if terminal && strings.TrimSpace(event.RequestID) == strings.TrimSpace(snapshot.RequestID) { - return - } - } - } - } -} - -func writeChatSSE( - w io.Writer, - flusher http.Flusher, - snapshotRunID string, - event *session.ChatBroadcastEvent, -) (bool, error) { - if event == nil { - return false, nil - } - payload, terminal := chatBroadcastPayload(event) - if payload == nil { - return false, nil - } - seq := event.Seq - runID := strings.TrimSpace(event.RequestID) - if runID == "" { - runID = strings.TrimSpace(snapshotRunID) - } - conversationID, _ := payload["conversation_id"].(string) - envelope := map[string]any{ - "specversion": "1.0", - "id": fmt.Sprintf("%s/%d", runID, seq), - "source": "liveagent.gateway.chat", - "type": cloudChatEventType(payload), - "time": time.Now().UTC().Format(time.RFC3339Nano), - "run_id": runID, - "snapshot_run_id": strings.TrimSpace(snapshotRunID), - "conversation_id": strings.TrimSpace(conversationID), - "seq": seq, - "payload": payload, - } - data, err := json.Marshal(envelope) - if err != nil { - return terminal, err - } - eventName := "chat.event" - if isChatControlPayload(payload) { - eventName = "chat.control" - } - if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", seq, eventName, data); err != nil { - return terminal, err - } - if flusher != nil { - flusher.Flush() - } - return terminal, nil -} func chatBroadcastPayload(event *session.ChatBroadcastEvent) (map[string]any, bool) { if len(event.Payload) > 0 { @@ -330,54 +206,6 @@ func isTerminalChatPayload(payload map[string]any) bool { } } -func cloudChatEventType(payload map[string]any) string { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "accepted": - return "run.accepted" - case "runtime_snapshot": - return "runtime.snapshot" - case "user_message": - return "user.message.appended" - case "rebased": - return "conversation.rebased" - case "projection_updated": - return "projection.updated" - case "delivered", "claimed", "starting", "progress": - return "context.preparing" - case "started": - return "runtime.started" - case "token": - return "assistant.delta" - case "thinking": - return "thinking.delta" - case "tool_call": - return "tool.call" - case "tool_call_delta": - return "tool.call.delta" - case "tool_result": - return "tool.result" - case "done", "completed": - return "run.completed" - case "error", "failed": - return "run.failed" - case "cancelled": - return "run.cancelled" - default: - return "chat.event" - } -} - -func isChatControlPayload(payload map[string]any) bool { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "accepted", "user_message", "rebased", "projection_updated", "delivered", "claimed", "starting", "queued_in_gui", "started", "progress", "completed", "failed", "cancelled": - return true - default: - return false - } -} - func cloneChatPayload(input map[string]any) map[string]any { if len(input) == 0 { return map[string]any{} diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index d2431bb2..513577a3 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -1,10 +1,7 @@ package server import ( - "bufio" - "context" "encoding/json" - "io" "net/http" "net/http/httptest" "strings" @@ -349,26 +346,6 @@ func TestChatCommandRejectsForeignOrigin(t *testing.T) { } } -func TestChatEventsRejectsForeignOrigin(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodGet, - "http://gateway.test/api/chat/events?run_id=run-1", - nil, - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Origin", "https://evil.example") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "origin") { - t.Fatalf("body = %q, want origin error", rec.Body.String()) - } -} func TestOriginAllowedRequiresStrictOriginForPublicHosts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://gateway.test:8080/api/chat/commands", nil) @@ -449,25 +426,6 @@ func TestChatCommandsRequireClientRequestID(t *testing.T) { } } -func TestChatEventsRejectLegacyRequestIDAlias(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodGet, - "http://gateway.test/api/chat/events?request_id=run-1", - nil, - ) - req.Header.Set("Authorization", "Bearer dev-token") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "run_id or conversation_id") { - t.Fatalf("body = %q, want run_id error", rec.Body.String()) - } -} func TestChatEventsAfterSeqParsingIsStrictNumeric(t *testing.T) { if got := parseAfterSeq("41"); got != 41 { @@ -621,13 +579,13 @@ func TestChatCancelCommandFindsRunByConversation(t *testing.T) { t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) } - ch, _, cleanup, _, err := sm.SubscribeChatRun("run-1", "conversation-1", startedSnapshot.LatestSeq) + cancelSub, err := sm.SubscribeChatRun("run-1", "conversation-1", startedSnapshot.LatestSeq) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer cancelSub.Cleanup() select { - case event := <-ch: + case event := <-cancelSub.EventCh: if event.Control == nil || event.Control.GetType() != "cancelled" { t.Fatalf("cancel event = %#v, want cancelled control", event) } @@ -822,36 +780,6 @@ func TestChatCommandSubmitAcceptsAndSSEReplaysControl(t *testing.T) { case <-time.After(time.Second): t.Fatal("timed out waiting for chat command outbound request") } - - eventsReq, err := http.NewRequest( - http.MethodGet, - ts.URL+"/api/chat/events?run_id="+accepted.RunID+"&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - eventsReq.Header.Set("Authorization", "Bearer dev-token") - eventsResp, err := ts.Client().Do(eventsReq) - if err != nil { - t.Fatalf("get chat events: %v", err) - } - defer eventsResp.Body.Close() - if eventsResp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", eventsResp.StatusCode, http.StatusOK) - } - - event := readChatSSEEvent(t, bufio.NewReader(eventsResp.Body)) - if event["type"] != "run.accepted" || event["run_id"] != accepted.RunID { - t.Fatalf("sse event = %#v", event) - } - payload, _ := event["payload"].(map[string]any) - if payload["type"] != "accepted" || payload["seq"] != float64(1) { - t.Fatalf("sse payload = %#v", payload) - } - if _, ok := payload["request_id"]; ok { - t.Fatalf("sse payload leaked legacy request_id: %#v", payload) - } } func TestChatCommandStartWatchdogFailsUndeliveredRun(t *testing.T) { @@ -917,14 +845,14 @@ func TestChatCommandStartWatchdogFailsUndeliveredRun(t *testing.T) { } } - ch, _, cleanup, _, err := sm.SubscribeChatRun(accepted.RunID, "conversation-1", 0) + wdSub, err := sm.SubscribeChatRun(accepted.RunID, "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer wdSub.Cleanup() for { select { - case event := <-ch: + case event := <-wdSub.EventCh: if event.Event != nil && event.Event.GetType() == gatewayv1.ChatEvent_ERROR { if !strings.Contains(event.Event.GetData(), "Desktop backend did not accept") { t.Fatalf("watchdog error data = %q", event.Event.GetData()) @@ -937,71 +865,6 @@ func TestChatCommandStartWatchdogFailsUndeliveredRun(t *testing.T) { } } -func TestChatCommandDedupesClientRequestID(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - postCommand := func() map[string]any { - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-1"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var decoded map[string]any - if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - return decoded - } - - first := postCommand() - firstRunID, _ := first["run_id"].(string) - if firstRunID == "" || first["deduped"] == true { - t.Fatalf("first response = %#v", first) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - if command.GetType() != "chat.submit" || command.GetRequest() == nil { - t.Fatalf("outbound payload = %#v, want chat.submit command", command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for first chat request") - } - - second := postCommand() - if second["run_id"] != firstRunID || second["deduped"] != true { - t.Fatalf("second response = %#v, first run_id %q", second, firstRunID) - } - select { - case outbound := <-agentSession.Outbound(): - t.Fatalf("unexpected duplicate outbound request %s payload %T", outbound.GetRequestId(), outbound.GetPayload()) - case <-time.After(100 * time.Millisecond): - } -} func TestChatCommandSeqContinuesAcrossConversationRuns(t *testing.T) { sm := session.NewManager() @@ -1073,138 +936,7 @@ func TestChatCommandSeqContinuesAcrossConversationRuns(t *testing.T) { } } -func TestChatEventsReplayConversationAcrossRuns(t *testing.T) { - sm := session.NewManager() - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-submit-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - - if _, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-submit-2"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, sm)) - defer ts.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - req, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - ts.URL+"/api/chat/events?conversation_id=conversation-1&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("get chat events: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", resp.StatusCode, http.StatusOK) - } - - reader := bufio.NewReader(resp.Body) - got := make([]string, 0, 4) - for len(got) < 4 { - event := readChatSSEEvent(t, reader) - payload, _ := event["payload"].(map[string]any) - eventType, _ := payload["type"].(string) - if event["snapshot_run_id"] != "request-2" { - t.Fatalf("snapshot_run_id = %#v, want request-2", event["snapshot_run_id"]) - } - got = append(got, event["run_id"].(string)+":"+eventType) - } - cancel() - - want := []string{ - "request-1:accepted", - "request-1:user_message", - "request-1:completed", - "request-2:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation SSE replay = %#v, want %#v", got, want) - } - } -} - -func TestChatEventsRuntimeSnapshotTerminatesSSE(t *testing.T) { - sm := session.NewManager() - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateRunning, - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"partial","round":1}]`, - }) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateCompleted, - Revision: 2, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"done","round":1}]`, - }) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodGet, - ts.URL+"/api/chat/events?conversation_id=conversation-1&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("get chat events: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", resp.StatusCode, http.StatusOK) - } - - reader := bufio.NewReader(resp.Body) - first := readChatSSEEvent(t, reader) - if first["type"] != "runtime.snapshot" || first["run_id"] != "run-1" { - t.Fatalf("first runtime snapshot event = %#v", first) - } - firstPayload, _ := first["payload"].(map[string]any) - if firstPayload["type"] != "runtime_snapshot" || firstPayload["state"] != "running" { - t.Fatalf("first runtime snapshot payload = %#v", firstPayload) - } - second := readChatSSEEvent(t, reader) - if second["type"] != "runtime.snapshot" || second["run_id"] != "run-1" { - t.Fatalf("terminal runtime snapshot event = %#v", second) - } - secondPayload, _ := second["payload"].(map[string]any) - if secondPayload["type"] != "runtime_snapshot" || secondPayload["state"] != "completed" { - t.Fatalf("terminal runtime snapshot payload = %#v", secondPayload) - } - - rest, err := io.ReadAll(reader) - if err != nil { - t.Fatalf("read rest of runtime snapshot stream: %v", err) - } - if strings.TrimSpace(string(rest)) != "" { - t.Fatalf("unexpected data after terminal runtime snapshot: %q", string(rest)) - } -} func TestChatEditResendSendsSingleChatCommand(t *testing.T) { sm := session.NewManager() @@ -1248,47 +980,6 @@ func TestChatEditResendSendsSingleChatCommand(t *testing.T) { t.Fatalf("accepted response missing run_id") } - eventsReq, err := http.NewRequest( - http.MethodGet, - ts.URL+"/api/chat/events?run_id="+accepted.RunID+"&after_seq=0", - nil, - ) - if err != nil { - t.Fatal(err) - } - eventsReq.Header.Set("Authorization", "Bearer dev-token") - eventsResp, err := ts.Client().Do(eventsReq) - if err != nil { - t.Fatalf("get chat edit events: %v", err) - } - defer eventsResp.Body.Close() - if eventsResp.StatusCode != http.StatusOK { - t.Fatalf("events status = %d, want %d", eventsResp.StatusCode, http.StatusOK) - } - eventsReader := bufio.NewReader(eventsResp.Body) - if event := readChatSSEEvent(t, eventsReader); event["type"] != "run.accepted" { - t.Fatalf("first edit event = %#v, want run.accepted", event) - } - rebaseEvent := readChatSSEEvent(t, eventsReader) - if rebaseEvent["type"] != "conversation.rebased" { - t.Fatalf("second edit event = %#v, want conversation.rebased", rebaseEvent) - } - rebasePayload, _ := rebaseEvent["payload"].(map[string]any) - if rebasePayload["type"] != "rebased" || rebasePayload["reason"] != "edit_resend" { - t.Fatalf("rebase payload = %#v", rebasePayload) - } - if _, ok := rebasePayload["request_id"]; ok { - t.Fatalf("rebase payload leaked legacy request_id: %#v", rebasePayload) - } - userMessageEvent := readChatSSEEvent(t, eventsReader) - if userMessageEvent["type"] != "user.message.appended" { - t.Fatalf("third edit event = %#v, want user.message.appended", userMessageEvent) - } - userMessagePayload, _ := userMessageEvent["payload"].(map[string]any) - if userMessagePayload["type"] != "user_message" || userMessagePayload["message"] != "edited" { - t.Fatalf("user message payload = %#v", userMessagePayload) - } - select { case outbound := <-agentSession.Outbound(): outbound.Ack(nil) @@ -1320,24 +1011,3 @@ func TestChatEditResendSendsSingleChatCommand(t *testing.T) { } } -func readChatSSEEvent(t *testing.T, reader *bufio.Reader) map[string]any { - t.Helper() - var dataLine string - for { - line, err := reader.ReadString('\n') - if err != nil { - t.Fatalf("read sse line: %v", err) - } - if strings.HasPrefix(line, "data:") { - dataLine = strings.TrimSpace(strings.TrimPrefix(line, "data:")) - } - if strings.TrimSpace(line) == "" && dataLine != "" { - break - } - } - var event map[string]any - if err := json.Unmarshal([]byte(dataLine), &event); err != nil { - t.Fatalf("decode sse data %q: %v", dataLine, err) - } - return event -} diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 403d0857..57aab902 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -125,6 +125,9 @@ type websocketConnection struct { heartbeatOnce sync.Once terminalInterest *websocketTerminalInterestTracker + + chatSubsMu sync.Mutex + chatSubs map[string]*chatSubscription } const maxHistoryListLimit = 200 @@ -235,6 +238,7 @@ func (c *websocketConnection) close() { c.chatQueueEventsCleanup() c.chatQueueEventsCleanup = nil } + c.cleanupChatSubscriptions() _ = c.conn.Close() }) } diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go new file mode 100644 index 00000000..97475179 --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -0,0 +1,343 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" +) + +type chatSubscription struct { + runID string + conversationID string + sub *session.ChatRunSubscribeResult + cancel context.CancelFunc +} + +func (c *websocketConnection) handleChatSubscribe(req websocketRequest) { + var payload struct { + RunID string `json:"run_id"` + ConversationID string `json:"conversation_id"` + AfterSeq int64 `json:"after_seq"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.subscribe payload") + return + } + payload.RunID = strings.TrimSpace(payload.RunID) + payload.ConversationID = strings.TrimSpace(payload.ConversationID) + if payload.RunID == "" && payload.ConversationID == "" { + _ = c.writeError(req.ID, "run_id or conversation_id is required") + return + } + + sub, err := c.sm.SubscribeChatRun(payload.RunID, payload.ConversationID, payload.AfterSeq) + if err != nil { + if errors.Is(err, session.ErrChatRunNotFound) { + _ = c.writeError(req.ID, "chat run not found") + } else { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + } + return + } + + subID := "chat-sub-" + uuid.NewString()[:8] + ctx, cancel := context.WithCancel(context.Background()) + + c.chatSubsMu.Lock() + if c.chatSubs == nil { + c.chatSubs = make(map[string]*chatSubscription) + } + c.chatSubs[subID] = &chatSubscription{ + runID: sub.Snapshot.RequestID, + conversationID: sub.Snapshot.ConversationID, + sub: sub, + cancel: cancel, + } + c.chatSubsMu.Unlock() + + resp := map[string]any{ + "subscription_id": subID, + "snapshot": map[string]any{ + "run_id": sub.Snapshot.RequestID, + "conversation_id": sub.Snapshot.ConversationID, + "state": sub.Snapshot.State, + "latest_seq": sub.Snapshot.LatestSeq, + }, + } + if sub.GapDetected { + resp["gap"] = true + resp["oldest_buffered_seq"] = sub.OldestSeq + } + _ = c.writeResponse(req.ID, resp) + + go c.forwardChatEvents(ctx, subID, sub) +} + +func (c *websocketConnection) forwardChatEvents(ctx context.Context, subID string, sub *session.ChatRunSubscribeResult) { + defer sub.Cleanup() + defer func() { + _ = c.writeEvent("chat.subscription_end", map[string]any{ + "subscription_id": subID, + }) + }() + for { + select { + case <-ctx.Done(): + return + case <-c.done: + return + case <-sub.Done: + c.drainChatSubscriptionEvents(subID, sub) + return + case event, ok := <-sub.EventCh: + if !ok { + return + } + payload, terminal := chatBroadcastPayload(event) + if payload == nil { + continue + } + payload["subscription_id"] = subID + payload["seq"] = event.Seq + payload["run_id"] = strings.TrimSpace(event.RequestID) + if err := c.writeEvent("chat.event", payload); err != nil { + return + } + if terminal { + return + } + } + } +} + +func (c *websocketConnection) handleChatUnsubscribe(req websocketRequest) { + var payload struct { + SubscriptionID string `json:"subscription_id"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.unsubscribe payload") + return + } + payload.SubscriptionID = strings.TrimSpace(payload.SubscriptionID) + + c.chatSubsMu.Lock() + if cs, ok := c.chatSubs[payload.SubscriptionID]; ok { + cs.cancel() + delete(c.chatSubs, payload.SubscriptionID) + } + c.chatSubsMu.Unlock() + + _ = c.writeResponse(req.ID, map[string]any{"ok": true}) +} + +func (c *websocketConnection) handleChatCommand(req websocketRequest) { + commandType, body, baseMessageRef, err := decodeChatCommandPayload(req.Payload) + if err != nil { + _ = c.writeError(req.ID, "invalid chat command payload") + return + } + + switch commandType { + case "chat.submit": + baseMessageRef = nil + case "chat.edit_resend": + if baseMessageRef == nil { + _ = c.writeError(req.ID, "base_message_ref is required") + return + } + if err := validateChatMessageRef(baseMessageRef); err != nil { + _ = c.writeError(req.ID, err.Error()) + return + } + case "chat.cancel": + c.handleChatCancel(req) + return + default: + _ = c.writeError(req.ID, "unsupported chat command") + return + } + + if err := normalizeChatRequestBody(&body); err != nil { + _ = c.writeError(req.ID, err.Error()) + return + } + + if !c.sm.IsOnline() { + _ = c.writeError(req.ID, "agent offline") + return + } + + requestID := "chat-command-" + uuid.NewString() + initialPayloads := buildAcceptedChatCommandPayloads(body, baseMessageRef) + start, err := startAcceptedChatCommand(c.sm, requestID, body, initialPayloads) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + _ = c.writeResponse(req.ID, map[string]any{ + "run_id": start.RunID, + "conversation_id": start.ConversationID, + "state": start.State, + "accepted_seq": start.AcceptedSeq, + "deduped": !start.Created, + }) + + if start.Created { + go dispatchAcceptedChatCommand(context.Background(), c.cfg, c.sm, start, body, baseMessageRef, newChatTraceID()) + } +} + +func (c *websocketConnection) handleChatCancel(req websocketRequest) { + var payload struct { + ConversationID string `json:"conversation_id"` + RunID string `json:"run_id"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.cancel payload") + return + } + payload.ConversationID = strings.TrimSpace(payload.ConversationID) + payload.RunID = strings.TrimSpace(payload.RunID) + if payload.ConversationID == "" { + _ = c.writeError(req.ID, "conversation_id is required") + return + } + if !c.sm.IsOnline() { + _ = c.writeError(req.ID, "agent offline") + return + } + + runID := payload.RunID + if runID == "" { + if snapshot, ok := c.sm.RunningChatRunSnapshot(payload.ConversationID); ok { + runID = strings.TrimSpace(snapshot.RequestID) + } + } + if runID == "" { + _ = c.writeResponse(req.ID, map[string]any{"ok": true, "run_id": "", "conversation_id": payload.ConversationID}) + return + } + + timeout := 10 * time.Second + if c.cfg != nil && c.cfg.WebSocketWriteTimeout > 0 { + timeout = c.cfg.WebSocketWriteTimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := c.sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: runID, + Timestamp: time.Now().Unix(), + Payload: buildChatCancelCommandPayload(payload.ConversationID), + }); err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + c.sm.MarkChatRunControl(runID, payload.ConversationID, "cancelled", "", "") + _ = c.writeResponse(req.ID, map[string]any{"ok": true, "run_id": runID, "conversation_id": payload.ConversationID}) +} + +func (c *websocketConnection) handleChatReplay(req websocketRequest) { + var payload struct { + RunID string `json:"run_id"` + ConversationID string `json:"conversation_id"` + AfterSeq int64 `json:"after_seq"` + } + if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + _ = c.writeError(req.ID, "invalid chat.replay payload") + return + } + payload.RunID = strings.TrimSpace(payload.RunID) + payload.ConversationID = strings.TrimSpace(payload.ConversationID) + if payload.ConversationID == "" { + _ = c.writeError(req.ID, "conversation_id is required") + return + } + + if !c.sm.IsOnline() { + _ = c.writeError(req.ID, "agent offline") + return + } + + replayRequestID := "chat-replay-" + uuid.NewString()[:8] + timeout := 30 * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + env, err := awaitAgentUnaryResponse(ctx, c.sm, replayRequestID, &gatewayv1.GatewayEnvelope{ + RequestId: replayRequestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_ChatEventReplay{ + ChatEventReplay: &gatewayv1.ChatEventReplayRequest{ + RunId: payload.RunID, + ConversationId: payload.ConversationID, + AfterSeq: payload.AfterSeq, + }, + }, + }) + if err != nil { + _ = c.writeError(req.ID, websocketErrorMessage(err)) + return + } + + replayResp := env.GetChatEventReplayResp() + if replayResp == nil { + _ = c.writeError(req.ID, "unexpected replay response") + return + } + + events := make([]map[string]any, 0, len(replayResp.GetEvents())) + for _, re := range replayResp.GetEvents() { + var parsed map[string]any + if err := json.Unmarshal([]byte(re.GetEventJson()), &parsed); err != nil { + continue + } + parsed["seq"] = re.GetSeq() + events = append(events, parsed) + } + + _ = c.writeResponse(req.ID, map[string]any{ + "run_id": replayResp.GetRunId(), + "conversation_id": replayResp.GetConversationId(), + "events": events, + "complete": replayResp.GetComplete(), + }) +} + +func (c *websocketConnection) drainChatSubscriptionEvents(subID string, sub *session.ChatRunSubscribeResult) { + for { + select { + case event, ok := <-sub.EventCh: + if !ok { + return + } + payload, _ := chatBroadcastPayload(event) + if payload == nil { + continue + } + payload["subscription_id"] = subID + payload["seq"] = event.Seq + payload["run_id"] = strings.TrimSpace(event.RequestID) + _ = c.writeEvent("chat.event", payload) + default: + return + } + } +} + +func (c *websocketConnection) cleanupChatSubscriptions() { + c.chatSubsMu.Lock() + for id, cs := range c.chatSubs { + cs.cancel() + delete(c.chatSubs, id) + } + c.chatSubsMu.Unlock() +} + diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 8f3908b3..1f2a961b 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -86,6 +86,11 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "git.push": (*websocketConnection).handleGitRequest, "cron.manage": (*websocketConnection).handleCronManage, "provider.models": (*websocketConnection).handleProviderModels, + "chat.subscribe": (*websocketConnection).handleChatSubscribe, + "chat.unsubscribe": (*websocketConnection).handleChatUnsubscribe, + "chat.command": (*websocketConnection).handleChatCommand, + "chat.cancel": (*websocketConnection).handleChatCancel, + "chat.replay": (*websocketConnection).handleChatReplay, "chat_queue.get": (*websocketConnection).handleChatQueueRequest, "chat_queue.get_item": (*websocketConnection).handleChatQueueRequest, "chat_queue.run_now": (*websocketConnection).handleChatQueueRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index d5be51ae..e2b62604 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -90,6 +90,11 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "git.push", "cron.manage", "provider.models", + "chat.subscribe", + "chat.unsubscribe", + "chat.command", + "chat.cancel", + "chat.replay", "chat_queue.get", "chat_queue.get_item", "chat_queue.run_now", @@ -115,14 +120,13 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "chat.resume", "chat.attach", "chat.detach", - "chat.cancel", "terminal.attach", "terminal.input", "terminal.resize", "terminal.detach", } { if websocketRequestHandlers[removedType] != nil { - t.Fatalf("websocketRequestHandlers[%q] should be removed; chat uses HTTP/SSE and terminal bytes use /ws/terminal", removedType) + t.Fatalf("websocketRequestHandlers[%q] should be removed; terminal bytes use /ws/terminal", removedType) } } } diff --git a/crates/agent-gateway/internal/session/command_queue.go b/crates/agent-gateway/internal/session/command_queue.go new file mode 100644 index 00000000..7fa80e03 --- /dev/null +++ b/crates/agent-gateway/internal/session/command_queue.go @@ -0,0 +1,116 @@ +package session + +import ( + "context" + "sync" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + defaultCommandQueueTimeout = 30 * time.Second + maxCommandQueueSize = 10 +) + +type pendingCommand struct { + envelope *gatewayv1.GatewayEnvelope + result chan error + deadline time.Time +} + +type commandQueue struct { + mu sync.Mutex + items []pendingCommand + timeout time.Duration +} + +func newCommandQueue(timeout time.Duration) *commandQueue { + if timeout <= 0 { + timeout = defaultCommandQueueTimeout + } + return &commandQueue{ + timeout: timeout, + } +} + +func (q *commandQueue) Enqueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + q.mu.Lock() + q.evictExpiredLocked() + if len(q.items) >= maxCommandQueueSize { + q.mu.Unlock() + return ErrCommandQueueFull + } + resultCh := make(chan error, 1) + q.items = append(q.items, pendingCommand{ + envelope: env, + result: resultCh, + deadline: time.Now().Add(q.timeout), + }) + q.mu.Unlock() + + select { + case err := <-resultCh: + return err + case <-ctx.Done(): + return ctx.Err() + case <-time.After(q.timeout): + return ErrCommandQueueTimeout + } +} + +func (q *commandQueue) DrainTo(session *AgentSession) { + q.mu.Lock() + items := q.items + q.items = nil + q.mu.Unlock() + + now := time.Now() + for _, cmd := range items { + if now.After(cmd.deadline) { + select { + case cmd.result <- ErrCommandQueueTimeout: + default: + } + continue + } + ctx, cancel := context.WithDeadline(context.Background(), cmd.deadline) + err := session.SendToAgentContext(ctx, cmd.envelope) + cancel() + select { + case cmd.result <- err: + default: + } + } +} + +func (q *commandQueue) FailAll(err error) { + q.mu.Lock() + items := q.items + q.items = nil + q.mu.Unlock() + + for _, cmd := range items { + select { + case cmd.result <- err: + default: + } + } +} + +func (q *commandQueue) evictExpiredLocked() { + now := time.Now() + n := 0 + for _, cmd := range q.items { + if now.After(cmd.deadline) { + select { + case cmd.result <- ErrCommandQueueTimeout: + default: + } + continue + } + q.items[n] = cmd + n++ + } + q.items = q.items[:n] +} diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index 403af891..51cf50b5 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -14,11 +14,13 @@ var ErrTunnelNotFound = errors.New("tunnel not found") var ErrTunnelExpired = errors.New("tunnel expired") var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") +var ErrCommandQueueFull = errors.New("command queue full") +var ErrCommandQueueTimeout = errors.New("command queue timeout: agent did not reconnect") const ( - maxBufferedChatRunEvents = 50000 - chatRunDoneRetention = time.Hour - chatRunStaleRetention = 12 * time.Hour + defaultRelayBufferRetention = 30 * time.Second + chatRunDoneRetention = 5 * time.Minute + chatRunStaleRetention = 30 * time.Minute chatRuntimeReadyTTL = 15 * time.Second agentSessionHeartbeatTTL = 90 * time.Second @@ -36,6 +38,7 @@ type Manager struct { syncHub *syncHub chatStore *chatRunStore tunnels *tunnelStore + cmdQueue *commandQueue } type AgentSession struct { @@ -62,25 +65,24 @@ type agentStream struct { } type ChatBroadcastEvent struct { - RequestID string - Event *gatewayv1.ChatEvent - Control *gatewayv1.ChatControlEvent - Payload map[string]any - Seq int64 - Workdir string + RequestID string + Event *gatewayv1.ChatEvent + Control *gatewayv1.ChatControlEvent + Payload map[string]any + Seq int64 + Workdir string + ReceivedAt time.Time } type ChatRunSnapshot struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - FirstSeq int64 - LatestSeq int64 - RunEpoch int64 - State string - ErrorCode string - Done bool + RequestID string + ConversationID string + Workdir string + FirstSeq int64 + LatestSeq int64 + RunEpoch int64 + State string + Done bool } type ActiveChatRunSummary struct { @@ -106,24 +108,19 @@ const ( ) type chatRun struct { - requestID string - conversationID string - clientRequestID string - workdir string - sessionEpoch uint64 - runEpoch int64 - events []*ChatBroadcastEvent - nextSeq int64 - state string - errorCode string - accepted bool - started bool - firstDeltaLogged bool - done bool - runtimeSnapshotRevision int64 - updatedAt time.Time - expiresAt time.Time - subscribers map[int]*chatRunSubscriber + requestID string + conversationID string + workdir string + runEpoch int64 + events []*ChatBroadcastEvent + nextSeq int64 + state string + done bool + updatedAt time.Time + expiresAt time.Time + subscribers map[int]*chatRunSubscriber + + relayBufferRetention time.Duration } type chatRunSubscriber struct { @@ -154,11 +151,7 @@ func NewManager() *Manager { syncHub: newSyncHub(), chatStore: newChatRunStore(), tunnels: newTunnelStore(), + cmdQueue: newCommandQueue(defaultCommandQueueTimeout), } } -func NewManagerWithChatEventStore(store ChatEventStore) (*Manager, error) { - manager := NewManager() - manager.chatStore.eventStore = store - return manager, nil -} diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go index c26b081d..134000ae 100644 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ b/crates/agent-gateway/internal/session/manager_chat_runs.go @@ -14,30 +14,31 @@ import ( func (m *Manager) StartPendingChatCommandRun( requestID string, conversationID string, - clientRequestID string, workdirInput ...string, ) (ChatRunSnapshot, bool, error) { - return m.startPendingChatCommandRun(requestID, conversationID, clientRequestID, workdirInput...) + workdir := "" + if len(workdirInput) > 0 { + workdir = workdirInput[0] + } + snapshot, created := m.createChatRun(requestID, conversationID, workdir) + if !created { + return snapshot, false, nil + } + return snapshot, true, nil } func (m *Manager) StartAcceptedChatCommandRun( requestID string, conversationID string, - clientRequestID string, workdir string, initialPayloads []map[string]any, ) (ChatRunSnapshot, bool, int64, error) { m.chatStore.chatCommandMu.Lock() defer m.chatStore.chatCommandMu.Unlock() - snapshot, created, err := m.startPendingChatCommandRun( - requestID, - conversationID, - clientRequestID, - workdir, - ) - if err != nil || !created { - return snapshot, created, snapshot.LatestSeq, err + snapshot, created := m.createChatRun(requestID, conversationID, workdir) + if !created { + return snapshot, false, snapshot.LatestSeq, nil } m.MarkChatRunControl(snapshot.RequestID, conversationID, "accepted", "", "") @@ -55,98 +56,45 @@ func (m *Manager) StartAcceptedChatCommandRun( return snapshot, true, acceptedSeq, nil } -func (m *Manager) startPendingChatCommandRun( +func (m *Manager) createChatRun( requestID string, conversationID string, - clientRequestID string, - workdirInput ...string, -) (ChatRunSnapshot, bool, error) { + workdir string, +) (ChatRunSnapshot, bool) { requestID = strings.TrimSpace(requestID) if requestID == "" { - return ChatRunSnapshot{}, false, ErrChatRunNotFound + return ChatRunSnapshot{}, false } - - now := time.Now() conversationID = strings.TrimSpace(conversationID) - clientRequestID = strings.TrimSpace(clientRequestID) - workdir := "" - if len(workdirInput) > 0 { - workdir = strings.TrimSpace(workdirInput[0]) - } - sessionEpoch := m.currentSessionEpoch() - if store := m.chatStore.eventStore; store != nil { - snapshot, created, err := store.StartRun(ChatRunStoreStart{ - RequestID: requestID, - ConversationID: conversationID, - ClientRequestID: clientRequestID, - Workdir: workdir, - CreatedAt: now, - }) - if err != nil { - return ChatRunSnapshot{}, false, err - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - if created { - if latestSeq := m.latestConversationSeqLocked(conversationID); latestSeq > snapshot.LatestSeq { - snapshot.LatestSeq = latestSeq - } - } - run := m.upsertChatRunSnapshotLocked(snapshot, sessionEpoch, now) - if run == nil { - return snapshot, created, nil - } - return run.snapshot(), created, nil - } + workdir = strings.TrimSpace(workdir) + now := time.Now() m.chatStore.chatMu.Lock() defer m.chatStore.chatMu.Unlock() m.pruneExpiredChatRunsLocked(now) - if clientRequestID != "" { - if existingRequestID := m.chatStore.chatRunByClientRequest[clientRequestID]; existingRequestID != "" { - if existing := m.chatStore.chatRuns[existingRequestID]; existing != nil { - if !existing.done { - if workdir != "" && existing.workdir == "" { - existing.workdir = workdir - } - return existing.snapshot(), false, nil - } - m.releaseCompletedChatRunLocked(existingRequestID, existing) - } - delete(m.chatStore.chatRunByClientRequest, clientRequestID) - } - } - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - m.removeChatRunLocked(requestID, existing) + return existing.snapshot(), false } - m.chatStore.nextChatRunEpoch += 1 + m.chatStore.nextChatRunEpoch++ latestSeq := m.latestConversationSeqLocked(conversationID) run := &chatRun{ - requestID: requestID, - conversationID: conversationID, - clientRequestID: clientRequestID, - workdir: workdir, - sessionEpoch: sessionEpoch, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - run.applyState(ChatRunStateQueued) + requestID: requestID, + conversationID: conversationID, + workdir: workdir, + runEpoch: m.chatStore.nextChatRunEpoch, + state: ChatRunStateQueued, + nextSeq: latestSeq, + updatedAt: now, + subscribers: make(map[int]*chatRunSubscriber), + relayBufferRetention: m.chatStore.relayBufferRetention, + } m.chatStore.chatRuns[requestID] = run if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { m.chatStore.chatRunByConversation[conversationID] = requestID } - if clientRequestID != "" { - m.chatStore.chatRunByClientRequest[clientRequestID] = requestID - } - - return run.snapshot(), true, nil + return run.snapshot(), true } func (m *Manager) latestConversationSeqLocked(conversationID string) int64 { @@ -184,103 +132,11 @@ func chatRunControlCanClaimConversation(controlType string, state string) bool { return controlType == "started" } -func (m *Manager) upsertChatRunSnapshotLocked( - snapshot ChatRunSnapshot, - sessionEpoch uint64, - now time.Time, -) *chatRun { - requestID := strings.TrimSpace(snapshot.RequestID) - if requestID == "" { - return nil - } - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - m.applyChatRunSnapshotLocked(existing, snapshot, now) - return existing - } - if snapshot.RunEpoch > m.chatStore.nextChatRunEpoch { - m.chatStore.nextChatRunEpoch = snapshot.RunEpoch - } - run := &chatRun{ - requestID: requestID, - conversationID: strings.TrimSpace(snapshot.ConversationID), - clientRequestID: strings.TrimSpace(snapshot.ClientRequestID), - workdir: strings.TrimSpace(snapshot.Workdir), - sessionEpoch: sessionEpoch, - runEpoch: snapshot.RunEpoch, - state: normalizeChatRunState(snapshot.State), - errorCode: strings.TrimSpace(snapshot.ErrorCode), - nextSeq: snapshot.LatestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - } - if run.runEpoch <= 0 { - m.chatStore.nextChatRunEpoch += 1 - run.runEpoch = m.chatStore.nextChatRunEpoch - } - run.applyState(run.state) - if snapshot.Done { - run.applyState(ChatRunStateCompleted) - if snapshot.State == ChatRunStateFailed { - run.applyState(ChatRunStateFailed) - run.errorCode = strings.TrimSpace(snapshot.ErrorCode) - } else if snapshot.State == ChatRunStateCancelled { - run.applyState(ChatRunStateCancelled) - } - } - m.chatStore.chatRuns[requestID] = run - if run.conversationID != "" && m.chatRunCanClaimConversationLocked(run.conversationID, requestID) { - m.chatStore.chatRunByConversation[run.conversationID] = requestID - } - if run.clientRequestID != "" { - m.chatStore.chatRunByClientRequest[run.clientRequestID] = requestID - } - return run -} - -func (m *Manager) applyChatRunSnapshotLocked(run *chatRun, snapshot ChatRunSnapshot, now time.Time) { - if run == nil { - return - } - requestID := strings.TrimSpace(snapshot.RequestID) - if requestID == "" { - requestID = run.requestID - } - conversationID := strings.TrimSpace(snapshot.ConversationID) - m.updateRunConversationLocked(run, requestID, conversationID, false) - if clientRequestID := strings.TrimSpace(snapshot.ClientRequestID); clientRequestID != "" { - run.clientRequestID = clientRequestID - m.chatStore.chatRunByClientRequest[clientRequestID] = requestID - } - if workdir := strings.TrimSpace(snapshot.Workdir); workdir != "" { - run.workdir = workdir - } - if snapshot.RunEpoch > 0 { - run.runEpoch = snapshot.RunEpoch - if snapshot.RunEpoch > m.chatStore.nextChatRunEpoch { - m.chatStore.nextChatRunEpoch = snapshot.RunEpoch - } - } - if snapshot.LatestSeq > run.nextSeq { - run.nextSeq = snapshot.LatestSeq - } - if state := normalizeChatRunState(snapshot.State); state != "" { - run.applyState(state) - } - if snapshot.Done && !run.done { - run.applyState(ChatRunStateCompleted) - } - if snapshot.ErrorCode != "" { - run.errorCode = strings.TrimSpace(snapshot.ErrorCode) - } - run.updatedAt = now -} - func (m *Manager) RemoveChatRun(requestID string) { requestID = strings.TrimSpace(requestID) if requestID == "" { return } - m.chatStore.chatMu.Lock() defer m.chatStore.chatMu.Unlock() run := m.chatStore.chatRuns[requestID] @@ -295,7 +151,6 @@ func (m *Manager) RemoveChatRunByConversation(conversationID string) { if conversationID == "" { return } - m.chatStore.chatMu.Lock() defer m.chatStore.chatMu.Unlock() requestID := m.chatStore.chatRunByConversation[conversationID] @@ -350,21 +205,13 @@ func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { } currentOwner := m.chatStore.chatRunByConversation[conversationID] if shouldReplaceActiveChatRunSummary(summary, summaries[index], currentOwner) { - summaries[index].RequestID = summary.RequestID - summaries[index].FirstSeq = summary.FirstSeq - summaries[index].LatestSeq = summary.LatestSeq - summaries[index].RunEpoch = summary.RunEpoch - summaries[index].UpdatedAt = summary.UpdatedAt + summaries[index] = summary } continue } seen[conversationID] = len(summaries) summaries = append(summaries, summary) } - - sort.Slice(summaries, func(i, j int) bool { - return summaries[i].ConversationID < summaries[j].ConversationID - }) return summaries } @@ -408,26 +255,18 @@ func (m *Manager) ConversationRunSummary(conversationID string) (ActiveChatRunSu } func (m *Manager) FailStartingChatRun(requestID string, message string) bool { - failed, sessionEpoch := m.failChatRunIf( + return m.failChatRunIf( requestID, message, "Desktop backend did not accept the remote chat request. Please retry.", func(run *chatRun) bool { - if run == nil || run.done { - return false - } - state := normalizeChatRunState(run.state) - return state == ChatRunStateQueued + return run != nil && !run.done && normalizeChatRunState(run.state) == ChatRunStateQueued }, ) - if failed { - m.clearSessionForEpoch(sessionEpoch) - } - return failed } func (m *Manager) FailUnstartedChatRun(requestID string, message string) bool { - failed, _ := m.failChatRunIf( + return m.failChatRunIf( requestID, message, "Desktop app accepted the remote chat request but did not start it. Please retry.", @@ -442,7 +281,6 @@ func (m *Manager) FailUnstartedChatRun(requestID string, message string) bool { !isTerminalChatRunState(state) }, ) - return failed } func (m *Manager) failChatRunIf( @@ -450,11 +288,11 @@ func (m *Manager) failChatRunIf( message string, defaultMessage string, shouldFail func(*chatRun) bool, -) (bool, uint64) { +) bool { requestID = strings.TrimSpace(requestID) message = strings.TrimSpace(message) if requestID == "" { - return false, 0 + return false } if message == "" { message = defaultMessage @@ -471,7 +309,6 @@ func (m *Manager) failChatRunIf( now := time.Now() var broadcast *ChatBroadcastEvent - var persist ChatRunEventAppend var runSubscribers []*chatRunSubscriber m.chatStore.chatMu.Lock() @@ -479,14 +316,12 @@ func (m *Manager) failChatRunIf( run := m.chatStore.chatRuns[requestID] if shouldFail == nil || !shouldFail(run) { m.chatStore.chatMu.Unlock() - return false, 0 + return false } - sessionEpoch := run.sessionEpoch - run.nextSeq += 1 + run.nextSeq++ run.updatedAt = now run.applyState(ChatRunStateFailed) - run.errorCode = "desktop_runtime_unavailable" run.expiresAt = now.Add(chatRunDoneRetention) chatEvent := &gatewayv1.ChatEvent{ Type: gatewayv1.ChatEvent_ERROR, @@ -494,26 +329,34 @@ func (m *Manager) failChatRunIf( Data: string(data), } broadcast = &ChatBroadcastEvent{ - RequestID: requestID, - Event: chatEvent, - Seq: run.nextSeq, - Workdir: run.workdir, + RequestID: requestID, + Event: chatEvent, + Seq: run.nextSeq, + Workdir: run.workdir, + ReceivedAt: now, } run.appendEvent(broadcast) - persist = chatRunEventAppendSnapshot(run, broadcast, now) runSubscribers = run.collectSubscribers() m.chatStore.chatMu.Unlock() - m.persistChatBroadcast(persist) notifySubscribers(runSubscribers, broadcast) - return true, sessionEpoch + return true +} + +type ChatRunSubscribeResult struct { + EventCh <-chan *ChatBroadcastEvent + Done <-chan struct{} + Cleanup func() + Snapshot ChatRunSnapshot + GapDetected bool + OldestSeq int64 } func (m *Manager) SubscribeChatRun( requestID string, conversationID string, afterSeq int64, -) (<-chan *ChatBroadcastEvent, <-chan struct{}, func(), ChatRunSnapshot, error) { +) (*ChatRunSubscribeResult, error) { requestID = strings.TrimSpace(requestID) conversationID = strings.TrimSpace(conversationID) if afterSeq < 0 { @@ -521,27 +364,6 @@ func (m *Manager) SubscribeChatRun( } conversationReplayRequested := requestID == "" && conversationID != "" - var persistedReplay []*ChatBroadcastEvent - var persistedSnapshot ChatRunSnapshot - persistedFound := false - if store := m.chatStore.eventStore; store != nil { - snapshot, replay, ok, err := store.Replay(requestID, conversationID, afterSeq, maxBufferedChatRunEvents) - if err != nil { - done := make(chan struct{}) - close(done) - return nil, done, func() {}, ChatRunSnapshot{}, err - } - if ok { - persistedFound = true - persistedSnapshot = snapshot - persistedReplay = replay - requestID = strings.TrimSpace(snapshot.RequestID) - if conversationID == "" { - conversationID = strings.TrimSpace(snapshot.ConversationID) - } - } - } - m.chatStore.chatMu.Lock() defer m.chatStore.chatMu.Unlock() now := time.Now() @@ -555,44 +377,33 @@ func (m *Manager) SubscribeChatRun( requestID = m.chatStore.chatRunByConversation[conversationID] } run := m.chatStore.chatRuns[requestID] - if run == nil && persistedFound { - run = m.upsertChatRunSnapshotLocked(persistedSnapshot, m.currentSessionEpoch(), now) - if run != nil { - for _, event := range persistedReplay { - if event.RequestID == run.requestID { - run.appendEvent(event) - } - } - } - } else if run != nil && persistedFound && run.requestID == persistedSnapshot.RequestID { - m.applyChatRunSnapshotLocked(run, persistedSnapshot, now) - } if run == nil { done := make(chan struct{}) close(done) - return nil, done, func() {}, ChatRunSnapshot{}, ErrChatRunNotFound + return &ChatRunSubscribeResult{ + Done: done, + Cleanup: func() {}, + }, ErrChatRunNotFound } - replay := make([]*ChatBroadcastEvent, 0) - var buffered []*ChatBroadcastEvent + var replay []*ChatBroadcastEvent + gapDetected := false + var oldestSeq int64 + if conversationReplayRequested && conversationID != "" { - buffered = m.collectConversationEventsLocked(conversationID, afterSeq) + replay = m.collectConversationEventsLocked(conversationID, afterSeq) } else { - buffered = collectBufferedEventsAfterSeq(run, afterSeq) + replay = collectBufferedEventsAfterSeq(run, afterSeq) } - if persistedFound { - for _, event := range persistedReplay { - replay = append(replay, cloneChatBroadcastEvent(event)) + + if afterSeq > 0 && len(run.events) > 0 { + oldestSeq = run.events[0].Seq + if afterSeq < oldestSeq { + gapDetected = true } - replay = mergeChatReplayEvents(replay, buffered) - } else { - replay = buffered } bufferSize := len(replay) + 128 - if bufferSize < 128 { - bufferSize = 128 - } ch := make(chan *ChatBroadcastEvent, bufferSize) done := make(chan struct{}) for _, event := range replay { @@ -604,7 +415,7 @@ func (m *Manager) SubscribeChatRun( doneClosed := false if !run.done { subID = m.chatStore.nextChatRunSubID - m.chatStore.nextChatRunSubID += 1 + m.chatStore.nextChatRunSubID++ subscriber = &chatRunSubscriber{ ch: ch, done: done, @@ -633,7 +444,14 @@ func (m *Manager) SubscribeChatRun( }) } - return ch, done, cleanup, run.snapshot(), nil + return &ChatRunSubscribeResult{ + EventCh: ch, + Done: done, + Cleanup: cleanup, + Snapshot: run.snapshot(), + GapDetected: gapDetected, + OldestSeq: oldestSeq, + }, nil } func collectBufferedEventsAfterSeq(run *chatRun, afterSeq int64) []*ChatBroadcastEvent { @@ -661,38 +479,13 @@ func (m *Manager) collectConversationEventsLocked(conversationID string, afterSe } } } - return replay -} - -func mergeChatReplayEvents( - persisted []*ChatBroadcastEvent, - buffered []*ChatBroadcastEvent, -) []*ChatBroadcastEvent { - if len(persisted) == 0 { - return buffered - } - if len(buffered) == 0 { - return persisted - } - seen := make(map[int64]struct{}, len(persisted)) - for _, e := range persisted { - if e != nil && e.Seq > 0 { - seen[e.Seq] = struct{}{} - } - } - merged := make([]*ChatBroadcastEvent, 0, len(persisted)+len(buffered)) - merged = append(merged, persisted...) - for _, e := range buffered { - if e != nil && e.Seq > 0 { - if _, dup := seen[e.Seq]; !dup { - merged = append(merged, cloneChatBroadcastEvent(e)) - } - } - } - sort.SliceStable(merged, func(i, j int) bool { - return merged[i].Seq < merged[j].Seq + // Runs live in a map, so multi-run conversations (e.g. a queued prompt + // auto-sent right after the previous run) would otherwise replay in + // arbitrary run order. + sort.SliceStable(replay, func(i, j int) bool { + return replay[i].Seq < replay[j].Seq }) - return merged + return replay } func (m *Manager) ChatRunSnapshot( @@ -732,16 +525,12 @@ func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot } var best *chatRun - var bestRequestID string - for requestID, run := range m.chatStore.chatRuns { + for _, run := range m.chatStore.chatRuns { if !chatRunIsRunningForConversation(run, conversationID) { continue } - if best == nil || - run.updatedAt.After(best.updatedAt) || - (run.updatedAt.Equal(best.updatedAt) && requestID > bestRequestID) { + if best == nil || run.updatedAt.After(best.updatedAt) { best = run - bestRequestID = requestID } } if best == nil { @@ -799,7 +588,6 @@ func (m *Manager) MarkChatRunPayloads( } now := time.Now() - persists := make([]ChatRunEventAppend, 0, len(payloads)) broadcasts := make([]*ChatBroadcastEvent, 0, len(payloads)) m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) @@ -814,12 +602,8 @@ func (m *Manager) MarkChatRunPayloads( m.updateRunConversationLocked(run, requestID, conversationID, false) for _, payload := range payloads { broadcast := m.appendChatPayloadLocked(run, payload, now) - if broadcast == nil { - continue - } - broadcasts = append(broadcasts, broadcast) - if !isEphemeralChatBroadcastEvent(broadcast) { - persists = append(persists, chatRunEventAppendSnapshot(run, broadcast, now)) + if broadcast != nil { + broadcasts = append(broadcasts, broadcast) } } runSubscribers := run.collectSubscribers() @@ -828,14 +612,8 @@ func (m *Manager) MarkChatRunPayloads( if len(broadcasts) == 0 { return nil } - m.persistChatBroadcasts(persists) - for _, s := range runSubscribers { - for _, broadcast := range broadcasts { - select { - case <-s.done: - case s.ch <- cloneChatBroadcastEvent(broadcast): - } - } + for _, broadcast := range broadcasts { + notifySubscribers(runSubscribers, broadcast) } seqs := make([]int64, 0, len(broadcasts)) for _, broadcast := range broadcasts { @@ -858,7 +636,6 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh state = ChatRunStateRunning } now := chatRuntimeSnapshotTime(snapshot.GetUpdatedAt()) - clientRequestID := strings.TrimSpace(snapshot.GetClientRequestId()) workdir := strings.TrimSpace(snapshot.GetCwd()) payload := map[string]any{ @@ -872,14 +649,10 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh "tool_status": strings.TrimSpace(snapshot.GetToolStatus()), "tool_status_is_compaction": snapshot.GetToolStatusIsCompaction(), } - if clientRequestID != "" { - payload["client_request_id"] = clientRequestID - } if workerID := strings.TrimSpace(snapshot.GetWorkerId()); workerID != "" { payload["worker_id"] = workerID } - var persist ChatRunEventAppend var broadcast *ChatBroadcastEvent var runSubscribers []*chatRunSubscriber var activityKind string @@ -892,39 +665,27 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh run := m.chatStore.chatRuns[requestID] created := false if run == nil { - m.chatStore.nextChatRunEpoch += 1 + m.chatStore.nextChatRunEpoch++ run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - clientRequestID: clientRequestID, - workdir: workdir, - sessionEpoch: m.currentSessionEpoch(), - runEpoch: m.chatStore.nextChatRunEpoch, - state: state, - nextSeq: m.latestConversationSeqLocked(conversationID), - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), + requestID: requestID, + conversationID: conversationID, + workdir: workdir, + runEpoch: m.chatStore.nextChatRunEpoch, + state: state, + nextSeq: m.latestConversationSeqLocked(conversationID), + updatedAt: now, + subscribers: make(map[int]*chatRunSubscriber), + relayBufferRetention: m.chatStore.relayBufferRetention, } - run.applyState(state) m.chatStore.chatRuns[requestID] = run created = true } - snapshotRevision := snapshot.GetRevision() - if snapshotRevision > 0 && run.runtimeSnapshotRevision > 0 && - snapshotRevision <= run.runtimeSnapshotRevision { - m.chatStore.chatMu.Unlock() - return - } if run.done && !terminalState { m.chatStore.chatMu.Unlock() return } previousState := normalizeChatRunState(run.state) m.updateRunConversationLocked(run, requestID, conversationID, state == ChatRunStateRunning) - if clientRequestID != "" { - run.clientRequestID = clientRequestID - m.chatStore.chatRunByClientRequest[clientRequestID] = requestID - } if workdir != "" { run.workdir = workdir } @@ -933,13 +694,7 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh if terminalState { run.expiresAt = now.Add(chatRunDoneRetention) } - if snapshotRevision > 0 { - run.runtimeSnapshotRevision = snapshotRevision - } broadcast = m.appendChatPayloadLocked(run, payload, now) - if broadcast != nil { - persist = chatRunEventAppendSnapshot(run, broadcast, now) - } runSubscribers = run.collectSubscribers() if state == ChatRunStateRunning && (created || previousState != ChatRunStateRunning) { activityKind = "running" @@ -953,7 +708,6 @@ func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapsh if broadcast == nil { return } - m.persistChatBroadcast(persist) notifySubscribers(runSubscribers, broadcast) if terminalState { for _, s := range runSubscribers { @@ -986,31 +740,28 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) broadcast := &ChatBroadcastEvent{ - RequestID: requestID, - Event: event, + RequestID: requestID, + Event: event, + ReceivedAt: now, } - var persist ChatRunEventAppend var runSubscribers []*chatRunSubscriber - var firstDelta *ChatBroadcastEvent var activityKind string var activityConversationID string var activityWorkdir string run := m.chatStore.chatRuns[requestID] if run == nil && requestID != "" { - sessionEpoch := m.currentSessionEpoch() - m.chatStore.nextChatRunEpoch += 1 + m.chatStore.nextChatRunEpoch++ latestSeq := m.latestConversationSeqLocked(conversationID) run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - sessionEpoch: sessionEpoch, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), + requestID: requestID, + conversationID: conversationID, + runEpoch: m.chatStore.nextChatRunEpoch, + state: ChatRunStateQueued, + nextSeq: latestSeq, + updatedAt: now, + subscribers: make(map[int]*chatRunSubscriber), + relayBufferRetention: m.chatStore.relayBufferRetention, } - run.applyState(ChatRunStateQueued) m.chatStore.chatRuns[requestID] = run if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { m.chatStore.chatRunByConversation[conversationID] = requestID @@ -1025,7 +776,7 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven if normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { run.applyState(ChatRunStateRunning) } - run.nextSeq += 1 + run.nextSeq++ run.updatedAt = now broadcast.Seq = run.nextSeq broadcast.Workdir = run.workdir @@ -1034,29 +785,24 @@ func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEven run.applyState(ChatRunStateCompleted) } else { run.applyState(ChatRunStateFailed) - if run.errorCode == "" { - run.errorCode = "desktop_error" - } } run.expiresAt = now.Add(chatRunDoneRetention) } nextState := normalizeChatRunState(run.state) activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) run.appendEvent(broadcast) - if !isEphemeralChatBroadcastEvent(broadcast) { - persist = chatRunEventAppendSnapshot(run, broadcast, now) - } - if isFirstDeltaChatEvent(event) && !run.firstDeltaLogged { - run.firstDeltaLogged = true - firstDelta = cloneChatBroadcastEvent(broadcast) - } runSubscribers = run.collectSubscribers() m.chatStore.chatMu.Unlock() - if firstDelta != nil { - logChatRunSpan("first_delta", firstDelta) + notifySubscribers(runSubscribers, broadcast) + if isTerminalChatEvent(event) { + for _, s := range runSubscribers { + s.close() + } + } + if activityKind != "" { + m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) } - m.finalizeChatRunBroadcast(broadcast, persist, runSubscribers, activityKind, activityConversationID, activityWorkdir, now) } func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.ChatControlEvent) { @@ -1078,31 +824,6 @@ func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.Chat m.markChatRunControl(requestID, conversationID, controlType, state, errorCode, message, time.Now()) } -func (m *Manager) markChatRunStateSilent( - requestID string, - conversationID string, - state string, - now time.Time, -) { - state = normalizeChatRunState(state) - if requestID == "" || state == "" { - return - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil || run.done { - return - } - m.updateRunConversationLocked(run, requestID, conversationID, state == ChatRunStateRunning) - run.applyState(state) - run.updatedAt = now - if isTerminalChatRunState(state) { - run.expiresAt = now.Add(chatRunDoneRetention) - } -} - func (m *Manager) markChatRunControl( requestID string, conversationID string, @@ -1124,13 +845,31 @@ func (m *Manager) markChatRunControl( } } - var persist ChatRunEventAppend var activityKind string var activityConversationID string var activityWorkdir string m.chatStore.chatMu.Lock() m.pruneExpiredChatRunsLocked(now) run := m.chatStore.chatRuns[requestID] + if run == nil && controlType == "started" && conversationID != "" { + // GUI-local runs (e.g. a queued prompt auto-sent by the desktop app) + // announce themselves with a bare "started" control before any chat + // event or runtime snapshot reaches the gateway. Register the run here + // so the "running" activity broadcast is not lost and remote clients + // can attach immediately. + m.chatStore.nextChatRunEpoch++ + run = &chatRun{ + requestID: requestID, + conversationID: conversationID, + runEpoch: m.chatStore.nextChatRunEpoch, + state: ChatRunStateQueued, + nextSeq: m.latestConversationSeqLocked(conversationID), + updatedAt: now, + subscribers: make(map[int]*chatRunSubscriber), + relayBufferRetention: m.chatStore.relayBufferRetention, + } + m.chatStore.chatRuns[requestID] = run + } if run == nil || run.done { m.chatStore.chatMu.Unlock() if run == nil { @@ -1144,14 +883,21 @@ func (m *Manager) markChatRunControl( broadcast := m.appendChatControlLocked(run, controlType, errorCode, message, now) nextState := normalizeChatRunState(run.state) activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) - persist = chatRunEventAppendSnapshot(run, broadcast, now) runSubscribers := run.collectSubscribers() m.chatStore.chatMu.Unlock() if span := chatControlSpanName(broadcast.Control); span != "" { logChatRunSpan(span, broadcast) } - m.finalizeChatRunBroadcast(broadcast, persist, runSubscribers, activityKind, activityConversationID, activityWorkdir, now) + notifySubscribers(runSubscribers, broadcast) + if isTerminalChatRunState(nextState) { + for _, s := range runSubscribers { + s.close() + } + } + if activityKind != "" { + m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) + } } func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { @@ -1226,22 +972,22 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent session.dispatch(env) } +// chatRun methods + func (r *chatRun) snapshot() ChatRunSnapshot { var firstSeq int64 if len(r.events) > 0 { firstSeq = r.events[0].Seq } return ChatRunSnapshot{ - RequestID: r.requestID, - ConversationID: r.conversationID, - ClientRequestID: r.clientRequestID, - Workdir: r.workdir, - FirstSeq: firstSeq, - LatestSeq: r.nextSeq, - RunEpoch: r.runEpoch, - State: r.state, - ErrorCode: r.errorCode, - Done: r.done, + RequestID: r.requestID, + ConversationID: r.conversationID, + Workdir: r.workdir, + FirstSeq: firstSeq, + LatestSeq: r.nextSeq, + RunEpoch: r.runEpoch, + State: r.state, + Done: r.done, } } @@ -1251,45 +997,34 @@ func (r *chatRun) applyState(state string) { state = ChatRunStateQueued } r.state = state - r.accepted = state != ChatRunStateQueued - r.started = state == ChatRunStateRunning || state == ChatRunStateCompleted r.done = isTerminalChatRunState(state) - if state != ChatRunStateFailed { - r.errorCode = "" - } } func (r *chatRun) appendEvent(event *ChatBroadcastEvent) { if r == nil || event == nil { return } - if revision := runtimeSnapshotRevisionFromPayload(event.Payload); revision > r.runtimeSnapshotRevision { - r.runtimeSnapshotRevision = revision + if event.ReceivedAt.IsZero() { + event.ReceivedAt = time.Now() } - r.events = appendCappedChatRunEvent(r.events, event, maxBufferedChatRunEvents) + r.events = append(r.events, cloneChatBroadcastEvent(event)) + r.evictExpiredEvents() } -func appendCappedChatRunEvent( - events []*ChatBroadcastEvent, - event *ChatBroadcastEvent, - limit int, -) []*ChatBroadcastEvent { - if event == nil { - return events - } - if limit <= 0 { - return events[:0] +func (r *chatRun) evictExpiredEvents() { + retention := r.relayBufferRetention + if retention <= 0 { + retention = defaultRelayBufferRetention } - cloned := cloneChatBroadcastEvent(event) - if len(events) < limit { - return append(events, cloned) + cutoff := time.Now().Add(-retention) + i := 0 + for i < len(r.events) && r.events[i].ReceivedAt.Before(cutoff) { + i++ } - if len(events) > limit { - events = events[len(events)-limit:] + if i > 0 { + copy(r.events, r.events[i:]) + r.events = r.events[:len(r.events)-i] } - copy(events, events[1:]) - events[len(events)-1] = cloned - return events } func (r *chatRun) shouldPrune(now time.Time) bool { @@ -1356,36 +1091,24 @@ func (m *Manager) removeChatRunLocked(requestID string, run *chatRun) { if run.conversationID != "" && m.chatStore.chatRunByConversation[run.conversationID] == requestID { delete(m.chatStore.chatRunByConversation, run.conversationID) } - if run.clientRequestID != "" && m.chatStore.chatRunByClientRequest[run.clientRequestID] == requestID { - delete(m.chatStore.chatRunByClientRequest, run.clientRequestID) - } delete(m.chatStore.chatRuns, requestID) for _, subscriber := range run.subscribers { subscriber.close() } } -func (m *Manager) releaseCompletedChatRunLocked(requestID string, run *chatRun) { - if run.conversationID != "" && m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - if run.clientRequestID != "" && m.chatStore.chatRunByClientRequest[run.clientRequestID] == requestID { - delete(m.chatStore.chatRunByClientRequest, run.clientRequestID) - } - delete(m.chatStore.chatRuns, requestID) -} - func cloneChatBroadcastEvent(event *ChatBroadcastEvent) *ChatBroadcastEvent { if event == nil { return nil } return &ChatBroadcastEvent{ - RequestID: event.RequestID, - Event: event.Event, - Control: event.Control, - Payload: cloneChatPayloadMap(event.Payload), - Seq: event.Seq, - Workdir: event.Workdir, + RequestID: event.RequestID, + Event: event.Event, + Control: event.Control, + Payload: cloneChatPayloadMap(event.Payload), + Seq: event.Seq, + Workdir: event.Workdir, + ReceivedAt: event.ReceivedAt, } } @@ -1400,6 +1123,8 @@ func cloneChatPayloadMap(input map[string]any) map[string]any { return out } +// State constants and helpers + var validChatRunStates = map[string]bool{ ChatRunStateQueued: true, ChatRunStateDelivered: true, @@ -1438,15 +1163,15 @@ var activeChatRunStates = map[string]bool{ } var controlTypeToState = map[string]string{ - "accepted": ChatRunStateQueued, - "delivered": ChatRunStateDelivered, - "claimed": ChatRunStateClaimed, - "starting": ChatRunStateStarting, + "accepted": ChatRunStateQueued, + "delivered": ChatRunStateDelivered, + "claimed": ChatRunStateClaimed, + "starting": ChatRunStateStarting, "queued_in_gui": ChatRunStateDesktopQueued, - "started": ChatRunStateRunning, - "completed": ChatRunStateCompleted, - "failed": ChatRunStateFailed, - "cancelled": ChatRunStateCancelled, + "started": ChatRunStateRunning, + "completed": ChatRunStateCompleted, + "failed": ChatRunStateFailed, + "cancelled": ChatRunStateCancelled, } var stateToControlType = map[string]string{ @@ -1461,6 +1186,13 @@ var stateToControlType = map[string]string{ ChatRunStateCancelled: "cancelled", } +func isTerminalChatEvent(event *gatewayv1.ChatEvent) bool { + if event == nil { + return false + } + return event.GetType() == gatewayv1.ChatEvent_DONE || event.GetType() == gatewayv1.ChatEvent_ERROR +} + func detectRunActivity(run *chatRun, previousState, nextState string) (kind, conversationID, workdir string) { if isTerminalChatRunState(nextState) { kind = "idle" @@ -1474,17 +1206,6 @@ func detectRunActivity(run *chatRun, previousState, nextState string) (kind, con return } -func (m *Manager) finalizeChatRunBroadcast(broadcast *ChatBroadcastEvent, persist ChatRunEventAppend, subscribers []*chatRunSubscriber, activityKind, activityConversationID, activityWorkdir string, now time.Time) { - if broadcast == nil { - return - } - m.persistChatBroadcast(persist) - notifySubscribers(subscribers, broadcast) - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } -} - func (m *Manager) appendChatControlLocked( run *chatRun, controlType string, @@ -1503,14 +1224,11 @@ func (m *Manager) appendChatControlLocked( state = ChatRunStateQueued } run.applyState(state) - if errorCode != "" { - run.errorCode = errorCode - } run.updatedAt = now if isTerminalChatRunState(state) { run.expiresAt = now.Add(chatRunDoneRetention) } - run.nextSeq += 1 + run.nextSeq++ seq := run.nextSeq if controlType == "" { controlType = stateToControlType[normalizeChatRunState(state)] @@ -1519,21 +1237,21 @@ func (m *Manager) appendChatControlLocked( } } control := &gatewayv1.ChatControlEvent{ - RequestId: run.requestID, - ClientRequestId: run.clientRequestID, - ConversationId: run.conversationID, - RunEpoch: run.runEpoch, - Type: controlType, - State: run.state, - ErrorCode: run.errorCode, - Message: message, - Seq: seq, + RequestId: run.requestID, + ConversationId: run.conversationID, + RunEpoch: run.runEpoch, + Type: controlType, + State: run.state, + ErrorCode: errorCode, + Message: message, + Seq: seq, } broadcast := &ChatBroadcastEvent{ - RequestID: run.requestID, - Control: control, - Seq: seq, - Workdir: run.workdir, + RequestID: run.requestID, + Control: control, + Seq: seq, + Workdir: run.workdir, + ReceivedAt: now, } run.appendEvent(broadcast) return broadcast @@ -1548,158 +1266,28 @@ func (m *Manager) appendChatPayloadLocked( return nil } run.updatedAt = now - run.nextSeq += 1 + run.nextSeq++ seq := run.nextSeq nextPayload := cloneChatPayloadMap(payload) if nextPayload == nil { nextPayload = make(map[string]any) } nextPayload["request_id"] = run.requestID - nextPayload["client_request_id"] = run.clientRequestID nextPayload["conversation_id"] = run.conversationID nextPayload["run_epoch"] = run.runEpoch nextPayload["state"] = run.state nextPayload["seq"] = seq broadcast := &ChatBroadcastEvent{ - RequestID: run.requestID, - Payload: nextPayload, - Seq: seq, - Workdir: run.workdir, + RequestID: run.requestID, + Payload: nextPayload, + Seq: seq, + Workdir: run.workdir, + ReceivedAt: now, } run.appendEvent(broadcast) return broadcast } -func chatRunEventAppendSnapshot( - run *chatRun, - broadcast *ChatBroadcastEvent, - now time.Time, -) ChatRunEventAppend { - if run == nil || broadcast == nil { - return ChatRunEventAppend{} - } - return ChatRunEventAppend{ - RequestID: run.requestID, - ConversationID: run.conversationID, - ClientRequestID: run.clientRequestID, - Workdir: run.workdir, - RunEpoch: run.runEpoch, - State: run.state, - ErrorCode: run.errorCode, - Done: run.done, - Event: cloneChatBroadcastEvent(broadcast), - CreatedAt: now, - } -} - -func (m *Manager) persistChatBroadcast(input ChatRunEventAppend) { - m.persistChatBroadcasts([]ChatRunEventAppend{input}) -} - -func (m *Manager) persistChatBroadcasts(inputs []ChatRunEventAppend) { - if m.chatStore.eventStore == nil { - return - } - validInputs := make([]ChatRunEventAppend, 0, len(inputs)) - for _, input := range inputs { - if input.Event != nil { - validInputs = append(validInputs, input) - } - } - if len(validInputs) == 0 || m.chatStore.eventStore == nil { - return - } - if err := m.chatStore.eventStore.AppendEvents(validInputs); err != nil { - first := validInputs[0] - log.Printf("persist chat events failed: run_id=%s count=%d first_seq=%d err=%v", first.RequestID, len(validInputs), first.Event.Seq, err) - } -} - -func isTerminalChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil { - return false - } - return event.GetType() == gatewayv1.ChatEvent_DONE || event.GetType() == gatewayv1.ChatEvent_ERROR -} - -func isFirstDeltaChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil { - return false - } - switch event.GetType() { - case gatewayv1.ChatEvent_TOKEN, - gatewayv1.ChatEvent_THINKING, - gatewayv1.ChatEvent_TOOL_CALL, - gatewayv1.ChatEvent_TOOL_STATUS, - gatewayv1.ChatEvent_HOSTED_SEARCH: - return true - default: - return false - } -} - -func isEphemeralChatPayload(payload map[string]any) bool { - if payload == nil { - return false - } - eventType, _ := payload["type"].(string) - return eventType == "tool_call_delta" -} - -func isEphemeralChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil || event.GetType() != gatewayv1.ChatEvent_TOOL_CALL { - return false - } - var payload map[string]any - if err := json.Unmarshal([]byte(strings.TrimSpace(event.GetData())), &payload); err != nil { - return false - } - return isEphemeralChatPayload(payload) -} - -func isEphemeralChatBroadcastEvent(event *ChatBroadcastEvent) bool { - if event == nil { - return false - } - if len(event.Payload) > 0 { - return isEphemeralChatPayload(event.Payload) - } - return isEphemeralChatEvent(event.Event) -} - -func runtimeSnapshotRevisionFromPayload(payload map[string]any) int64 { - if len(payload) == 0 { - return 0 - } - eventType, _ := payload["type"].(string) - if eventType != "runtime_snapshot" { - return 0 - } - return toPositiveInt64(payload["revision"]) -} - -func toPositiveInt64(v any) int64 { - switch n := v.(type) { - case int64: - if n > 0 { - return n - } - case float64: - if n > 0 { - return int64(n) - } - case json.Number: - if parsed, err := n.Int64(); err == nil && parsed > 0 { - return parsed - } - case int: - if n > 0 { - return int64(n) - } - } - return 0 -} - func chatControlSpanName(control *gatewayv1.ChatControlEvent) string { if control == nil { return "" @@ -1726,26 +1314,20 @@ func logChatRunSpan(span string, event *ChatBroadcastEvent) { } runID := event.RequestID conversationID := "" - clientRequestID := "" if event.Control != nil { conversationID = event.Control.GetConversationId() - clientRequestID = event.Control.GetClientRequestId() } else if event.Payload != nil { if value, ok := event.Payload["conversation_id"].(string); ok { conversationID = value } - if value, ok := event.Payload["client_request_id"].(string); ok { - clientRequestID = value - } } else if event.Event != nil { conversationID = event.Event.GetConversationId() } log.Printf( - "chat_run_span span=%s run_id=%q conversation_id=%q client_request_id=%q seq=%d", + "chat_run_span span=%s run_id=%q conversation_id=%q seq=%d", span, runID, conversationID, - clientRequestID, event.Seq, ) } diff --git a/crates/agent-gateway/internal/session/manager_history_sync.go b/crates/agent-gateway/internal/session/manager_history_sync.go index 430784be..6709a407 100644 --- a/crates/agent-gateway/internal/session/manager_history_sync.go +++ b/crates/agent-gateway/internal/session/manager_history_sync.go @@ -152,5 +152,5 @@ func (m *Manager) releaseCompletedChatRunAfterHistoryUpsert(event *gatewayv1.His if run == nil || !run.done { return } - m.releaseCompletedChatRunLocked(requestID, run) + m.removeChatRunLocked(requestID, run) } diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index e25837aa..099b2a32 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -2,6 +2,7 @@ package session import ( "context" + "errors" "strings" "time" @@ -53,6 +54,9 @@ func (m *Manager) SetSession(s *AgentSession) { if previous != nil && previous != s { previous.Close() } + if s != nil && sessionChanged { + m.cmdQueue.DrainTo(s) + } } func (m *Manager) ClearSession(session *AgentSession) { @@ -244,6 +248,14 @@ func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.Gateway return err } +func (m *Manager) SendToAgentOrQueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { + err := m.SendToAgentContext(ctx, env) + if errors.Is(err, ErrAgentOffline) { + return m.cmdQueue.Enqueue(ctx, env) + } + return err +} + func (m *Manager) clearSessionAfterSendError(session *AgentSession, err error) { if err == nil || session == nil { return diff --git a/crates/agent-gateway/internal/session/manager_state.go b/crates/agent-gateway/internal/session/manager_state.go index 4c75bbc6..b28d0aa5 100644 --- a/crates/agent-gateway/internal/session/manager_state.go +++ b/crates/agent-gateway/internal/session/manager_state.go @@ -70,20 +70,20 @@ func newSyncHub() *syncHub { } type chatRunStore struct { - chatCommandMu sync.Mutex - chatMu sync.Mutex - eventStore ChatEventStore - nextChatRunSubID int - nextChatRunEpoch int64 - chatRuns map[string]*chatRun - chatRunByConversation map[string]string - chatRunByClientRequest map[string]string + chatCommandMu sync.Mutex + chatMu sync.Mutex + nextChatRunSubID int + nextChatRunEpoch int64 + chatRuns map[string]*chatRun + chatRunByConversation map[string]string + + relayBufferRetention time.Duration } func newChatRunStore() *chatRunStore { return &chatRunStore{ - chatRuns: make(map[string]*chatRun), - chatRunByConversation: make(map[string]string), - chatRunByClientRequest: make(map[string]string), + chatRuns: make(map[string]*chatRun), + chatRunByConversation: make(map[string]string), + relayBufferRetention: defaultRelayBufferRetention, } } diff --git a/crates/agent-gateway/internal/session/manager_test.go b/crates/agent-gateway/internal/session/manager_test.go index 652fe5a4..1124f2be 100644 --- a/crates/agent-gateway/internal/session/manager_test.go +++ b/crates/agent-gateway/internal/session/manager_test.go @@ -134,30 +134,11 @@ func TestTerminalSessionSnapshotPreservesSshMetadataAndSorts(t *testing.T) { } } -func TestAppendCappedChatRunEventKeepsLatestEvents(t *testing.T) { - var events []*ChatBroadcastEvent - for seq := int64(1); seq <= 5; seq++ { - events = appendCappedChatRunEvent(events, &ChatBroadcastEvent{Seq: seq}, 3) - } - - if len(events) != 3 { - t.Fatalf("events len = %d, want 3", len(events)) - } - if got := []int64{events[0].Seq, events[1].Seq, events[2].Seq}; got[0] != 3 || got[1] != 4 || got[2] != 5 { - t.Fatalf("events seqs = %#v, want [3 4 5]", got) - } - - events = appendCappedChatRunEvent(events, nil, 3) - if got := []int64{events[0].Seq, events[1].Seq, events[2].Seq}; got[0] != 3 || got[1] != 4 || got[2] != 5 { - t.Fatalf("nil event changed buffered seqs to %#v, want [3 4 5]", got) - } -} - func TestChatRunShouldPruneRetainsRunningUntilStale(t *testing.T) { now := time.Now() running := &chatRun{ state: ChatRunStateRunning, - updatedAt: now.Add(-time.Hour), + updatedAt: now.Add(-15 * time.Minute), } if running.shouldPrune(now) { t.Fatal("running chat should survive well before stale retention") @@ -196,7 +177,7 @@ func TestPruneExpiredChatRunsDropsNilEntries(t *testing.T) { func TestConversationRunSummaryReturnsCompletedRun(t *testing.T) { manager := NewManager() - snapshot, created, _, err := manager.StartAcceptedChatCommandRun("run-1", "conv-1", "client-1", "/workspace", nil) + snapshot, created, _, err := manager.StartAcceptedChatCommandRun("run-1", "conv-1", "/workspace", nil) if err != nil || !created { t.Fatalf("StartAcceptedChatCommandRun failed: err=%v created=%v", err, created) } diff --git a/crates/agent-gateway/internal/session/sqlite_chat_event_store.go b/crates/agent-gateway/internal/session/sqlite_chat_event_store.go deleted file mode 100644 index bb474cab..00000000 --- a/crates/agent-gateway/internal/session/sqlite_chat_event_store.go +++ /dev/null @@ -1,709 +0,0 @@ -package session - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - _ "modernc.org/sqlite" -) - -type ChatEventStore interface { - StartRun(input ChatRunStoreStart) (ChatRunSnapshot, bool, error) - AppendEvents(inputs []ChatRunEventAppend) error - Replay(requestID string, conversationID string, afterSeq int64, limit int) (ChatRunSnapshot, []*ChatBroadcastEvent, bool, error) - Close() error -} - -type ChatRunStoreStart struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - State string - CreatedAt time.Time -} - -type ChatRunEventAppend struct { - RequestID string - ConversationID string - ClientRequestID string - Workdir string - RunEpoch int64 - State string - ErrorCode string - Done bool - Event *ChatBroadcastEvent - CreatedAt time.Time -} - -type sqliteChatEventStore struct { - db *sql.DB -} - -func OpenSQLiteChatEventStore(path string) (ChatEventStore, error) { - path = strings.TrimSpace(path) - if path == "" { - return nil, errors.New("chat event store path is required") - } - if path != ":memory:" { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return nil, fmt.Errorf("create chat event store directory: %w", err) - } - } - db, err := sql.Open("sqlite", path) - if err != nil { - return nil, err - } - db.SetMaxOpenConns(4) - db.SetMaxIdleConns(4) - store := &sqliteChatEventStore{db: db} - if err := store.configure(); err != nil { - _ = db.Close() - return nil, err - } - if err := store.migrate(); err != nil { - _ = db.Close() - return nil, err - } - return store, nil -} - -func (s *sqliteChatEventStore) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} - -func (s *sqliteChatEventStore) configure() error { - if s == nil || s.db == nil { - return errors.New("chat event store is not open") - } - pragmas := []string{ - "PRAGMA busy_timeout = 5000", - "PRAGMA foreign_keys = ON", - "PRAGMA journal_mode = WAL", - "PRAGMA synchronous = NORMAL", - } - for _, pragma := range pragmas { - if _, err := s.db.Exec(pragma); err != nil { - return fmt.Errorf("configure sqlite chat event store: %w", err) - } - } - return nil -} - -func (s *sqliteChatEventStore) migrate() error { - statements := []string{ - `CREATE TABLE IF NOT EXISTS chat_runs ( - run_id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL DEFAULT '', - client_request_id TEXT NOT NULL DEFAULT '', - workdir TEXT NOT NULL DEFAULT '', - run_epoch INTEGER NOT NULL, - state TEXT NOT NULL, - error_code TEXT NOT NULL DEFAULT '', - done INTEGER NOT NULL DEFAULT 0, - latest_seq INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS chat_command_dedup ( - client_request_id TEXT PRIMARY KEY, - run_id TEXT NOT NULL, - conversation_id TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL - )`, - `CREATE TABLE IF NOT EXISTS chat_events ( - event_id TEXT PRIMARY KEY, - conversation_id TEXT NOT NULL DEFAULT '', - run_id TEXT NOT NULL, - client_request_id TEXT NOT NULL DEFAULT '', - type TEXT NOT NULL, - seq INTEGER NOT NULL, - payload_json TEXT NOT NULL, - created_at INTEGER NOT NULL, - UNIQUE(run_id, seq) - )`, - `CREATE INDEX IF NOT EXISTS idx_chat_runs_conversation_updated ON chat_runs(conversation_id, updated_at DESC)`, - `CREATE INDEX IF NOT EXISTS idx_chat_events_run_seq ON chat_events(run_id, seq)`, - `CREATE INDEX IF NOT EXISTS idx_chat_events_conversation_seq ON chat_events(conversation_id, seq)`, - } - for _, statement := range statements { - if _, err := s.db.Exec(statement); err != nil { - return fmt.Errorf("migrate sqlite chat event store: %w", err) - } - } - return nil -} - -func (s *sqliteChatEventStore) StartRun(input ChatRunStoreStart) (ChatRunSnapshot, bool, error) { - if s == nil || s.db == nil { - return ChatRunSnapshot{}, false, errors.New("chat event store is not open") - } - input.RequestID = strings.TrimSpace(input.RequestID) - input.ConversationID = strings.TrimSpace(input.ConversationID) - input.ClientRequestID = strings.TrimSpace(input.ClientRequestID) - input.Workdir = strings.TrimSpace(input.Workdir) - input.State = normalizeChatRunState(input.State) - if input.State == "" { - input.State = ChatRunStateQueued - } - if input.RequestID == "" { - return ChatRunSnapshot{}, false, ErrChatRunNotFound - } - now := input.CreatedAt - if now.IsZero() { - now = time.Now() - } - nowMs := now.UnixMilli() - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ChatRunSnapshot{}, false, err - } - defer rollbackUnlessCommitted(tx) - - if input.ClientRequestID != "" { - snapshot, ok, err := s.lookupRunByClientRequestTx(ctx, tx, input.ClientRequestID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if ok { - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return snapshot, false, nil - } - } - - snapshot, ok, err := s.lookupRunByIDTx(ctx, tx, input.RequestID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if ok { - if input.ClientRequestID == "" && snapshot.ClientRequestID == "" && snapshot.Done { - conversationID := input.ConversationID - if conversationID == "" { - conversationID = snapshot.ConversationID - } - workdir := input.Workdir - if workdir == "" { - workdir = snapshot.Workdir - } - runEpoch, err := nextChatRunEpochTx(ctx, tx) - if err != nil { - return ChatRunSnapshot{}, false, err - } - latestSeq, err := latestConversationSeqTx(ctx, tx, conversationID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if latestSeq < snapshot.LatestSeq { - latestSeq = snapshot.LatestSeq - } - if _, err := tx.ExecContext(ctx, ` - UPDATE chat_runs - SET conversation_id = ?, workdir = ?, run_epoch = ?, state = ?, - error_code = '', done = 0, latest_seq = max(latest_seq, ?), updated_at = ? - WHERE run_id = ? - `, conversationID, workdir, runEpoch, input.State, latestSeq, nowMs, input.RequestID); err != nil { - return ChatRunSnapshot{}, false, err - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: conversationID, - Workdir: workdir, - RunEpoch: runEpoch, - FirstSeq: snapshot.FirstSeq, - LatestSeq: latestSeq, - State: input.State, - }, true, nil - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return snapshot, false, nil - } - - runEpoch, err := nextChatRunEpochTx(ctx, tx) - if err != nil { - return ChatRunSnapshot{}, false, err - } - latestSeq, err := latestConversationSeqTx(ctx, tx, input.ConversationID) - if err != nil { - return ChatRunSnapshot{}, false, err - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_runs ( - run_id, conversation_id, client_request_id, workdir, run_epoch, - state, error_code, done, latest_seq, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, '', 0, ?, ?, ?) - `, input.RequestID, input.ConversationID, input.ClientRequestID, input.Workdir, runEpoch, input.State, latestSeq, nowMs, nowMs); err != nil { - return ChatRunSnapshot{}, false, err - } - if input.ClientRequestID != "" { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_command_dedup (client_request_id, run_id, conversation_id, created_at) - VALUES (?, ?, ?, ?) - `, input.ClientRequestID, input.RequestID, input.ConversationID, nowMs); err != nil { - return ChatRunSnapshot{}, false, err - } - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: runEpoch, - LatestSeq: latestSeq, - State: input.State, - }, true, nil -} - -func (s *sqliteChatEventStore) AppendEvents(inputs []ChatRunEventAppend) error { - if s == nil || s.db == nil { - return nil - } - validInputs := make([]ChatRunEventAppend, 0, len(inputs)) - for _, input := range inputs { - if input.Event == nil || input.Event.Seq <= 0 { - continue - } - input.RequestID = strings.TrimSpace(input.RequestID) - if input.RequestID == "" { - continue - } - validInputs = append(validInputs, input) - } - if len(validInputs) == 0 { - return nil - } - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer rollbackUnlessCommitted(tx) - - for _, input := range validInputs { - if err := s.appendEventTx(ctx, tx, input); err != nil { - return err - } - } - return tx.Commit() -} - -func (s *sqliteChatEventStore) appendEventTx( - ctx context.Context, - tx *sql.Tx, - input ChatRunEventAppend, -) error { - input.RequestID = strings.TrimSpace(input.RequestID) - input.ConversationID = strings.TrimSpace(input.ConversationID) - input.ClientRequestID = strings.TrimSpace(input.ClientRequestID) - input.Workdir = strings.TrimSpace(input.Workdir) - input.State = normalizeChatRunState(input.State) - input.ErrorCode = strings.TrimSpace(input.ErrorCode) - if input.RequestID == "" { - return nil - } - payload := storedChatBroadcastPayload(input) - if len(payload) == 0 { - return nil - } - payloadJSON, err := json.Marshal(payload) - if err != nil { - return err - } - if input.CreatedAt.IsZero() { - input.CreatedAt = time.Now() - } - createdMs := input.CreatedAt.UnixMilli() - runID := input.RequestID - conversationID := input.ConversationID - clientRequestID := input.ClientRequestID - workdir := input.Workdir - eventType, _ := payload["type"].(string) - eventType = strings.TrimSpace(eventType) - if eventType == "" { - eventType = "message" - } - doneValue := 0 - if input.Done { - doneValue = 1 - } - - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_runs ( - run_id, conversation_id, client_request_id, workdir, run_epoch, - state, error_code, done, latest_seq, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id) DO UPDATE SET - conversation_id = excluded.conversation_id, - client_request_id = excluded.client_request_id, - workdir = excluded.workdir, - run_epoch = excluded.run_epoch, - state = excluded.state, - error_code = excluded.error_code, - done = excluded.done, - latest_seq = max(chat_runs.latest_seq, excluded.latest_seq), - updated_at = excluded.updated_at - `, runID, conversationID, clientRequestID, workdir, input.RunEpoch, input.State, input.ErrorCode, doneValue, input.Event.Seq, createdMs, createdMs); err != nil { - return err - } - if clientRequestID != "" { - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_command_dedup (client_request_id, run_id, conversation_id, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(client_request_id) DO NOTHING - `, clientRequestID, runID, conversationID, createdMs); err != nil { - return err - } - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO chat_events ( - event_id, conversation_id, run_id, client_request_id, type, seq, payload_json, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(run_id, seq) DO NOTHING - `, fmt.Sprintf("%s/%d", runID, input.Event.Seq), conversationID, runID, clientRequestID, eventType, input.Event.Seq, string(payloadJSON), createdMs); err != nil { - return err - } - return nil -} - -func (s *sqliteChatEventStore) Replay( - requestID string, - conversationID string, - afterSeq int64, - limit int, -) (ChatRunSnapshot, []*ChatBroadcastEvent, bool, error) { - if s == nil || s.db == nil { - return ChatRunSnapshot{}, nil, false, errors.New("chat event store is not open") - } - if afterSeq < 0 { - afterSeq = 0 - } - if limit <= 0 { - limit = maxBufferedChatRunEvents - } - - ctx := context.Background() - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return ChatRunSnapshot{}, nil, false, err - } - defer rollbackUnlessCommitted(tx) - - snapshot, ok, err := s.lookupRunTx(ctx, tx, requestID, conversationID) - if err != nil || !ok { - return ChatRunSnapshot{}, nil, ok, err - } - var rows *sql.Rows - if strings.TrimSpace(requestID) == "" && strings.TrimSpace(conversationID) != "" { - rows, err = tx.QueryContext(ctx, ` - SELECT e.run_id, e.seq, e.payload_json, COALESCE(r.workdir, '') - FROM chat_events e - LEFT JOIN chat_runs r ON r.run_id = e.run_id - WHERE e.conversation_id = ? AND e.seq > ? - ORDER BY e.seq ASC - LIMIT ? - `, snapshot.ConversationID, afterSeq, limit) - } else { - rows, err = tx.QueryContext(ctx, ` - SELECT e.run_id, e.seq, e.payload_json, COALESCE(r.workdir, '') - FROM chat_events e - LEFT JOIN chat_runs r ON r.run_id = e.run_id - WHERE e.run_id = ? AND e.seq > ? - ORDER BY e.seq ASC - LIMIT ? - `, snapshot.RequestID, afterSeq, limit) - } - if err != nil { - return ChatRunSnapshot{}, nil, false, err - } - defer rows.Close() - - events := make([]*ChatBroadcastEvent, 0) - for rows.Next() { - var runID string - var seq int64 - var payloadJSON string - var workdir string - if err := rows.Scan(&runID, &seq, &payloadJSON, &workdir); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - var payload map[string]any - if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - events = append(events, &ChatBroadcastEvent{ - RequestID: strings.TrimSpace(runID), - Payload: payload, - Seq: seq, - Workdir: strings.TrimSpace(workdir), - }) - } - if err := rows.Err(); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - if err := tx.Commit(); err != nil { - return ChatRunSnapshot{}, nil, false, err - } - return snapshot, events, true, nil -} - -func (s *sqliteChatEventStore) lookupRunByClientRequestTx( - ctx context.Context, - tx *sql.Tx, - clientRequestID string, -) (ChatRunSnapshot, bool, error) { - clientRequestID = strings.TrimSpace(clientRequestID) - if clientRequestID == "" { - return ChatRunSnapshot{}, false, nil - } - var runID string - err := tx.QueryRowContext(ctx, ` - SELECT run_id FROM chat_command_dedup WHERE client_request_id = ? - `, clientRequestID).Scan(&runID) - if errors.Is(err, sql.ErrNoRows) { - return ChatRunSnapshot{}, false, nil - } - if err != nil { - return ChatRunSnapshot{}, false, err - } - snapshot, ok, err := s.lookupRunByIDTx(ctx, tx, runID) - if err != nil || ok { - return snapshot, ok, err - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM chat_command_dedup WHERE client_request_id = ? - `, clientRequestID); err != nil { - return ChatRunSnapshot{}, false, err - } - return ChatRunSnapshot{}, false, nil -} - -func (s *sqliteChatEventStore) lookupRunByIDTx( - ctx context.Context, - tx *sql.Tx, - requestID string, -) (ChatRunSnapshot, bool, error) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return ChatRunSnapshot{}, false, nil - } - return scanChatRunSnapshot(tx.QueryRowContext(ctx, chatRunSnapshotSQL(`r.run_id = ?`), requestID)) -} - -func (s *sqliteChatEventStore) lookupRunTx( - ctx context.Context, - tx *sql.Tx, - requestID string, - conversationID string, -) (ChatRunSnapshot, bool, error) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if requestID != "" { - return s.lookupRunByIDTx(ctx, tx, requestID) - } - if conversationID == "" { - return ChatRunSnapshot{}, false, nil - } - return scanChatRunSnapshot(tx.QueryRowContext(ctx, chatRunSnapshotSQL(`r.conversation_id = ? ORDER BY r.updated_at DESC LIMIT 1`), conversationID)) -} - -func chatRunSnapshotSQL(where string) string { - return fmt.Sprintf(` - SELECT - r.run_id, - r.conversation_id, - r.client_request_id, - r.workdir, - COALESCE((SELECT MIN(e.seq) FROM chat_events e WHERE e.run_id = r.run_id), 0) AS first_seq, - r.latest_seq, - r.run_epoch, - r.state, - r.error_code, - r.done - FROM chat_runs r - WHERE %s - `, where) -} - -type chatRunSnapshotScanner interface { - Scan(dest ...any) error -} - -func scanChatRunSnapshot(row chatRunSnapshotScanner) (ChatRunSnapshot, bool, error) { - var snapshot ChatRunSnapshot - var done int - err := row.Scan( - &snapshot.RequestID, - &snapshot.ConversationID, - &snapshot.ClientRequestID, - &snapshot.Workdir, - &snapshot.FirstSeq, - &snapshot.LatestSeq, - &snapshot.RunEpoch, - &snapshot.State, - &snapshot.ErrorCode, - &done, - ) - if errors.Is(err, sql.ErrNoRows) { - return ChatRunSnapshot{}, false, nil - } - if err != nil { - return ChatRunSnapshot{}, false, err - } - snapshot.State = normalizeChatRunState(snapshot.State) - snapshot.Done = done != 0 || isTerminalChatRunState(snapshot.State) - return snapshot, true, nil -} - -func nextChatRunEpochTx(ctx context.Context, tx *sql.Tx) (int64, error) { - var epoch int64 - if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(run_epoch), 0) + 1 FROM chat_runs`).Scan(&epoch); err != nil { - return 0, err - } - return epoch, nil -} - -func latestConversationSeqTx(ctx context.Context, tx *sql.Tx, conversationID string) (int64, error) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return 0, nil - } - var latestSeq int64 - if err := tx.QueryRowContext(ctx, ` - SELECT COALESCE(MAX(seq), 0) - FROM chat_events - WHERE conversation_id = ? - `, conversationID).Scan(&latestSeq); err != nil { - return 0, err - } - return latestSeq, nil -} - -func rollbackUnlessCommitted(tx *sql.Tx) { - if tx != nil { - _ = tx.Rollback() - } -} - -func storedChatBroadcastPayload(input ChatRunEventAppend) map[string]any { - event := input.Event - if event == nil { - return nil - } - var payload map[string]any - switch { - case len(event.Payload) > 0: - payload = cloneChatPayloadMap(event.Payload) - case event.Control != nil: - payload = storedChatControlPayload(event.Control) - case event.Event != nil: - payload = storedChatEventPayload(event.Event) - default: - payload = make(map[string]any) - } - if payload == nil { - payload = make(map[string]any) - } - payload["request_id"] = strings.TrimSpace(input.RequestID) - payload["client_request_id"] = strings.TrimSpace(input.ClientRequestID) - payload["conversation_id"] = strings.TrimSpace(input.ConversationID) - payload["run_epoch"] = input.RunEpoch - payload["state"] = normalizeChatRunState(input.State) - payload["seq"] = event.Seq - if workdir := strings.TrimSpace(input.Workdir); workdir != "" { - payload["workdir"] = workdir - } - if eventType, _ := payload["type"].(string); strings.TrimSpace(eventType) == "" { - payload["type"] = "message" - } else { - payload["type"] = strings.TrimSpace(eventType) - } - return payload -} - -func storedChatControlPayload(control *gatewayv1.ChatControlEvent) map[string]any { - payload := map[string]any{ - "type": strings.TrimSpace(control.GetType()), - "request_id": strings.TrimSpace(control.GetRequestId()), - "client_request_id": strings.TrimSpace(control.GetClientRequestId()), - "conversation_id": strings.TrimSpace(control.GetConversationId()), - "run_epoch": control.GetRunEpoch(), - "state": strings.TrimSpace(control.GetState()), - } - if seq := control.GetSeq(); seq > 0 { - payload["seq"] = seq - } - if errorCode := strings.TrimSpace(control.GetErrorCode()); errorCode != "" { - payload["error_code"] = errorCode - } - if message := strings.TrimSpace(control.GetMessage()); message != "" { - payload["message"] = message - } - return payload -} - -func storedChatEventPayload(event *gatewayv1.ChatEvent) map[string]any { - payload := map[string]any{ - "type": storedChatEventType(event.GetType()), - } - raw := strings.TrimSpace(event.GetData()) - if raw != "" { - var decoded map[string]any - if err := json.Unmarshal([]byte(raw), &decoded); err == nil { - for key, value := range decoded { - payload[key] = value - } - } - } - if conversationID := strings.TrimSpace(event.GetConversationId()); conversationID != "" { - payload["conversation_id"] = conversationID - } - return payload -} - -func storedChatEventType(eventType gatewayv1.ChatEvent_ChatEventType) string { - switch eventType { - case gatewayv1.ChatEvent_TOKEN: - return "token" - case gatewayv1.ChatEvent_THINKING: - return "thinking" - case gatewayv1.ChatEvent_TOOL_CALL: - return "tool_call" - case gatewayv1.ChatEvent_TOOL_RESULT: - return "tool_result" - case gatewayv1.ChatEvent_DONE: - return "done" - case gatewayv1.ChatEvent_ERROR: - return "error" - case gatewayv1.ChatEvent_TOOL_STATUS: - return "tool_status" - case gatewayv1.ChatEvent_HOSTED_SEARCH: - return "hosted_search" - case gatewayv1.ChatEvent_USER_MESSAGE: - return "user_message" - default: - return "message" - } -} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 2c739fe5..06fdd872 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -69,6 +69,7 @@ message GatewayEnvelope { TunnelFrame tunnel_frame = 69; SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; ChatQueueRequest chat_queue = 73; + ChatEventReplayRequest chat_event_replay = 74; } } @@ -127,6 +128,7 @@ message AgentEnvelope { RuntimeStatusEvent runtime_status = 71; SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; ChatRuntimeSnapshot chat_runtime_snapshot = 77; + ChatEventReplayResponse chat_event_replay_resp = 78; ErrorResponse error = 99; } } @@ -571,6 +573,24 @@ message RuntimeStatusEvent { int64 timestamp = 5; } +message ChatEventReplayRequest { + string run_id = 1; + string conversation_id = 2; + int64 after_seq = 3; +} + +message ChatEventReplayResponse { + string run_id = 1; + string conversation_id = 2; + repeated ChatReplayEvent events = 3; + bool complete = 4; +} + +message ChatReplayEvent { + int64 seq = 1; + string event_json = 2; +} + message CronManageRequest { string action = 1; string task_id = 2; diff --git a/crates/agent-gateway/test/session/chat_event_store_test.go b/crates/agent-gateway/test/session/chat_event_store_test.go deleted file mode 100644 index bc2142e1..00000000 --- a/crates/agent-gateway/test/session/chat_event_store_test.go +++ /dev/null @@ -1,557 +0,0 @@ -package session_test - -import ( - "errors" - "path/filepath" - "strings" - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -func newPersistentTestSessionManager(t *testing.T, path string) (*session.Manager, session.ChatEventStore) { - t.Helper() - store, err := session.OpenSQLiteChatEventStore(path) - if err != nil { - t.Fatalf("OpenSQLiteChatEventStore: %v", err) - } - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - return sm, store -} - -type staleReplayChatEventStore struct { - snapshot session.ChatRunSnapshot - replay []*session.ChatBroadcastEvent -} - -func readChatBroadcastPayloadType( - t *testing.T, - ch <-chan *session.ChatBroadcastEvent, - label string, -) string { - t.Helper() - select { - case event := <-ch: - if event.Payload != nil { - eventType, _ := event.Payload["type"].(string) - return eventType - } - if event.Control != nil { - return event.Control.GetType() - } - if event.Event != nil { - return event.Event.GetType().String() - } - return "" - case <-time.After(time.Second): - t.Fatalf("timed out waiting for %s", label) - } - return "" -} - -func (s *staleReplayChatEventStore) StartRun(input session.ChatRunStoreStart) (session.ChatRunSnapshot, bool, error) { - return session.ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: 1, - State: session.ChatRunStateQueued, - }, true, nil -} - -func (s *staleReplayChatEventStore) AppendEvents([]session.ChatRunEventAppend) error { - return nil -} - -func (s *staleReplayChatEventStore) Replay( - string, - string, - int64, - int, -) (session.ChatRunSnapshot, []*session.ChatBroadcastEvent, bool, error) { - return s.snapshot, s.replay, true, nil -} - -func (s *staleReplayChatEventStore) Close() error { - return nil -} - -func TestSQLiteChatEventStoreReplaysCompletedRunAndDedupesCommand(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - snapshot, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || snapshot.RequestID != "request-1" { - t.Fatalf("created snapshot = %#v created=%v", snapshot, created) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "hello", - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"hi"}`, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - duplicate, created, err := next.StartPendingChatCommandRun( - "request-2", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun duplicate: %v", err) - } - if created || duplicate.RequestID != "request-1" || duplicate.LatestSeq != 4 { - t.Fatalf("duplicate snapshot = %#v created=%v, want original completed run", duplicate, created) - } - - ch, _, cleanup, replaySnapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - if replaySnapshot.RequestID != "request-1" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("replay snapshot = %#v", replaySnapshot) - } - - gotTypes := make([]string, 0, 4) - for len(gotTypes) < 4 { - select { - case event := <-ch: - eventType, _ := event.Payload["type"].(string) - gotTypes = append(gotTypes, eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for replay, got types %#v", gotTypes) - } - } - want := []string{"accepted", "user_message", "token", "done"} - if len(gotTypes) != len(want) { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, want) - } - for index := range want { - if gotTypes[index] != want[index] { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, want) - } - } -} - -func TestSQLiteHistoryRunningDoesNotCreateAttachableConversationRun(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - defer store.Close() - - dispatchRunning := func() { - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-running", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, - }, - }) - } - - dispatchRunning() - _, done, cleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - cleanup() - assertDoneClosed(t, done) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun first running = %v, want ErrChatRunNotFound", err) - } - if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { - t.Fatalf("active summaries after history running = %#v, want empty", summaries) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - - dispatchRunning() - snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateCompleted || !snapshot.Done { - t.Fatalf("completed snapshot after history running = %#v ok=%v", snapshot, ok) - } - if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { - t.Fatalf("active summaries after completed history running = %#v, want empty", summaries) - } -} - -func TestSQLiteRuntimeSnapshotReplaysAttachableConversationRun(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - ClientRequestId: "client-1", - WorkerId: "gui-live", - State: session.ChatRunStateRunning, - Cwd: "/workspace", - Revision: 7, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"partial","round":1}]`, - ToolStatus: "Thinking...", - ToolStatusIsCompaction: false, - }) - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - summaries := next.ActiveChatRunSummaries() - if len(summaries) != 0 { - t.Fatalf("active summaries before replay = %#v, want lazy hydration only", summaries) - } - - ch, done, cleanup, snapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "run-1" || - snapshot.State != session.ChatRunStateRunning || - snapshot.Done || - snapshot.Workdir != "/workspace" { - t.Fatalf("snapshot after runtime replay = %#v, want running run-1", snapshot) - } - - select { - case event := <-ch: - eventType, _ := event.Payload["type"].(string) - entriesJSON, _ := event.Payload["entries_json"].(string) - if eventType != "runtime_snapshot" || !strings.Contains(entriesJSON, "partial") { - t.Fatalf("replayed runtime snapshot = %#v, want partial runtime_snapshot", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for runtime snapshot replay") - } -} - -func TestSQLiteChatEventStoreDoesNotPersistToolCallDeltaPayloads(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun live: %v", err) - } - defer cleanup() - - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "tool_call_delta", - "id": "call-write", - "name": "Write", - "arguments": map[string]any{"path": "src/app.ts", "content": "con"}, - }) - - if got := readChatBroadcastPayloadType(t, ch, "live accepted"); got != "accepted" { - t.Fatalf("live event type = %q, want accepted", got) - } - if got := readChatBroadcastPayloadType(t, ch, "live tool_call_delta"); got != "tool_call_delta" { - t.Fatalf("live event type = %q, want tool_call_delta", got) - } - - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "tool_call", - "id": "call-write", - "name": "Write", - "arguments": map[string]any{"path": "src/app.ts", "content": "console.log(1);\n"}, - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - replayCh, _, replayCleanup, replaySnapshot, err := next.SubscribeChatRun( - "request-1", - "conversation-1", - 0, - ) - if err != nil { - t.Fatalf("SubscribeChatRun replay: %v", err) - } - defer replayCleanup() - if replaySnapshot.LatestSeq != 4 { - t.Fatalf("replay latest seq = %d, want 4", replaySnapshot.LatestSeq) - } - - gotTypes := []string{ - readChatBroadcastPayloadType(t, replayCh, "replayed accepted"), - readChatBroadcastPayloadType(t, replayCh, "replayed tool_call"), - readChatBroadcastPayloadType(t, replayCh, "replayed completed"), - } - wantTypes := []string{"accepted", "tool_call", "completed"} - for index := range wantTypes { - if gotTypes[index] != wantTypes[index] { - t.Fatalf("replayed event types = %#v, want %#v", gotTypes, wantTypes) - } - } -} - -func TestSubscribeChatRunMergesStalePersistedReplayWithBufferedEvents(t *testing.T) { - store := &staleReplayChatEventStore{} - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "hello", - }) - store.snapshot = session.ChatRunSnapshot{ - RequestID: "request-1", - ConversationID: "conversation-1", - ClientRequestID: "client-submit-1", - RunEpoch: 1, - LatestSeq: 1, - State: session.ChatRunStateQueued, - } - store.replay = []*session.ChatBroadcastEvent{ - { - RequestID: "request-1", - Seq: 1, - Payload: map[string]any{ - "type": "accepted", - "conversation_id": "conversation-1", - }, - }, - } - - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - - gotTypes := make([]string, 0, 2) - for len(gotTypes) < 2 { - select { - case event := <-ch: - eventType := "" - if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } else if event.Control != nil { - eventType = event.Control.GetType() - } - gotTypes = append(gotTypes, eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for merged replay, got %#v", gotTypes) - } - } - want := []string{"accepted", "user_message"} - for index := range want { - if gotTypes[index] != want[index] { - t.Fatalf("merged replay types = %#v, want %#v", gotTypes, want) - } - } -} - -func TestSQLiteChatEventStoreContinuesConversationSeqAcrossRuns(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - snapshot, created, err := next.StartPendingChatCommandRun( - "request-2", - "conversation-1", - "client-submit-2", - ) - if err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - if snapshot.LatestSeq != 3 { - t.Fatalf("second run initial snapshot = %#v, want latest seq 3", snapshot) - } - next.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - ch, _, cleanup, replaySnapshot, err := next.SubscribeChatRun("request-2", "conversation-1", 3) - if err != nil { - t.Fatalf("SubscribeChatRun request-2: %v", err) - } - defer cleanup() - if replaySnapshot.LatestSeq != 4 { - t.Fatalf("second replay snapshot = %#v, want latest seq 4", replaySnapshot) - } - select { - case event := <-ch: - if event.Seq != 4 { - t.Fatalf("second run accepted seq = %d, want 4", event.Seq) - } - eventType, _ := event.Payload["type"].(string) - if eventType != "accepted" { - t.Fatalf("second run event type = %q, want accepted", eventType) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for second run accepted event") - } - - conversationCh, _, conversationCleanup, conversationSnapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer conversationCleanup() - if conversationSnapshot.RequestID != "request-2" || conversationSnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", conversationSnapshot) - } - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-conversationCh: - eventType, _ := event.Payload["type"].(string) - got = append(got, event.RequestID+":"+eventType) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - want := []string{ - "request-1:accepted", - "request-1:user_message", - "request-1:completed", - "request-2:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation replay = %#v, want %#v", got, want) - } - } -} - -func TestSQLiteChatEventStorePreservesOpenRunsOnManagerStartup(t *testing.T) { - t.Parallel() - - dbPath := filepath.Join(t.TempDir(), "gateway-chat.sqlite3") - sm, store := newPersistentTestSessionManager(t, dbPath) - if _, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - if err := store.Close(); err != nil { - t.Fatalf("close first store: %v", err) - } - - next, nextStore := newPersistentTestSessionManager(t, dbPath) - defer nextStore.Close() - ch, _, cleanup, snapshot, err := next.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cleanup() - if snapshot.State != session.ChatRunStateQueued || snapshot.Done || snapshot.LatestSeq != 1 { - t.Fatalf("snapshot after restart = %#v, want open queued run", snapshot) - } - - select { - case event := <-ch: - eventType, _ := event.Payload["type"].(string) - if eventType != "accepted" { - t.Fatalf("replayed event type = %q, want accepted", eventType) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for accepted replay") - } - select { - case event := <-ch: - t.Fatalf("unexpected replay after preserved open run: %#v", event) - default: - } -} diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 761476c3..761d7744 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "sync" "testing" "time" @@ -239,18 +238,18 @@ func TestChatRunSeqContinuesWithinConversation(t *testing.T) { t.Fatalf("second snapshot = %#v ok=%v, want latest seq 4", snapshot, ok) } - ch, _, cleanup, replaySnapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun conversation replay: %v", err) } - defer cleanup() - if replaySnapshot.RequestID != "request-2" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", replaySnapshot) + defer sub.Cleanup() + if sub.Snapshot.RequestID != "request-2" || sub.Snapshot.LatestSeq != 4 { + t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", sub.Snapshot) } got := make([]string, 0, 4) for len(got) < 4 { select { - case event := <-ch: + case event := <-sub.EventCh: eventType := "" if event.Control != nil { eventType = event.Control.GetType() @@ -294,20 +293,20 @@ func TestSubscribeChatRunConversationReplayAttachesLatestLiveRun(t *testing.T) { } sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - ch, done, cleanup, replaySnapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + replaySub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun conversation replay: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if replaySnapshot.RequestID != "request-2" || replaySnapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want live request-2 seq 4", replaySnapshot) + defer replaySub.Cleanup() + assertDoneOpen(t, replaySub.Done) + if replaySub.Snapshot.RequestID != "request-2" || replaySub.Snapshot.LatestSeq != 4 { + t.Fatalf("conversation replay snapshot = %#v, want live request-2 seq 4", replaySub.Snapshot) } got := make([]string, 0, 4) for len(got) < 4 { select { - case event := <-ch: + case event := <-replaySub.EventCh: eventType := "" if event.Control != nil { eventType = event.Control.GetType() @@ -331,7 +330,7 @@ func TestSubscribeChatRunConversationReplayAttachesLatestLiveRun(t *testing.T) { }, }) select { - case event := <-ch: + case event := <-replaySub.EventCh: if event.RequestID != "request-2" || event.Seq != 5 || event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { t.Fatalf("live event after replay = %#v, want request-2 token seq 5", event) } @@ -340,158 +339,6 @@ func TestSubscribeChatRunConversationReplayAttachesLatestLiveRun(t *testing.T) { } } -type blockingAppendChatEventStore struct { - mu sync.Mutex - appendCalls int - appendEntered chan struct{} - releaseFirst chan struct{} -} - -func newBlockingAppendChatEventStore() *blockingAppendChatEventStore { - return &blockingAppendChatEventStore{ - appendEntered: make(chan struct{}), - releaseFirst: make(chan struct{}), - } -} - -func (s *blockingAppendChatEventStore) StartRun(input session.ChatRunStoreStart) (session.ChatRunSnapshot, bool, error) { - return session.ChatRunSnapshot{ - RequestID: input.RequestID, - ConversationID: input.ConversationID, - ClientRequestID: input.ClientRequestID, - Workdir: input.Workdir, - RunEpoch: 1, - State: session.ChatRunStateQueued, - }, true, nil -} - -func (s *blockingAppendChatEventStore) AppendEvents(inputs []session.ChatRunEventAppend) error { - if len(inputs) == 0 { - return nil - } - s.mu.Lock() - s.appendCalls += 1 - call := s.appendCalls - s.mu.Unlock() - if call == 1 { - close(s.appendEntered) - <-s.releaseFirst - } - return nil -} - -func (s *blockingAppendChatEventStore) Replay(string, string, int64, int) (session.ChatRunSnapshot, []*session.ChatBroadcastEvent, bool, error) { - return session.ChatRunSnapshot{}, nil, false, nil -} - -func (s *blockingAppendChatEventStore) Close() error { - return nil -} - -func TestChatEventStoreAppendDoesNotHoldChatLock(t *testing.T) { - t.Parallel() - - store := newBlockingAppendChatEventStore() - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - - firstDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - close(firstDone) - }() - - select { - case <-store.appendEntered: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to block") - } - - secondDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "projection_updated", - "message": "second", - }) - close(secondDone) - }() - - select { - case <-secondDone: - case <-time.After(time.Second): - t.Fatal("second chat payload blocked behind event store append") - } - - snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1") - if !ok || snapshot.LatestSeq != 2 { - t.Fatalf("snapshot = %#v ok=%v, want latest seq 2 while first append is still blocked", snapshot, ok) - } - - close(store.releaseFirst) - select { - case <-firstDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to finish") - } -} - -func TestStartAcceptedChatCommandUsesInMemoryConversationSeqDuringPersistLag(t *testing.T) { - t.Parallel() - - store := newBlockingAppendChatEventStore() - sm, err := session.NewManagerWithChatEventStore(store) - if err != nil { - t.Fatalf("NewManagerWithChatEventStore: %v", err) - } - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - - firstDone := make(chan struct{}) - go func() { - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - close(firstDone) - }() - - select { - case <-store.appendEntered: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to block") - } - - next, created, acceptedSeq, err := sm.StartAcceptedChatCommandRun( - "request-2", - "conversation-1", - "client-2", - "", - nil, - ) - if err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun request-2 created=%v err=%v", created, err) - } - if acceptedSeq != 2 || next.LatestSeq != 2 { - t.Fatalf("second run snapshot = %#v acceptedSeq=%d, want seq 2", next, acceptedSeq) - } - - close(store.releaseFirst) - select { - case <-firstDone: - case <-time.After(time.Second): - t.Fatal("timed out waiting for first append to finish") - } -} - func TestClearSessionIfHeartbeatStalePreservesOpenChatRuns(t *testing.T) { t.Parallel() @@ -505,19 +352,19 @@ func TestClearSessionIfHeartbeatStalePreservesOpenChatRuns(t *testing.T) { ); err != nil { t.Fatalf("StartPendingChatCommandRun: %v", err) } - ch, done, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() time.Sleep(time.Millisecond) if !sm.ClearSessionIfHeartbeatStale(sess, time.Nanosecond) { t.Fatalf("current stale session was not cleared") } - assertDoneOpen(t, done) + assertDoneOpen(t, sub.Done) select { - case event := <-ch: + case event := <-sub.EventCh: t.Fatalf("unexpected chat event after heartbeat timeout: %#v", event) default: } @@ -641,26 +488,26 @@ func TestRemoveChatRunByConversationReleasesBufferedRun(t *testing.T) { sm := newTestSessionManager() startRunningChatCommandRun(t, sm, "request-1", "conversation-1") - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun before remove: %v", err) } - if snapshot.RequestID != "request-1" { - t.Fatalf("snapshot request id = %q, want request-1", snapshot.RequestID) + if sub.Snapshot.RequestID != "request-1" { + t.Fatalf("snapshot request id = %q, want request-1", sub.Snapshot.RequestID) } sm.RemoveChatRunByConversation("conversation-1") - assertDoneClosed(t, done) - cleanup() + assertDoneClosed(t, sub.Done) + sub.Cleanup() select { - case event := <-ch: + case event := <-sub.EventCh: t.Fatalf("unexpected replay event after remove: %#v", event) default: } - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) + missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) + defer missingSub.Cleanup() + assertDoneClosed(t, missingSub.Done) if !errors.Is(err, session.ErrChatRunNotFound) { t.Fatalf("SubscribeChatRun after remove = %v, want ErrChatRunNotFound", err) } @@ -672,8 +519,7 @@ func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { sm := newTestSessionManager() first, created, err := sm.StartPendingChatCommandRun( "request-1", - "", - "client-submit-1", + "conversation-1", ) if err != nil { t.Fatalf("StartPendingChatCommandRun first: %v", err) @@ -684,14 +530,9 @@ func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { if first.RequestID != "request-1" { t.Fatalf("first request id = %q, want request-1", first.RequestID) } - if first.ClientRequestID != "client-submit-1" { - t.Fatalf("first client request id = %q, want client-submit-1", first.ClientRequestID) - } - duplicate, created, err := sm.StartPendingChatCommandRun( - "request-2", - "", - "client-submit-1", + "request-1", + "conversation-1", ) if err != nil { t.Fatalf("StartPendingChatCommandRun duplicate: %v", err) @@ -703,18 +544,10 @@ func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { t.Fatalf("duplicate request id = %q, want original request-1", duplicate.RequestID) } - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("request-2", "", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun duplicate request = %v, want ErrChatRunNotFound", err) - } - sm.RemoveChatRun("request-1") restarted, created, err := sm.StartPendingChatCommandRun( - "request-3", - "", - "client-submit-1", + "request-2", + "conversation-1", ) if err != nil { t.Fatalf("StartPendingChatCommandRun after remove: %v", err) @@ -722,8 +555,8 @@ func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { if !created { t.Fatalf("restarted run created = false, want true") } - if restarted.RequestID != "request-3" { - t.Fatalf("restarted request id = %q, want request-3", restarted.RequestID) + if restarted.RequestID != "request-2" { + t.Fatalf("restarted request id = %q, want request-2", restarted.RequestID) } } @@ -748,11 +581,11 @@ func TestPendingChatRunIsAdvertisedBeforeStartedEvent(t *testing.T) { t.Fatalf("pending active chat runs = %#v, want conversation-1", got) } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() dispatchChatControl(sm, "request-1", "conversation-1", "delivered", session.ChatRunStateDelivered) if got := activeChatRunConversationIDs(sm); fmt.Sprint(got) != fmt.Sprint([]string{"conversation-1"}) { @@ -770,7 +603,7 @@ func TestPendingChatRunIsAdvertisedBeforeStartedEvent(t *testing.T) { t.Fatalf("active chat runs after started = %#v, want %#v", got, want) } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Control == nil || event.Control.GetType() != "delivered" { t.Fatalf("first control event = %#v, want delivered", event) } @@ -778,7 +611,7 @@ func TestPendingChatRunIsAdvertisedBeforeStartedEvent(t *testing.T) { t.Fatalf("timed out waiting for delivered control event") } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Control == nil || event.Control.GetType() != "started" { t.Fatalf("second control event = %#v, want started", event) } @@ -799,18 +632,18 @@ func TestFailStartingChatRunBroadcastsErrorAndClearsActiveSummary(t *testing.T) ); err != nil { t.Fatalf("StartPendingChatCommandRun: %v", err) } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() if !sm.FailStartingChatRun("request-1", "desktop did not accept") { t.Fatalf("FailStartingChatRun returned false") } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) } @@ -823,8 +656,8 @@ func TestFailStartingChatRunBroadcastsErrorAndClearsActiveSummary(t *testing.T) if got := activeChatRunConversationIDs(sm); len(got) != 0 { t.Fatalf("active chat runs after failed start = %#v, want empty", got) } - if status := sm.Status(); status.Online { - t.Fatalf("status online = true after chat run failed before desktop accept") + if status := sm.Status(); !status.Online { + t.Fatalf("status online = false, agent session should remain active after individual chat run failure") } } @@ -840,11 +673,11 @@ func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { ); err != nil { t.Fatalf("StartPendingChatCommandRun request-1: %v", err) } - ch, _, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() if sm.FailUnstartedChatRun("request-1", "desktop app did not start") { t.Fatalf("unaccepted pending run should not fail the render-start watchdog") } @@ -854,7 +687,7 @@ func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { t.Fatalf("FailUnstartedChatRun returned false for accepted pending run") } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Control == nil || event.Control.GetType() != "delivered" { t.Fatalf("event = %#v, want delivered control", event) } @@ -862,7 +695,7 @@ func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { t.Fatalf("timed out waiting for delivered control event") } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { t.Fatalf("event = %#v, want ERROR", event) } @@ -930,18 +763,18 @@ func TestTerminalChatRunStateIsImmutable(t *testing.T) { }, }) - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.State != session.ChatRunStateFailed { - t.Fatalf("terminal state = %q, want %q", snapshot.State, session.ChatRunStateFailed) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) + if sub.Snapshot.State != session.ChatRunStateFailed { + t.Fatalf("terminal state = %q, want %q", sub.Snapshot.State, session.ChatRunStateFailed) } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { t.Fatalf("replayed event = %#v, want ERROR", event) } @@ -949,7 +782,7 @@ func TestTerminalChatRunStateIsImmutable(t *testing.T) { t.Fatalf("timed out waiting for replayed error event") } select { - case event := <-ch: + case event := <-sub.EventCh: t.Fatalf("terminal completion control should be ignored after failure: %#v", event) default: } @@ -972,18 +805,18 @@ func TestDesktopBroadcastChatEventCreatesAttachableRun(t *testing.T) { }, }) - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "desktop-run-1" { - t.Fatalf("snapshot request id = %q, want desktop-run-1", snapshot.RequestID) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) + if sub.Snapshot.RequestID != "desktop-run-1" { + t.Fatalf("snapshot request id = %q, want desktop-run-1", sub.Snapshot.RequestID) } select { - case event := <-ch: + case event := <-sub.EventCh: if event.Seq != 1 { t.Fatalf("event seq = %d, want 1", event.Seq) } @@ -998,6 +831,54 @@ func TestDesktopBroadcastChatEventCreatesAttachableRun(t *testing.T) { } } +func TestDesktopStartedControlCreatesAttachableRun(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + + // GUI-local runs (e.g. queue auto-send) announce themselves with a bare + // "started" control before any chat event or runtime snapshot arrives. + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "desktop-local-run-1", + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: "desktop-local-run-1", + ConversationId: "conversation-local", + Type: "started", + }, + }, + }) + + snapshot, ok := sm.RunningChatRunSnapshot("conversation-local") + if !ok { + t.Fatalf("RunningChatRunSnapshot: run for bare started control not registered") + } + if snapshot.RequestID != "desktop-local-run-1" { + t.Fatalf("snapshot request id = %q, want desktop-local-run-1", snapshot.RequestID) + } + + summary, ok := sm.ConversationRunSummary("conversation-local") + if !ok || summary.RequestID != "desktop-local-run-1" { + t.Fatalf("ConversationRunSummary = %#v ok=%v, want desktop-local-run-1", summary, ok) + } + + // A stale "completed" control for an unknown run must not resurrect a run. + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "desktop-unknown-run", + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: "desktop-unknown-run", + ConversationId: "conversation-unknown", + Type: "completed", + }, + }, + }) + if _, ok := sm.ChatRunSnapshot("desktop-unknown-run", ""); ok { + t.Fatalf("completed control for unknown run must not create a run") + } +} + func TestChatRuntimeSnapshotCreatesAttachableConversationRun(t *testing.T) { t.Parallel() @@ -1030,18 +911,18 @@ func TestChatRuntimeSnapshotCreatesAttachableConversationRun(t *testing.T) { t.Fatalf("active summaries = %#v, want snapshot-backed run-1", summaries) } - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "run-1" || snapshot.State != session.ChatRunStateRunning || snapshot.Done { - t.Fatalf("snapshot = %#v, want running run-1", snapshot) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) + if sub.Snapshot.RequestID != "run-1" || sub.Snapshot.State != session.ChatRunStateRunning || sub.Snapshot.Done { + t.Fatalf("snapshot = %#v, want running run-1", sub.Snapshot) } select { - case event := <-ch: + case event := <-sub.EventCh: if event.RequestID != "run-1" || event.Seq != 1 { t.Fatalf("runtime snapshot event = %#v, want run-1 seq 1", event) } @@ -1071,12 +952,12 @@ func TestChatRuntimeSnapshotTerminalClosesSubscribers(t *testing.T) { EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]}]`, }) - ch, done, cleanup, _, err := sm.SubscribeChatRun("run-1", "conversation-1", 1) + sub, err := sm.SubscribeChatRun("run-1", "conversation-1", 1) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ ConversationId: "conversation-1", @@ -1087,7 +968,7 @@ func TestChatRuntimeSnapshotTerminalClosesSubscribers(t *testing.T) { }) select { - case event := <-ch: + case event := <-sub.EventCh: eventType, _ := event.Payload["type"].(string) state, _ := event.Payload["state"].(string) if eventType != "runtime_snapshot" || state != session.ChatRunStateCompleted { @@ -1096,7 +977,7 @@ func TestChatRuntimeSnapshotTerminalClosesSubscribers(t *testing.T) { case <-time.After(time.Second): t.Fatalf("timed out waiting for terminal runtime snapshot") } - assertDoneClosed(t, done) + assertDoneClosed(t, sub.Done) if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { t.Fatalf("active summaries after terminal snapshot = %#v, want empty", summaries) } @@ -1168,20 +1049,20 @@ func TestChatRuntimeSnapshotTerminalCanFollowDoneEvent(t *testing.T) { EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"final","round":1}]`, }) - ch, _, cleanup, snapshot, err := sm.SubscribeChatRun("run-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("run-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - if snapshot.State != session.ChatRunStateCompleted || !snapshot.Done || snapshot.LatestSeq != 3 { - t.Fatalf("snapshot = %#v, want completed seq 3", snapshot) + defer sub.Cleanup() + if sub.Snapshot.State != session.ChatRunStateCompleted || !sub.Snapshot.Done || sub.Snapshot.LatestSeq != 3 { + t.Fatalf("snapshot = %#v, want completed seq 3", sub.Snapshot) } gotRuntimeSnapshots := 0 gotFinalSnapshot := false for gotRuntimeSnapshots < 2 { select { - case event := <-ch: + case event := <-sub.EventCh: if event.Payload == nil { continue } @@ -1228,9 +1109,9 @@ func TestHistoryRunningDoesNotCreateAttachableConversationRun(t *testing.T) { t.Fatalf("active summaries = %#v, want empty", summaries) } - _, done, cleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer cleanup() - assertDoneClosed(t, done) + missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) + defer missingSub.Cleanup() + assertDoneClosed(t, missingSub.Done) if !errors.Is(err, session.ErrChatRunNotFound) { t.Fatalf("SubscribeChatRun = %v, want ErrChatRunNotFound", err) } @@ -1242,7 +1123,7 @@ func TestHistoryRunningDoesNotPromoteDesktopQueuedCommandRun(t *testing.T) { sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "client-1", "/workspace", []map[string]any{{ + if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "/workspace", []map[string]any{{ "type": "user_message", "message": "queued prompt", }}); err != nil || !created { @@ -1296,20 +1177,20 @@ func TestHistoryRunningDoesNotPromoteDesktopQueuedCommandRun(t *testing.T) { t.Fatalf("active summaries after started = %#v, want request-queued replay cursor", summaries) } - ch, done, cleanup, snapshot, err := sm.SubscribeChatRun("request-queued", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-queued", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "request-queued" || snapshot.State != session.ChatRunStateRunning { - t.Fatalf("snapshot = %#v, want running request-queued", snapshot) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) + if sub.Snapshot.RequestID != "request-queued" || sub.Snapshot.State != session.ChatRunStateRunning { + t.Fatalf("snapshot = %#v, want running request-queued", sub.Snapshot) } got := make([]string, 0, 4) for len(got) < 4 { select { - case event := <-ch: + case event := <-sub.EventCh: eventType := "" if event.Control != nil { eventType = event.Control.GetType() @@ -1359,7 +1240,7 @@ func TestQueuedRunStartedHistoryEventSurvivesFullSubscriberQueue(t *testing.T) { }) } - if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "client-1", "/workspace", []map[string]any{{ + if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "/workspace", []map[string]any{{ "type": "user_message", "message": "queued prompt", }}); err != nil || !created { @@ -1460,14 +1341,14 @@ func TestStartedRunKeepsConversationOwnerWhenPreviousRunEmitsLateEvent(t *testin t.Fatalf("active summaries = %#v, want request-new", summaries) } - _, done, cleanup, snapshot, err := sm.SubscribeChatRun("", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() - assertDoneOpen(t, done) - if snapshot.RequestID != "request-new" { - t.Fatalf("conversation snapshot request id = %q, want request-new", snapshot.RequestID) + defer sub.Cleanup() + assertDoneOpen(t, sub.Done) + if sub.Snapshot.RequestID != "request-new" { + t.Fatalf("conversation snapshot request id = %q, want request-new", sub.Snapshot.RequestID) } } @@ -1478,11 +1359,11 @@ func TestCompletedHistoryUpsertDoesNotPreemptTerminalChatEvent(t *testing.T) { sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) started := startRunningChatCommandRun(t, sm, "request-1", "conversation-1") - ch, done, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", started.LatestSeq) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", started.LatestSeq) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ RequestId: "request-1", @@ -1507,9 +1388,8 @@ func TestCompletedHistoryUpsertDoesNotPreemptTerminalChatEvent(t *testing.T) { }, }) - assertDoneOpen(t, done) select { - case event := <-ch: + case event := <-sub.EventCh: if event.Event.GetType() != gatewayv1.ChatEvent_DONE { t.Fatalf("event type = %v, want DONE", event.Event.GetType()) } @@ -1517,9 +1397,9 @@ func TestCompletedHistoryUpsertDoesNotPreemptTerminalChatEvent(t *testing.T) { t.Fatalf("timed out waiting for terminal chat event") } - _, missingDone, missingCleanup, _, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingCleanup() - assertDoneClosed(t, missingDone) + missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) + defer missingSub.Cleanup() + assertDoneClosed(t, missingSub.Done) if !errors.Is(err, session.ErrChatRunNotFound) { t.Fatalf("SubscribeChatRun after release = %v, want ErrChatRunNotFound", err) } @@ -1571,18 +1451,18 @@ func TestClearSessionPreservesOpenChatRuns(t *testing.T) { t.Fatalf("first run = %#v created=%v", first, created) } - ch, done, cleanup, _, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) + sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) if err != nil { t.Fatalf("SubscribeChatRun: %v", err) } - defer cleanup() + defer sub.Cleanup() sm.ClearSession(sess) assertDoneClosed(t, sess.Done()) - assertDoneOpen(t, done) + assertDoneOpen(t, sub.Done) select { - case event := <-ch: + case event := <-sub.EventCh: t.Fatalf("unexpected chat event after session clear: %#v", event) default: } @@ -1594,9 +1474,8 @@ func TestClearSessionPreservesOpenChatRuns(t *testing.T) { } restarted, created, err := sm.StartPendingChatCommandRun( - "request-2", + "request-1", "conversation-1", - "client-submit-1", ) if err != nil { t.Fatalf("StartPendingChatCommandRun retry: %v", err) diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index bb0a9e4e..4161c6b7 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -90,6 +90,7 @@ import { } from "@/lib/chat/historyListScope"; import { buildOptimisticConversationTitle, + chatEntryDedupKey, pushChatEvent, resolveConversationBrowserTitle, type ChatEntry, @@ -130,11 +131,7 @@ function shouldHydrateRestoredConversationSnapshot(params: { } import { memoryDeleteProject } from "@/lib/memory/api"; function chatEntryContentKey(entry: ChatEntry) { - if (entry.kind === "user") { - return JSON.stringify({ kind: entry.kind, text: entry.text }); - } - const { id: _id, ...rest } = entry; - return JSON.stringify(rest); + return chatEntryDedupKey(entry); } function hasEquivalentTailEntries(existing: ChatEntry[], tail: ChatEntry[]) { @@ -3591,9 +3588,33 @@ export default function GatewayApp() { const lockedConversationIds = new Set([activeConversationId]); chatStartLocksRef.current.add(activeConversationId); - commitConversationLiveStreamToRuntime(activeConversationId, { - clearLiveStream: true, - }); + // Committing a retained live reply moves it from the live section into the + // virtualized history list. For the visible conversation, run that move as + // one synchronous, scroll-compensated commit — otherwise the virtualizer + // paints a frame with estimated row heights and the transcript visibly + // jumps right as the prompt is sent. + const retainedLiveEntryCount = + liveConversationStreamStoresRef.current.get(activeConversationId)?.getSnapshot().entries + .length ?? 0; + const isVisibleConversationAtSend = + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + activeConversationId; + if (retainedLiveEntryCount > 0 && isVisibleConversationAtSend) { + preserveTranscriptScrollPosition( + () => { + flushSync(() => { + commitConversationLiveStreamToRuntime(activeConversationId, { + clearLiveStream: true, + }); + }); + }, + { stickToBottom: isTranscriptAtBottom() }, + ); + } else { + commitConversationLiveStreamToRuntime(activeConversationId, { + clearLiveStream: true, + }); + } getConversationLiveStreamStore(activeConversationId); const controller = new AbortController(); setConversationAbortController(activeConversationId, controller); @@ -3854,6 +3875,7 @@ export default function GatewayApp() { activeConversationId, preserveRemoteRunCleanupOptions(), ); + break; } continue; } @@ -3899,6 +3921,7 @@ export default function GatewayApp() { api, preserveRemoteRunCleanupOptions(), ); + break; } else if (isRuntimeStartedChatControlEvent(event)) { markRuntimeStarted(); updateConversationRuntimeEntry(activeConversationId, (current) => ({ @@ -3938,11 +3961,20 @@ export default function GatewayApp() { if (terminalEvent) { terminalEventSeen = true; clearRuntimeStartingStatusTimer(); + const liveTitle = readChatEventTitle(event); + if (liveTitle && isChatEventTitleFinal(event)) { + applyLiveConversationTitle( + event.conversation_id?.trim() || activeConversationId, + liveTitle, + { isFinal: true }, + ); + } finalizeTerminalLiveStream( activeConversationId, api, preserveRemoteRunCleanupOptions(), ); + break; } } const liveTitle = readChatEventTitle(event); @@ -5159,12 +5191,6 @@ export default function GatewayApp() { } }; - void hydrateEditPrefix().catch((error) => { - if (isCurrentEditTransaction()) { - console.warn("edit resend history prefix hydration failed", error); - } - }); - try { const resendPromise = sendChatRef.current?.(normalized, { @@ -5175,6 +5201,16 @@ export default function GatewayApp() { optimisticUserEntryId: optimisticEditUserEntryId, skipOptimisticUserEntry: true, }) ?? Promise.resolve(); + // Start prefix hydration AFTER sendChat so the chat.command WebSocket + // message is queued before history.prefix. The desktop agent processes + // inbound gRPC envelopes sequentially — sending history.prefix first + // would block the lightweight chat.edit_resend dispatch behind a heavy + // DB read, adding unnecessary "Preparing request…" latency. + void hydrateEditPrefix().catch((error) => { + if (isCurrentEditTransaction()) { + console.warn("edit resend history prefix hydration failed", error); + } + }); await resendPromise; if (isCurrentEditTransaction()) { await refreshVisibleConversationHistorySnapshot(activeConversationId, api, { diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index cb8937f7..59de7b09 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -47,18 +47,14 @@ import { import { buildTranscriptItems, + chatEntryDedupKey, type ChatEntry, type GatewayTranscriptItem, type GatewayTranscriptRound, } from "../lib/chatUi"; function entryMatchesForDedup(a: ChatEntry, b: ChatEntry): boolean { if (a.kind !== b.kind) return false; - if (a.kind === "user" && b.kind === "user") { - return a.text === b.text; - } - const { id: _a, ...restA } = a; - const { id: _b, ...restB } = b; - return JSON.stringify(restA) === JSON.stringify(restB); + return chatEntryDedupKey(a) === chatEntryDedupKey(b); } function omitEquivalentTailEntries(existing: ChatEntry[], live: ChatEntry[]) { diff --git a/crates/agent-gateway/web/src/lib/chatUi.ts b/crates/agent-gateway/web/src/lib/chatUi.ts index c1255573..ffb022f8 100644 --- a/crates/agent-gateway/web/src/lib/chatUi.ts +++ b/crates/agent-gateway/web/src/lib/chatUi.ts @@ -117,6 +117,41 @@ export type ChatEntry = } | { id: string; kind: "error"; text: string }; +// Stable content identity for deduplicating the same logical entry across the +// three transcript sources (live stream, committed runtime messages, parsed +// history). Tool entries are compared by identity rather than content: the +// gateway trims large tool arguments/results on the live path and tool result +// timestamps differ per parse, so content comparison would classify every +// live entry as different from its persisted twin — forcing a full transcript +// replacement (and a visible flash) whenever history is refreshed. +export function chatEntryDedupKey(entry: ChatEntry): string { + switch (entry.kind) { + case "user": + return JSON.stringify({ kind: entry.kind, text: entry.text }); + case "assistant": + return JSON.stringify({ kind: entry.kind, text: entry.text, round: entry.round ?? 0 }); + case "thinking": + return JSON.stringify({ kind: entry.kind, text: entry.text, round: entry.round ?? 0 }); + case "tool_call": + return JSON.stringify({ + kind: entry.kind, + identity: entry.toolCall.id.trim() || entry.toolCall.name, + round: entry.round ?? 0, + }); + case "tool_result": + return JSON.stringify({ + kind: entry.kind, + identity: entry.toolResult.toolCallId.trim() || entry.toolResult.toolName, + isError: Boolean(entry.toolResult.isError), + round: entry.round ?? 0, + }); + default: { + const { id: _id, ...rest } = entry; + return JSON.stringify(rest); + } + } +} + type StoredMessage = { role?: unknown; id?: unknown; diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 323a7c4d..c25f6115 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -33,22 +33,11 @@ import type { SftpTransferResponse, } from "@/lib/sftp/types"; import { BrowserGatewayTerminalStreamClient } from "@/lib/terminal/gatewayTerminalStreamClient"; -const RECOVERABLE_TRANSPORT_STATUS_CODES = new Set([408, 425, 429, 502, 503, 504, 520, 521, 522, 523, 524]); -const RECOVERABLE_TRANSPORT_MESSAGE_RE = - /\b(?:502\s+bad\s+gateway|503\s+service\s+unavailable|504\s+gateway\s+timeout|bad\s+gateway|gateway\s+timeout|service\s+unavailable|temporarily\s+unavailable|failed\s+to\s+fetch|networkerror|network\s+error|load\s+failed|connection\s+(?:reset|closed|lost)|socket\s+hang\s+up|err_(?:network|internet_disconnected|connection_(?:reset|closed|refused)))\b/i; -function isRecoverableChatStreamTransportStatus(status: number) { - return Number.isInteger(status) && RECOVERABLE_TRANSPORT_STATUS_CODES.has(status); -} -function isRecoverableChatStreamTransportMessage(value: unknown) { - const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); - return RECOVERABLE_TRANSPORT_MESSAGE_RE.test(message.trim()); -} import type { AgentStatus, ChatQueueResponse, ChatQueueSnapshot, - ChatControlEvent, ChatEvent, ConversationSummary, GatewayHistoryEvent, @@ -512,18 +501,6 @@ function buildWebSocketUrl() { return url.toString(); } -function buildGatewayApiUrl(pathname: string) { - const origin = getRuntimeOrigin(); - if (!origin) { - throw new Error("Gateway API origin is unavailable"); - } - const url = new URL(origin); - url.pathname = pathname; - url.search = ""; - url.hash = ""; - return url; -} - function createChatClientRequestId(input: GatewayChatCommandInput) { const commandType = input.type.trim() || "chat.command"; const conversationId = input.conversationId?.trim() || "new"; @@ -672,293 +649,23 @@ function normalizeChatQueueEvent( ); } -function parseChatSseBlock(block: string): ChatEvent | null { - const dataLines: string[] = []; - for (const rawLine of block.split(/\r?\n/)) { - if (rawLine.startsWith("data:")) { - dataLines.push(rawLine.slice(5).trimStart()); - } - } - const data = dataLines.join("\n").trim(); - if (!data) { - return null; - } - const decoded = JSON.parse(data) as { - payload?: unknown; - seq?: number; - run_id?: string; - snapshot_run_id?: string; - conversation_id?: string; - }; - const payload = - decoded.payload && typeof decoded.payload === "object" - ? ({ ...(decoded.payload as Record) } as ChatEvent) - : ({ ...(decoded as Record) } as ChatEvent); - if (typeof payload.seq !== "number" && typeof decoded.seq === "number") { - payload.seq = decoded.seq; - } - if ( - !("conversation_id" in payload) && - typeof decoded.conversation_id === "string" && - decoded.conversation_id.trim() - ) { - (payload as ChatEvent & { conversation_id?: string }).conversation_id = - decoded.conversation_id; - } - attachChatEventMetadata(payload, { - runId: decoded.run_id, - snapshotRunId: decoded.snapshot_run_id, - }); - return typeof payload.type === "string" ? payload : null; -} - -function attachChatEventMetadata( - event: ChatEvent, - metadata: { runId?: string; snapshotRunId?: string }, -) { - const runId = metadata.runId?.trim() ?? ""; - const snapshotRunId = metadata.snapshotRunId?.trim() ?? ""; - if (runId) { - Object.defineProperty(event, "__gatewayRunId", { - value: runId, - enumerable: false, - configurable: true, - }); - } - if (snapshotRunId) { - Object.defineProperty(event, "__gatewaySnapshotRunId", { - value: snapshotRunId, - enumerable: false, - configurable: true, - }); - } -} - -async function readGatewayHTTPError(response: Response, fallback: string) { - let raw = ""; - try { - raw = await response.text(); - } catch { - raw = ""; - } - if (raw.trim()) { - try { - const decoded = JSON.parse(raw) as { error?: unknown; message?: unknown }; - const message = String(decoded.error ?? decoded.message ?? "").trim(); - if (message) { - return message; - } - } catch { - return raw.trim(); - } - } - return `${fallback} (${response.status})`; -} - -async function postGatewayChatCommand( - tokenInput: string, - payload: unknown, - signal?: AbortSignal, -): Promise { - const token = tokenInput.trim(); - if (!token) { - throw new Error("Gateway token is required"); - } - const response = await fetch(buildGatewayApiUrl("/api/chat/commands").toString(), { - method: "POST", - headers: { - Accept: "application/json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "X-LiveAgent-CSRF": "1", - }, - body: JSON.stringify(payload), - signal, - }); - if (!response.ok) { - throw new Error(await readGatewayHTTPError(response, "Gateway chat command failed")); - } - return (await response.json()) as T; -} - -async function* readChatEventStream(body: ReadableStream): AsyncGenerator { - const reader = body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - try { - for (;;) { - const { value, done } = await reader.read(); - if (done) { - break; - } - buffer += decoder.decode(value, { stream: true }); - let boundary = buffer.search(/\r?\n\r?\n/); - while (boundary >= 0) { - const block = buffer.slice(0, boundary); - const match = buffer.slice(boundary).match(/^\r?\n\r?\n/); - buffer = buffer.slice(boundary + (match?.[0].length ?? 2)); - const event = parseChatSseBlock(block); - if (event) { - yield event; - } - boundary = buffer.search(/\r?\n\r?\n/); - } - } - const tail = buffer + decoder.decode(); - const event = parseChatSseBlock(tail); - if (event) { - yield event; - } - } finally { - try { - await reader.cancel(); - } catch { - // The stream may already be closed by the server. - } - reader.releaseLock(); - } -} - -function waitForChatReconnect(delayMs: number, signal?: AbortSignal) { - if (signal?.aborted || delayMs <= 0) { - return Promise.resolve(); - } - return new Promise((resolve) => { - const host = getRuntimeHost(); - const timeoutId = host.setTimeout(() => { - signal?.removeEventListener("abort", handleAbort); - resolve(); - }, delayMs); - const handleAbort = () => { - host.clearTimeout(timeoutId); - signal?.removeEventListener("abort", handleAbort); - resolve(); - }; - signal?.addEventListener("abort", handleAbort, { once: true }); +// Consumers (sendChat's preserveRemoteRunOnMismatch cleanup guards) read the +// originating run id from non-enumerable event metadata so it never leaks into +// persisted transcript entries. The gateway includes `run_id` on every +// forwarded chat event; without this attachment a webui-initiated run's +// cleanup cannot tell its own run apart from a newer run (e.g. a GUI queue +// auto-send) and wipes the new run's remote-running state. +function attachChatEventRunMetadata(event: ChatEvent) { + const runId = (event as ChatEvent & { run_id?: unknown }).run_id; + if (typeof runId !== "string" || !runId.trim()) { + return event; + } + Object.defineProperty(event, "__gatewayRunId", { + value: runId.trim(), + enumerable: false, + configurable: true, }); -} - -function nextChatReconnectDelay(reconnectAttempt: number) { - const baseDelay = Math.min( - RECONNECT_MAX_DELAY_MS, - RECONNECT_INITIAL_DELAY_MS * 2 ** Math.min(reconnectAttempt, 5), - ); - const jitter = Math.floor(Math.random() * Math.min(500, Math.max(1, baseDelay))); - return baseDelay + jitter; -} - -async function* streamGatewayChatEvents(params: { - token: string; - runId?: string; - conversationId?: string; - afterSeq?: number; - signal?: AbortSignal; -}): AsyncGenerator { - const token = params.token.trim(); - if (!token) { - throw new Error("Gateway token is required"); - } - const runId = params.runId?.trim() ?? ""; - let conversationId = params.conversationId?.trim() ?? ""; - if (!runId && !conversationId) { - throw new Error("run_id or conversation_id is required"); - } - - let lastSeq = normalizeAfterSeq(params.afterSeq); - let reconnectAttempt = 0; - let terminalSeen = false; - while (!terminalSeen && !params.signal?.aborted) { - const eventsUrl = buildGatewayApiUrl("/api/chat/events"); - if (runId) { - eventsUrl.searchParams.set("run_id", runId); - } - if (conversationId) { - eventsUrl.searchParams.set("conversation_id", conversationId); - } - eventsUrl.searchParams.set("after_seq", String(lastSeq)); - - let response: Response; - try { - const headers: Record = { - Accept: "text/event-stream", - Authorization: `Bearer ${token}`, - }; - if (lastSeq > 0) { - headers["Last-Event-ID"] = String(lastSeq); - } - response = await fetch(eventsUrl.toString(), { - method: "GET", - headers, - signal: params.signal, - }); - } catch { - if (params.signal?.aborted) { - return; - } - const delay = nextChatReconnectDelay(reconnectAttempt); - reconnectAttempt += 1; - await waitForChatReconnect(delay, params.signal); - continue; - } - - if (!response.ok) { - const message = await readGatewayHTTPError(response, "Gateway chat event stream failed"); - if ( - isRecoverableChatStreamTransportStatus(response.status) || - isRecoverableChatStreamTransportMessage(message) - ) { - const delay = nextChatReconnectDelay(reconnectAttempt); - reconnectAttempt += 1; - await waitForChatReconnect(delay, params.signal); - continue; - } - throw new Error(message); - } - - if (!response.body) { - throw new Error(await readGatewayHTTPError(response, "Gateway chat event stream failed")); - } - - try { - for await (const event of readChatEventStream(response.body)) { - if (runId) { - const eventRunId = readChatEventRunId(event); - if (eventRunId && eventRunId !== runId) { - continue; - } - } - const seq = readChatEventSeq(event); - if (seq > 0 && seq <= lastSeq) { - continue; - } - if (seq > lastSeq) { - lastSeq = seq; - } - if (event.conversation_id?.trim()) { - conversationId = event.conversation_id.trim(); - } - terminalSeen = isTerminalChatEventForStream(event, runId); - if (isTerminalChatEvent(event) && !terminalSeen) { - continue; - } - reconnectAttempt = 0; - yield event; - if (terminalSeen || params.signal?.aborted) { - break; - } - } - } catch { - if (params.signal?.aborted) { - return; - } - } - - if (!terminalSeen && !params.signal?.aborted) { - const delay = nextChatReconnectDelay(reconnectAttempt); - reconnectAttempt += 1; - await waitForChatReconnect(delay, params.signal); - } - } + return event; } const RECONNECT_INITIAL_DELAY_MS = 500; @@ -1021,77 +728,6 @@ function isForegroundWakeupEvent(event?: Event) { return true; } -function readChatEventSeq(event: ChatEvent) { - const seq = event.seq; - return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; -} - -function readChatEventRunId(event: ChatEvent) { - const runId = (event as ChatEvent & { __gatewayRunId?: unknown }).__gatewayRunId; - return typeof runId === "string" ? runId.trim() : ""; -} - -function readChatEventSnapshotRunId(event: ChatEvent) { - const runId = (event as ChatEvent & { __gatewaySnapshotRunId?: unknown }) - .__gatewaySnapshotRunId; - return typeof runId === "string" ? runId.trim() : ""; -} - -function isChatControlEvent( - event: ChatEvent | null | undefined, -): event is ChatControlEvent { - switch (event?.type) { - case "accepted": - case "user_message": - case "rebased": - case "projection_updated": - case "delivered": - case "claimed": - case "starting": - case "queued_in_gui": - case "started": - case "progress": - case "completed": - case "failed": - case "cancelled": - return true; - default: - return false; - } -} - -function isTerminalChatControlEvent(event: ChatEvent | null | undefined) { - if (!isChatControlEvent(event)) { - return false; - } - return event?.state === "completed" || event?.state === "failed" || event?.state === "cancelled"; -} - -function isTerminalChatEvent(event: ChatEvent | null | undefined) { - if (event?.type === "runtime_snapshot") { - return ( - event.state === "completed" || event.state === "failed" || event.state === "cancelled" - ); - } - return event?.type === "done" || event?.type === "error" || isTerminalChatControlEvent(event); -} - -function isTerminalChatEventForStream(event: ChatEvent, runId: string) { - if (!isTerminalChatEvent(event)) { - return false; - } - const eventRunId = readChatEventRunId(event); - const targetRunId = runId.trim() || readChatEventSnapshotRunId(event); - if (!targetRunId) { - return true; - } - return eventRunId === "" || eventRunId === targetRunId; -} - -function normalizeAfterSeq(value: unknown) { - return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; -} - function normalizePositiveInteger(value: number, fallback: number) { if (!Number.isFinite(value)) { return fallback; @@ -1501,6 +1137,7 @@ export class GatewayWebSocketClient { private terminalListeners = new Set(); private sftpTransferListeners = new Set(); private chatQueueListeners = new Set(); + private chatSubscriptions = new Map void; done: () => void }>(); private terminalSessionSnapshot = new Map(); private statusPollTimer: number | null = null; private lastStatus: AgentStatus | null = null; @@ -1707,11 +1344,8 @@ export class GatewayWebSocketClient { throw new Error("Gateway token is required"); } - const commandResponse = await postGatewayChatCommand( - this.token, - buildChatCommandPayload(input), - signal, - ); + // Submit command via WebSocket + const commandResponse = await this.request("chat.command", buildChatCommandPayload(input)); const runId = String(commandResponse.run_id ?? "").trim(); if (!runId) { throw new Error("Gateway chat command returned no run_id"); @@ -1719,18 +1353,12 @@ export class GatewayWebSocketClient { let conversationId = String(commandResponse.conversation_id ?? "").trim() || input.conversationId?.trim() || ""; + // Set up abort handler const handleAbort = () => { - void postGatewayChatCommand( - this.token, - { - type: "chat.cancel", - payload: { - run_id: runId, - conversation_id: conversationId, - }, - }, - undefined, - ).catch(() => undefined); + void this.request("chat.cancel", { + conversation_id: conversationId, + run_id: runId, + }).catch(() => undefined); }; if (signal) { if (signal.aborted) { @@ -1741,18 +1369,7 @@ export class GatewayWebSocketClient { } try { - for await (const event of streamGatewayChatEvents({ - token: this.token, - runId, - conversationId, - afterSeq: 0, - signal, - })) { - if (event.conversation_id?.trim()) { - conversationId = event.conversation_id.trim(); - } - yield event; - } + yield* this.subscribeChatRunEvents(runId, conversationId, 0, signal); } finally { signal?.removeEventListener("abort", handleAbort); } @@ -1766,21 +1383,12 @@ export class GatewayWebSocketClient { if (!normalizedConversationId) { throw new Error("conversation_id is required"); } - const signal = options?.signal; if (signal?.aborted) { return; } const normalizedRunId = options?.runId?.trim() ?? ""; - for await (const event of streamGatewayChatEvents({ - token: this.token, - runId: normalizedRunId || undefined, - conversationId: normalizedConversationId, - afterSeq: options?.afterSeq, - signal, - })) { - yield event; - } + yield* this.subscribeChatRunEvents(normalizedRunId, normalizedConversationId, options?.afterSeq ?? 0, signal); } async cancelChat(conversationId: string, runId?: string): Promise { @@ -1788,16 +1396,85 @@ export class GatewayWebSocketClient { if (!normalized) { return; } - const normalizedRunId = runId?.trim() ?? ""; - await postGatewayChatCommand(this.token, { - type: "chat.cancel", - payload: { - run_id: normalizedRunId || undefined, - conversation_id: normalized, - }, + await this.request("chat.cancel", { + conversation_id: normalized, + run_id: runId?.trim() || undefined, }); } + private async *subscribeChatRunEvents( + runId: string, + conversationId: string, + afterSeq: number, + signal?: AbortSignal, + ): AsyncGenerator { + const subResponse = await this.request<{ + subscription_id: string; + snapshot: { run_id: string; conversation_id: string; state: string; latest_seq: number }; + gap?: boolean; + }>("chat.subscribe", { + run_id: runId || undefined, + conversation_id: conversationId || undefined, + after_seq: afterSeq, + }); + + const subscriptionId = subResponse.subscription_id; + if (!subscriptionId) { + throw new Error("No subscription_id from chat.subscribe"); + } + + // Create event queue + const eventQueue: ChatEvent[] = []; + let resolve: (() => void) | null = null; + let done = false; + + const onEvent = (event: ChatEvent) => { + eventQueue.push(event); + if (resolve) { + resolve(); + resolve = null; + } + }; + const onDone = () => { + done = true; + if (resolve) { + resolve(); + resolve = null; + } + }; + + this.chatSubscriptions.set(subscriptionId, { callback: onEvent, done: onDone }); + + try { + while (!done && !signal?.aborted) { + if (eventQueue.length === 0) { + await new Promise((r) => { + resolve = r; + }); + continue; + } + const event = eventQueue.shift()!; + if (event.conversation_id?.trim()) { + conversationId = event.conversation_id.trim(); + } + yield event; + const eventType = typeof event.type === "string" ? event.type.trim() : ""; + if ( + eventType === "done" || + eventType === "completed" || + eventType === "error" || + eventType === "failed" || + eventType === "cancelled" + ) { + return; + } + } + } finally { + this.chatSubscriptions.delete(subscriptionId); + void this.request("chat.unsubscribe", { subscription_id: subscriptionId }).catch(() => {}); + } + } + async cronManage(payload: CronManagePayload): Promise { return this.request("cron.manage", payload); } @@ -2864,6 +2541,26 @@ export class GatewayWebSocketClient { return; } + if (envelope.type === "chat.event") { + const payload = envelope.payload as any; + const subId = typeof payload?.subscription_id === "string" ? payload.subscription_id : ""; + const sub = subId ? this.chatSubscriptions.get(subId) : undefined; + if (sub) { + sub.callback(attachChatEventRunMetadata(payload as ChatEvent)); + } + return; + } + + if (envelope.type === "chat.subscription_end") { + const payload = envelope.payload as any; + const subId = typeof payload?.subscription_id === "string" ? payload.subscription_id : ""; + const sub = subId ? this.chatSubscriptions.get(subId) : undefined; + if (sub) { + sub.done(); + } + return; + } + const pending = requestId ? this.pending.get(requestId) : null; if (!pending) { return; @@ -2906,6 +2603,12 @@ export class GatewayWebSocketClient { entry.reject(error); } + const chatSubs = [...this.chatSubscriptions.values()]; + this.chatSubscriptions.clear(); + for (const sub of chatSubs) { + sub.done(); + } + if (!this.disposed && this.statusListeners.size > 0) { if (isRecoverableGatewayTransportError(error) && this.shouldMaintainConnection()) { this.scheduleReconnectNotice(); @@ -3175,1631 +2878,16 @@ export type GatewayWebSocketClientLike = { dispose(): void; }; -type SharedWorkerClientReadyMessage = { - type: "ready"; - connection_id: string; - payload: { - status: AgentStatus | null; - error: string | null; - }; -}; - -type SharedWorkerClientResponseMessage = { - type: "response"; - connection_id: string; - request_id: string; - payload?: unknown; - error?: string; -}; - -type SharedWorkerClientEventMessage = { - type: "event"; - connection_id: string; - event_type: "status" | "history" | "settings" | "terminal" | "sftp" | "chat_queue"; - payload: unknown; -}; - -type SharedWorkerTerminalStreamSnapshotMessage = { - type: "terminal_stream_snapshot"; - connection_id: string; - stream_id: string; - payload: TerminalStreamSnapshot; -}; - -type SharedWorkerTerminalStreamOutputMessage = { - type: "terminal_stream_output"; - connection_id: string; - stream_id: string; - payload: TerminalStreamChunk; -}; - -type SharedWorkerTerminalStreamInputStateMessage = { - type: "terminal_stream_input_state"; - connection_id: string; - stream_id: string; - payload: TerminalStreamInputState; -}; - -type SharedWorkerTerminalStreamErrorMessage = { - type: "terminal_stream_error"; - connection_id: string; - stream_id: string; - error?: string; -}; - -type SharedWorkerClientMessage = - | SharedWorkerClientReadyMessage - | SharedWorkerClientResponseMessage - | SharedWorkerClientEventMessage - | SharedWorkerTerminalStreamSnapshotMessage - | SharedWorkerTerminalStreamOutputMessage - | SharedWorkerTerminalStreamInputStateMessage - | SharedWorkerTerminalStreamErrorMessage; - -type SharedWorkerClientRequestMessage = - | { - type: "connect"; - connection_id: string; - token: string; - } - | { - type: "request"; - connection_id: string; - request_id: string; - method: string; - payload?: unknown; - } - | { - type: "wakeup"; - connection_id: string; - } - | { - type: "terminal_stream_attach"; - connection_id: string; - stream_id: string; - session: TerminalSession; - max_bytes?: number; - } - | { - type: "terminal_stream_write"; - connection_id: string; - stream_id: string; - session_id: string; - bytes: Uint8Array; - } - | { - type: "terminal_stream_resize"; - connection_id: string; - stream_id: string; - session_id: string; - cols: number; - rows: number; - } - | { - type: "terminal_stream_detach"; - connection_id: string; - stream_id: string; - session_id: string; - } - | { - type: "dispose"; - connection_id: string; - }; - -type SharedWorkerTerminalAttachPending = { - handle: SharedWorkerTerminalStreamHandle; - resolve: (handle: SharedWorkerTerminalStreamHandle) => void; - reject: (reason?: unknown) => void; - timeoutId: number; -}; - -class SharedWorkerTerminalStreamHandle implements TerminalStreamHandle { - private disposed = false; - private readonly listeners = new Set<(chunk: TerminalStreamChunk) => void>(); - private readonly inputStateListeners = new Set<(state: TerminalStreamInputState) => void>(); - private readonly queuedChunks: TerminalStreamChunk[] = []; - private inputState: TerminalStreamInputState = { - paused: false, - queuedBytes: 0, - highWaterBytes: 256 * 1024, - }; - - constructor( - private readonly owner: SharedWorkerTerminalStreamClient, - private readonly streamID: string, - public snapshot: TerminalStreamSnapshot, - ) {} - - accept(chunk: TerminalStreamChunk) { - if (this.disposed || chunk.sessionId !== this.snapshot.session.id) { - return; - } - if (this.listeners.size === 0) { - this.queuedChunks.push(chunk); - return; - } - for (const listener of this.listeners) { - listener(chunk); - } - } - - write(data: Uint8Array) { - if (this.disposed || data.byteLength === 0) { - return false; - } - if (this.inputState.paused) { - return false; - } - this.owner.write(this.streamID, this.snapshot.session.id, data); - return true; - } - - resize(cols: number, rows: number) { - if (this.disposed) { - return; - } - this.owner.resize(this.streamID, this.snapshot.session.id, cols, rows); - } - - subscribeOutput(listener: (chunk: TerminalStreamChunk) => void) { - this.listeners.add(listener); - const queued = this.queuedChunks.splice(0); - for (const chunk of queued) { - listener(chunk); - } - return () => { - this.listeners.delete(listener); - }; - } - - subscribeInputState(listener: (state: TerminalStreamInputState) => void) { - this.inputStateListeners.add(listener); - listener(this.inputState); - return () => { - this.inputStateListeners.delete(listener); - }; - } - - acceptInputState(state: TerminalStreamInputState) { - if (this.disposed) { - return; - } - this.inputState = state; - for (const listener of this.inputStateListeners) { - listener(state); - } - } +let activeClient: GatewayWebSocketClient | null = null; +let activeToken = ""; - dispose() { - if (this.disposed) { - return; - } - this.disposed = true; - this.listeners.clear(); - this.inputStateListeners.clear(); - this.queuedChunks.length = 0; - this.owner.detach(this.streamID, this.snapshot.session.id); - } - - disposeLocalOnly() { - this.disposed = true; - this.listeners.clear(); - this.inputStateListeners.clear(); - this.queuedChunks.length = 0; - } -} - -class SharedWorkerTerminalStreamClient implements TerminalStreamClient { - private readonly pending = new Map(); - private readonly handles = new Map(); - - constructor( - private readonly ensureConnected: () => Promise, - private readonly postMessage: (message: SharedWorkerClientRequestMessage) => void, - private readonly connectionID: () => string, - private readonly nextID: (prefix: string) => string, - ) {} - - async attach( - session: TerminalSession, - options?: { maxBytes?: number }, - ): Promise { - await this.ensureConnected(); - const streamID = this.nextID("terminal-stream"); - const handle = new SharedWorkerTerminalStreamHandle(this, streamID, { - session, - bytes: new Uint8Array(), - truncated: false, - outputStartOffset: 0, - outputEndOffset: 0, - }); - this.handles.set(streamID, handle); - - return new Promise((resolve, reject) => { - const host = getRuntimeHost(); - const timeoutId = host.setTimeout(() => { - this.failAttach(streamID, new Error("Terminal stream attach timed out")); - }, 15_000); - - this.pending.set(streamID, { handle, resolve, reject, timeoutId }); - try { - this.postMessage({ - type: "terminal_stream_attach", - connection_id: this.connectionID(), - stream_id: streamID, - session, - max_bytes: options?.maxBytes, - }); - } catch (error) { - this.failAttach(streamID, error); - } - }); - } - - write(streamID: string, sessionID: string, data: Uint8Array) { - if (data.byteLength === 0 || !this.handles.has(streamID)) { - return; - } - try { - this.postMessage({ - type: "terminal_stream_write", - connection_id: this.connectionID(), - stream_id: streamID, - session_id: sessionID, - bytes: data.slice(), - }); - } catch { - this.handleError({ - type: "terminal_stream_error", - connection_id: this.connectionID(), - stream_id: streamID, - error: "Terminal stream port is closed", - }); - } - } - - resize(streamID: string, sessionID: string, cols: number, rows: number) { - if (!this.handles.has(streamID)) { - return; - } - try { - this.postMessage({ - type: "terminal_stream_resize", - connection_id: this.connectionID(), - stream_id: streamID, - session_id: sessionID, - cols, - rows, - }); - } catch { - this.handleError({ - type: "terminal_stream_error", - connection_id: this.connectionID(), - stream_id: streamID, - error: "Terminal stream port is closed", - }); - } - } - - detach(streamID: string, sessionID: string) { - const handle = this.handles.get(streamID); - this.handles.delete(streamID); - const pending = this.pending.get(streamID); - if (pending) { - const host = getRuntimeHost(); - host.clearTimeout(pending.timeoutId); - this.pending.delete(streamID); - pending.reject(new Error("Terminal stream detached")); - } - handle?.disposeLocalOnly(); - try { - this.postMessage({ - type: "terminal_stream_detach", - connection_id: this.connectionID(), - stream_id: streamID, - session_id: sessionID, - }); - } catch { - // The SharedWorker port may already be closed during disposal. - } - } - - handleSnapshot(message: SharedWorkerTerminalStreamSnapshotMessage) { - const pending = this.pending.get(message.stream_id); - if (!pending) { - return; - } - const host = getRuntimeHost(); - host.clearTimeout(pending.timeoutId); - this.pending.delete(message.stream_id); - pending.handle.snapshot = message.payload; - pending.resolve(pending.handle); - } - - handleOutput(message: SharedWorkerTerminalStreamOutputMessage) { - this.handles.get(message.stream_id)?.accept(message.payload); - } - - handleInputState(message: SharedWorkerTerminalStreamInputStateMessage) { - this.handles.get(message.stream_id)?.acceptInputState(message.payload); - } - - handleError(message: SharedWorkerTerminalStreamErrorMessage) { - const error = new Error(message.error || "Terminal stream failed"); - if (this.pending.has(message.stream_id)) { - this.failAttach(message.stream_id, error); - return; - } - const handle = this.handles.get(message.stream_id); - if (handle) { - this.handles.delete(message.stream_id); - handle.disposeLocalOnly(); - } - } - - disposeAll(error: Error) { - const host = getRuntimeHost(); - for (const [streamID, pending] of this.pending) { - host.clearTimeout(pending.timeoutId); - pending.reject(error); - pending.handle.disposeLocalOnly(); - this.handles.delete(streamID); - } - this.pending.clear(); - for (const handle of this.handles.values()) { - handle.disposeLocalOnly(); - } - this.handles.clear(); - } - - private failAttach(streamID: string, reason: unknown) { - const pending = this.pending.get(streamID); - if (!pending) { - return; - } - const host = getRuntimeHost(); - host.clearTimeout(pending.timeoutId); - this.pending.delete(streamID); - this.handles.delete(streamID); - pending.handle.disposeLocalOnly(); - pending.reject(reason); - try { - this.postMessage({ - type: "terminal_stream_detach", - connection_id: this.connectionID(), - stream_id: streamID, - session_id: pending.handle.snapshot.session.id, - }); - } catch { - // The SharedWorker port may already be closed. - } - } -} - -function canUseSharedWorker() { - return typeof window !== "undefined" && typeof SharedWorker === "function"; -} - -class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { - readonly terminalStream: SharedWorkerTerminalStreamClient; - private readonly worker: SharedWorker; - private readonly port: MessagePort; - private readonly connectionID: string; - private connectPromise: Promise; - private resolveConnect!: () => void; - private rejectConnect!: (reason?: unknown) => void; - private readyTimeoutId: ReturnType | null = null; - private connectSettled = false; - private requestSeq = 0; - private disposed = false; - private statusRefreshRequested = false; - private pending = new Map(); - private statusListeners = new Set(); - private historyListeners = new Set(); - private settingsListeners = new Set(); - private terminalListeners = new Set(); - private sftpTransferListeners = new Set(); - private chatQueueListeners = new Set(); - private terminalSessionSnapshot = new Map(); - private lastStatus: AgentStatus | null = null; - private lastStatusError: string | null = null; - private readonly workerWakeup = (event?: Event) => { - this.postWorkerWakeup(event); - }; - - constructor(private readonly token: string) { - this.connectionID = this.nextRequestId("connection"); - this.connectPromise = new Promise((resolve, reject) => { - this.resolveConnect = resolve; - this.rejectConnect = reject; - }); - - this.worker = new SharedWorker(new URL("./gatewaySocket.worker.ts", import.meta.url), { - name: "liveagent-gateway-websocket", - type: "module", - }); - this.port = this.worker.port; - this.terminalStream = new SharedWorkerTerminalStreamClient( - () => this.ensureConnected(), - (message) => this.postMessage(message), - () => this.connectionID, - (prefix) => this.nextRequestId(prefix), - ); - this.port.onmessage = (event) => { - this.handleMessage(event.data); - }; - this.port.onmessageerror = () => { - this.handleDisconnect(new Error("Gateway SharedWorker message failed")); - }; - this.port.start(); - this.installWorkerWakeups(); - const host = getRuntimeHost(); - this.readyTimeoutId = host.setTimeout(() => { - this.handleDisconnect(new Error("Gateway SharedWorker connection timed out")); - }, 15_000); - try { - this.postMessage({ - type: "connect", - connection_id: this.connectionID, - token: this.token, - }); - } catch (error) { - this.clearReadyTimeout(); - throw error; - } - } - - async getStatus(): Promise { - const status = await this.request("status.get", {}); - this.statusRefreshRequested = false; - this.emitStatus(status, null); - return status; - } - - async prepareChatRuntime(reason?: string): Promise { - const status = await this.request("chat.prepare", { reason: reason ?? "" }); - this.statusRefreshRequested = false; - this.emitStatus(status, null); - return status; - } - - subscribeStatus(listener: StatusListener): () => void { - this.statusListeners.add(listener); - if (this.lastStatus || this.lastStatusError) { - listener(this.lastStatus, this.lastStatusError); - } else if (!this.statusRefreshRequested) { - this.statusRefreshRequested = true; - void this.refreshStatus(); - } - return () => { - this.statusListeners.delete(listener); - }; - } - - subscribeHistory(listener: HistoryListener): () => void { - this.historyListeners.add(listener); - return () => { - this.historyListeners.delete(listener); - }; - } - - subscribeSettings(listener: SettingsListener): () => void { - this.settingsListeners.add(listener); - return () => { - this.settingsListeners.delete(listener); - }; - } - - subscribeTerminal(listener: TerminalListener): () => void { - this.terminalListeners.add(listener); - replayTerminalSnapshot(this.terminalSessionSnapshot, listener); - return () => { - this.terminalListeners.delete(listener); - }; - } - - subscribeSftpTransfers(listener: SftpTransferListener): () => void { - this.sftpTransferListeners.add(listener); - return () => { - this.sftpTransferListeners.delete(listener); - }; - } - - subscribeChatQueue(listener: ChatQueueListener): () => void { - this.chatQueueListeners.add(listener); - return () => { - this.chatQueueListeners.delete(listener); - }; - } - - async chatQueueGet(conversationId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.get", { - conversation_id: conversationId, - }), - ); - } - - async chatQueueGetItem(conversationId: string, itemId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.get_item", { - conversation_id: conversationId, - item_id: itemId, - }), - ); - } - - async chatQueueRunNow(conversationId: string, itemId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.run_now", { - conversation_id: conversationId, - item_id: itemId, - }), - ); - } - - async chatQueueMove( - conversationId: string, - itemId: string, - direction: "up" | "down", - ): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.move", { - conversation_id: conversationId, - item_id: itemId, - direction, - }), - ); - } - - async chatQueueRemove(conversationId: string, itemId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.remove", { - conversation_id: conversationId, - item_id: itemId, - }), - ); - } - - async chatQueueEditBegin(conversationId: string, itemId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.edit_begin", { - conversation_id: conversationId, - item_id: itemId, - }), - ); - } - - async chatQueueEditCommit(input: { - conversationId: string; - itemId: string; - revision: number; - draftJson: string; - uploadedFilesJson: string; - }): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.edit_commit", { - conversation_id: input.conversationId, - item_id: input.itemId, - revision: input.revision, - draft_json: input.draftJson, - uploaded_files_json: input.uploadedFilesJson, - }), - ); - } - - async chatQueueEditCancel(conversationId: string, itemId: string): Promise { - return normalizeChatQueueResponse( - await this.request("chat_queue.edit_cancel", { - conversation_id: conversationId, - item_id: itemId, - }), - ); - } - - async *commandChat(input: GatewayChatCommandInput): AsyncGenerator { - const signal = input.signal; - if (signal?.aborted) { - return; - } - if (this.token.trim() === "") { - throw new Error("Gateway token is required"); - } - - const commandResponse = await postGatewayChatCommand( - this.token, - buildChatCommandPayload(input), - signal, - ); - const runId = String(commandResponse.run_id ?? "").trim(); - if (!runId) { - throw new Error("Gateway chat command returned no run_id"); - } - let conversationId = - String(commandResponse.conversation_id ?? "").trim() || input.conversationId?.trim() || ""; - - const handleAbort = () => { - void postGatewayChatCommand( - this.token, - { - type: "chat.cancel", - payload: { - run_id: runId, - conversation_id: conversationId, - }, - }, - undefined, - ).catch(() => undefined); - }; - if (signal) { - if (signal.aborted) { - handleAbort(); - return; - } - signal.addEventListener("abort", handleAbort, { once: true }); - } - - try { - for await (const event of streamGatewayChatEvents({ - token: this.token, - runId, - conversationId, - afterSeq: 0, - signal, - })) { - if (event.conversation_id?.trim()) { - conversationId = event.conversation_id.trim(); - } - yield event; - } - } finally { - signal?.removeEventListener("abort", handleAbort); - } - } - - async *streamChatEvents( - conversationId: string, - options?: ChatEventStreamOptions, - ): AsyncGenerator { - const normalizedConversationId = conversationId.trim(); - if (!normalizedConversationId) { - throw new Error("conversation_id is required"); - } - const signal = options?.signal; - if (signal?.aborted) { - return; - } - const normalizedRunId = options?.runId?.trim() ?? ""; - for await (const event of streamGatewayChatEvents({ - token: this.token, - runId: normalizedRunId || undefined, - conversationId: normalizedConversationId, - afterSeq: options?.afterSeq, - signal, - })) { - yield event; - } - } - - async cancelChat(conversationId: string, runId?: string): Promise { - const normalized = conversationId.trim(); - if (!normalized) { - return; - } - const normalizedRunId = runId?.trim() ?? ""; - await postGatewayChatCommand(this.token, { - type: "chat.cancel", - payload: { - run_id: normalizedRunId || undefined, - conversation_id: normalized, - }, - }); - } - - async cronManage(payload: CronManagePayload): Promise { - return this.request("cron.manage", payload); - } - - async memoryManage(payload: MemoryManagePayload): Promise { - return this.request("memory.manage", payload); - } - - async gitRequest( - action: string, - workdir: string, - args: Record = {}, - ): Promise { - return this.request(`git.${action}`, { workdir, args }); - } - - async sftpList(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - side: "local" | "remote"; - path?: string; - }): Promise { - return normalizeSftpListResponse( - await this.request("sftp.list", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - side: params.side, - direction: params.side, - local_path: params.side === "local" ? (params.path ?? "") : "", - remote_path: params.side === "remote" ? (params.path ?? "") : "", - }), - ); - } - - async sftpStat(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - side: "local" | "remote"; - path?: string; - }): Promise { - return normalizeSftpStatResponse( - await this.request("sftp.stat", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - side: params.side, - direction: params.side, - local_path: params.side === "local" ? (params.path ?? "") : "", - remote_path: params.side === "remote" ? (params.path ?? "") : "", - }), - ); - } - - async sftpMkdir(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - side: "local" | "remote"; - path: string; - }): Promise { - return normalizeSftpActionResponse( - await this.request("sftp.mkdir", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - side: params.side, - direction: params.side, - local_path: params.side === "local" ? params.path : "", - remote_path: params.side === "remote" ? params.path : "", - }), - ); - } - - async sftpRename(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - side: "local" | "remote"; - fromPath: string; - toPath: string; - }): Promise { - return normalizeSftpActionResponse( - await this.request("sftp.rename", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - side: params.side, - direction: params.side, - from_path: params.fromPath, - to_path: params.toPath, - }), - ); - } - - async sftpDelete(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - side: "local" | "remote"; - path: string; - recursive?: boolean; - }): Promise { - return normalizeSftpActionResponse( - await this.request("sftp.delete", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - side: params.side, - direction: params.side, - local_path: params.side === "local" ? params.path : "", - remote_path: params.side === "remote" ? params.path : "", - recursive: params.recursive ?? false, - }), - ); - } - - async sftpTransfer(params: { - sessionId: string; - projectPathKey: string; - workdir: string; - direction: "upload" | "download"; - sourcePath: string; - targetPath: string; - recursive?: boolean; - overwrite?: boolean; - }): Promise { - return normalizeSftpTransferResponse( - await this.request("sftp.transfer", { - session_id: params.sessionId, - project_path_key: params.projectPathKey, - workdir: params.workdir, - direction: params.direction, - from_path: params.sourcePath, - target_path: params.targetPath, - recursive: params.recursive ?? false, - overwrite: params.overwrite ?? false, - }), - ); - } - - async sftpCancelTransfer(params: { sessionId: string; transferId: string }): Promise { - await this.request("sftp.cancel", { - session_id: params.sessionId, - from_path: params.transferId, - }); - } - - async terminalShellOptions(): Promise { - return normalizeTerminalShellOptions( - await this.request("terminal.shell_options", {}), - ); - } - - async listTerminals(projectPathKey?: string): Promise { - const projectKey = projectPathKey?.trim() ?? ""; - const response = await this.request( - "terminal.list", - projectKey ? { project_path_key: projectKey } : {}, - ); - return (response.sessions ?? []).map(normalizeTerminalSession); - } - - async createTerminal(params: { - cwd: string; - projectPathKey: string; - shell?: string; - title?: string; - cols?: number; - rows?: number; - }): Promise { - return normalizeTerminalSnapshot( - await this.request("terminal.create", { - cwd: params.cwd, - project_path_key: params.projectPathKey, - shell: params.shell, - title: params.title, - cols: params.cols, - rows: params.rows, - }), - ); - } - - async createSshTerminal(params: { - cwd: string; - projectPathKey: string; - hostId: string; - title?: string; - cols?: number; - rows?: number; - sftpEnabled?: boolean; - }): Promise { - return normalizeTerminalSshCreateResult( - await this.request("terminal.create_ssh", { - cwd: params.cwd, - project_path_key: params.projectPathKey, - ssh_host_id: params.hostId, - title: params.title, - cols: params.cols, - rows: params.rows, - sftp_enabled: params.sftpEnabled ?? false, - }), - ); - } - - async answerSshTerminalPrompt(params: { - promptId: string; - answer?: string; - trustHostKey?: boolean; - }): Promise { - return normalizeTerminalSshCreateResult( - await this.request("terminal.answer_ssh_prompt", { - prompt_id: params.promptId, - prompt_answer: params.answer, - trust_host_key: params.trustHostKey, - }), - ); - } - - async cancelSshTerminalPrompt(promptId: string): Promise { - await this.request("terminal.cancel_ssh_prompt", { - prompt_id: promptId, - }); - } - - async sshTerminalLatency( - sessionId: string, - projectPathKey?: string, - ): Promise { - const latency = normalizeTerminalSshLatency( - await this.request("terminal.ssh_latency", { - session_id: sessionId, - project_path_key: projectPathKey, - }), - ); - return { ...latency, sessionId }; - } - - async listSshTerminalTabs(projectPathKey: string): Promise { - const response = await this.request("terminal.ssh_tabs_list", { - project_path_key: projectPathKey, - }); - return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); - } - - async openSshTerminalTab(params: { - sessionId: string; - kind: SshTerminalTabKind; - }): Promise { - const response = await this.request("terminal.ssh_tab_open", { - session_id: params.sessionId, - tab_kind: params.kind, - }); - return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); - } - - async closeSshTerminalTab(tabId: string): Promise { - const response = await this.request("terminal.ssh_tab_close", { - tab_id: tabId, - }); - return normalizeSshTerminalTabsSnapshot(response.sshTabs ?? response.ssh_tabs); - } - - async renameTerminal( - sessionId: string, - title: string, - projectPathKey?: string, - ): Promise { - const response = await this.request("terminal.rename", { - session_id: sessionId, - project_path_key: projectPathKey, - title, - }); - if (!response.session) { - throw new Error("Terminal response did not include a session"); - } - return normalizeTerminalSession(response.session); - } - - async closeTerminal(sessionId: string, projectPathKey?: string): Promise { - const response = await this.request("terminal.close", { - session_id: sessionId, - project_path_key: projectPathKey, - }); - if (!response.session) { - throw new Error("Terminal response did not include a session"); - } - return normalizeTerminalSession(response.session); - } - - async closeProjectTerminals(projectPathKey: string): Promise { - const response = await this.request("terminal.close_project", { - project_path_key: projectPathKey, - }); - return (response.sessions ?? []).map(normalizeTerminalSession); - } - - async listTunnels(): Promise { - return normalizeTunnelListResponse(await this.request("tunnel.list", {})); - } - - async createTunnel(input: TunnelCreateInput): Promise { - const payload: Record = { - targetUrl: input.targetUrl, - ttlSeconds: input.ttlSeconds, - name: input.name, - }; - if (input.projectPathKey?.trim()) { - payload.projectPathKey = input.projectPathKey.trim(); - } - return normalizeTunnelResponse( - await this.request("tunnel.create", payload), - ); - } - - async updateTunnel(input: TunnelUpdateInput): Promise { - const payload: Record = { - id: input.id, - targetUrl: input.targetUrl, - ttlSeconds: input.ttlSeconds, - name: input.name, - }; - if (input.projectPathKey?.trim()) { - payload.projectPathKey = input.projectPathKey.trim(); - } - return normalizeTunnelResponse( - await this.request("tunnel.update", payload), - ); - } - - async closeTunnel(id: string): Promise { - return normalizeTunnelResponse( - await this.request("tunnel.close", { - id, - }), - ); - } - - async probeTunnel(id: string): Promise { - return normalizeTunnelResponse( - await this.request("tunnel.probe", { - id, - }), - ); - } - - async listHistory( - page: number, - pageSize: number, - filter?: HistoryListFilter, - ): Promise { - const payload: { page: number; page_size: number; cwd?: string; cwd_empty?: boolean } = { - page: normalizeHistoryListPage(page), - page_size: normalizeHistoryListPageSize(pageSize), - }; - const cwd = filter?.cwd?.trim(); - if (cwd) { - payload.cwd = cwd; - } - if (filter?.cwdEmpty === true) { - payload.cwd_empty = true; - } - return this.request("history.list", payload); - } - - async listHistoryWorkdirs(): Promise { - const response = await this.request<{ - workdirs?: Array<{ path?: string; conversation_count?: number; updated_at?: number }>; - }>("history.workdirs", {}); - return { - workdirs: (response.workdirs ?? []).map((item) => ({ - path: item.path ?? "", - conversationCount: item.conversation_count ?? 0, - updatedAt: item.updated_at ?? 0, - })), - }; - } - - async listSharedHistory(page: number, pageSize: number): Promise { - return this.request("history.shared_list", { - page: normalizeHistoryListPage(page), - page_size: normalizeHistoryListPageSize(pageSize), - }); - } - - async getHistory(conversationId: string, options?: HistoryGetOptions): Promise { - return this.request("history.get", { - conversation_id: conversationId, - max_messages: options?.maxMessages, - }); - } - - async getHistoryPrefix( - conversationId: string, - baseMessageRef: HistoryMessageRef, - options?: HistoryGetOptions, - ): Promise { - return this.request("history.prefix", { - conversation_id: conversationId, - max_messages: options?.maxMessages, - base_message_ref: buildHistoryMessageRefPayload(baseMessageRef), - }); - } - - async renameHistory(conversationId: string, title: string): Promise { - return this.request("history.rename", { - conversation_id: conversationId, - title, - }); - } - - async pinHistory(conversationId: string, isPinned: boolean): Promise { - return this.request("history.pin", { - conversation_id: conversationId, - is_pinned: isPinned, - }); - } - - async getHistoryShare(conversationId: string): Promise { - return this.request("history.share.get", { - conversation_id: conversationId, - }); - } - - async setHistoryShare( - conversationId: string, - enabled: boolean, - options?: { redactToolContent?: boolean }, - ): Promise { - const payload: Record = { - conversation_id: conversationId, - enabled, - }; - if (typeof options?.redactToolContent === "boolean") { - payload.redact_tool_content = options.redactToolContent; - } - return this.request("history.share.set", payload); - } - - async deleteHistory(conversationId: string): Promise { - await this.request("history.delete", { - conversation_id: conversationId, - }); - } - - async listProviders(): Promise { - return this.request("providers.list", {}); - } - - async getSettings(): Promise { - return this.request("settings.get", {}); - } - - async updateSettings(payload: GatewaySettingsSyncUpdatePayload): Promise { - const response = await this.request("settings.update", payload); - if (response?.accepted === false) { - throw new Error(response.message?.trim() || "SSH 设置已在另一端更新,已刷新为最新状态,请重新提交。"); - } - } - - async resetSshKnownHost(params: { host: string; port: number }): Promise { - return this.request("settings.ssh_known_host.reset", { - host: params.host, - port: params.port, - }); - } - - async listSkillFiles(): Promise { - return this.request("skills.list", {}); - } - - async manageSkill(payload: SkillManagePayload): Promise { - return this.request("skills.manage", payload); - } - - async listMentionFiles( - workdir: string, - maxResults?: number, - query?: string, - ): Promise { - return this.request("mentions.list", { - workdir, - max_results: maxResults, - query, - }); - } - - async listFsRoots(): Promise { - return this.request("fs.roots", {}); - } - - async listDirs(path: string, maxResults?: number): Promise { - return this.request("fs.list_dirs", { - path, - max_results: maxResults, - }); - } - - async createProjectFolder(parent: string, name: string): Promise { - return this.request("fs.create_project_folder", { - parent, - name, - }); - } - - async listFiles( - workdir: string, - path?: string, - depth?: number, - offset?: number, - maxResults?: number, - ): Promise { - return this.request("fs.list", { - workdir, - path, - depth, - offset, - max_results: maxResults, - }); - } - - async writeTextFile(params: { - workdir: string; - path: string; - content: string; - mode?: string; - expectedMtimeMs?: number; - expectedContentHash?: string; - }): Promise { - return this.request("fs.write_text", { - workdir: params.workdir, - path: params.path, - content: params.content, - mode: params.mode ?? "rewrite", - expected_mtime_ms: params.expectedMtimeMs, - expected_content_hash: params.expectedContentHash, - }); - } - - async readEditableTextFile(workdir: string, path: string): Promise { - return this.request("fs.read_editable_text", { - workdir, - path, - }); - } - - async readWorkspaceImageFile( - workdir: string, - path: string, - ): Promise { - return this.request("fs.read_workspace_image", { - workdir, - path, - }); - } - - async createDir(workdir: string, path: string): Promise { - return this.request("fs.create_dir", { workdir, path }); - } - - async renamePath(workdir: string, fromPath: string, toPath: string): Promise { - return this.request("fs.rename", { - workdir, - from_path: fromPath, - to_path: toPath, - }); - } - - async deletePath(workdir: string, path: string): Promise { - return this.request("fs.delete", { workdir, path }); - } - - async readUploadedImagePreview( - workdir: string, - absolutePath: string, - ): Promise { - return this.request("files.preview", { - workdir, - absolute_path: absolutePath, - }); - } - - async readSkillMetadata(path: string): Promise { - return this.request("skills.read-metadata", { path }); - } - - async readSkillText(path: string, offset?: number, length?: number): Promise { - return this.request("skills.read-text", { - path, - offset, - length, - }); - } - - async getProviderModels(type: string, baseUrl: string, apiKey: string): Promise { - return this.request("provider.models", { - type, - base_url: baseUrl, - api_key: apiKey, - }); - } - - dispose() { - if (this.disposed) { - return; - } - this.disposed = true; - this.uninstallWorkerWakeups(); - try { - this.postMessage({ type: "dispose", connection_id: this.connectionID }); - } catch { - // The worker connection may already be gone. - } - this.port.onmessage = null; - this.port.onmessageerror = null; - this.port.close(); - this.handleDisconnect(new Error("Gateway SharedWorker client disposed")); - } - - private async refreshStatus() { - try { - await this.getStatus(); - } catch (error) { - this.statusRefreshRequested = false; - const message = asErrorMessage(error, "status request failed"); - const offlineStatus = - this.lastStatus !== null - ? { - ...this.lastStatus, - online: false, - } - : null; - this.emitStatus(offlineStatus, message); - } - } - - private async request(method: string, payload: unknown): Promise { - await this.ensureConnected(); - if (this.disposed) { - throw new Error("Gateway SharedWorker client has been disposed"); - } - - const requestId = this.nextRequestId(method); - return new Promise((resolve, reject) => { - const host = getRuntimeHost(); - const timeoutId = host.setTimeout(() => { - this.pending.delete(requestId); - reject(new Error(`Gateway SharedWorker request timed out: ${method}`)); - }, 30_000); - - this.pending.set(requestId, { resolve, reject, timeoutId }); - try { - this.postMessage({ - type: "request", - connection_id: this.connectionID, - request_id: requestId, - method, - payload, - }); - } catch (error) { - host.clearTimeout(timeoutId); - this.pending.delete(requestId); - reject(error); - } - }); - } - - private async ensureConnected() { - if (this.disposed) { - throw new Error("Gateway SharedWorker client has been disposed"); - } - if (this.token.trim() === "") { - throw new Error("Gateway token is required"); - } - await this.connectPromise; - } - - private installWorkerWakeups() { - if (typeof window !== "undefined" && typeof window.addEventListener === "function") { - window.addEventListener("online", this.workerWakeup); - window.addEventListener("focus", this.workerWakeup); - window.addEventListener("pageshow", this.workerWakeup); - window.addEventListener("pagehide", this.workerWakeup); - } - if (typeof document !== "undefined" && typeof document.addEventListener === "function") { - document.addEventListener("visibilitychange", this.workerWakeup); - document.addEventListener("freeze", this.workerWakeup as EventListener); - document.addEventListener("resume", this.workerWakeup as EventListener); - } - } - - private uninstallWorkerWakeups() { - if (typeof window !== "undefined" && typeof window.removeEventListener === "function") { - window.removeEventListener("online", this.workerWakeup); - window.removeEventListener("focus", this.workerWakeup); - window.removeEventListener("pageshow", this.workerWakeup); - window.removeEventListener("pagehide", this.workerWakeup); - } - if (typeof document !== "undefined" && typeof document.removeEventListener === "function") { - document.removeEventListener("visibilitychange", this.workerWakeup); - document.removeEventListener("freeze", this.workerWakeup as EventListener); - document.removeEventListener("resume", this.workerWakeup as EventListener); - } - } - - private postWorkerWakeup(event?: Event) { - if (this.disposed || !isForegroundWakeupEvent(event)) { - return; - } - try { - this.postMessage({ - type: "wakeup", - connection_id: this.connectionID, - }); - } catch { - this.handleDisconnect(new Error("Gateway SharedWorker wakeup failed")); - } - } - - private postMessage(message: SharedWorkerClientRequestMessage) { - this.port.postMessage(message); - } - - private handleMessage(raw: unknown) { - const message = raw as SharedWorkerClientMessage; - if (!message || typeof message !== "object" || message.connection_id !== this.connectionID) { - return; - } - - switch (message.type) { - case "ready": - this.clearReadyTimeout(); - this.connectSettled = true; - this.resolveConnect(); - this.emitStatus(message.payload.status, message.payload.error); - return; - case "response": - this.handleResponse(message); - return; - case "event": - this.handleWorkerEvent(message); - return; - case "terminal_stream_snapshot": - this.terminalStream.handleSnapshot(message); - return; - case "terminal_stream_output": - this.terminalStream.handleOutput(message); - return; - case "terminal_stream_input_state": - this.terminalStream.handleInputState(message); - return; - case "terminal_stream_error": - this.terminalStream.handleError(message); - return; - } - } - - private handleResponse(message: SharedWorkerClientResponseMessage) { - const pending = this.pending.get(message.request_id); - if (!pending) { - return; - } - const host = getRuntimeHost(); - host.clearTimeout(pending.timeoutId); - this.pending.delete(message.request_id); - if (message.error) { - pending.reject(new Error(message.error)); - return; - } - pending.resolve(message.payload); - } - - private handleWorkerEvent(message: SharedWorkerClientEventMessage) { - switch (message.event_type) { - case "status": { - const payload = message.payload as { status?: AgentStatus | null; error?: string | null }; - this.emitStatus(payload.status ?? null, payload.error ?? null); - return; - } - case "history": - this.emitHistory(message.payload as GatewayHistoryEvent); - return; - case "settings": - this.emitSettings(message.payload as GatewaySettingsSyncPayload); - return; - case "terminal": { - const event = normalizeTerminalEvent(message.payload as RawTerminalEvent); - if (event) { - this.emitTerminal(event); - } - return; - } - case "sftp": { - const event = normalizeSftpTransferEvent(message.payload as RawSftpEvent); - if (event) { - this.emitSftpTransfer(event); - } - return; - } - case "chat_queue": { - const snapshot = normalizeChatQueueEvent(message.payload as RawChatQueueEvent); - if (snapshot) { - this.emitChatQueue(snapshot); - } - return; - } - } - } - - private emitStatus(status: AgentStatus | null, error: string | null) { - this.lastStatus = status; - this.lastStatusError = error; - if (status?.online === false) { - this.terminalSessionSnapshot.clear(); - } - for (const listener of this.statusListeners) { - listener(status, error); - } - } - - private emitHistory(event: GatewayHistoryEvent) { - for (const listener of this.historyListeners) { - listener(event); - } - } - - private emitSettings(event: GatewaySettingsSyncPayload) { - for (const listener of this.settingsListeners) { - listener(event); - } - } - - private emitTerminal(event: TerminalEvent) { - applyTerminalSnapshotEvent(this.terminalSessionSnapshot, event); - for (const listener of this.terminalListeners) { - listener(event); - } - } - - private emitSftpTransfer(event: SftpTransferEvent) { - for (const listener of this.sftpTransferListeners) { - listener(event); - } - } - - private emitChatQueue(snapshot: ChatQueueSnapshot) { - for (const listener of this.chatQueueListeners) { - listener(snapshot); - } - } - - private handleDisconnect(error: Error) { - this.clearReadyTimeout(); - if (!this.connectSettled) { - this.connectSettled = true; - this.rejectConnect(error); - } - - const host = getRuntimeHost(); - const pending = [...this.pending.values()]; - this.pending.clear(); - for (const entry of pending) { - host.clearTimeout(entry.timeoutId); - entry.reject(error); - } - - this.terminalStream.disposeAll(error); - } - - private nextRequestId(prefix: string) { - this.requestSeq += 1; - return `${prefix}-${Date.now()}-${this.requestSeq}`; - } - - private clearReadyTimeout() { - if (this.readyTimeoutId === null) { - return; - } - const host = getRuntimeHost(); - host.clearTimeout(this.readyTimeoutId); - this.readyTimeoutId = null; - } -} - -let activeClient: GatewayWebSocketClientLike | null = null; -let activeToken = ""; - -export function getGatewayWebSocketClient(token: string) { - const normalizedToken = token.trim(); - if (activeClient && activeToken === normalizedToken) { - return activeClient; +export function getGatewayWebSocketClient(token: string): GatewayWebSocketClient { + const normalizedToken = token.trim(); + if (activeClient && activeToken === normalizedToken) { + return activeClient; } activeClient?.dispose(); activeToken = normalizedToken; - if (canUseSharedWorker() && normalizedToken) { - try { - activeClient = new SharedWorkerGatewayWebSocketClient(normalizedToken); - return activeClient; - } catch { - // Fall back to the page-owned socket in browsers that expose but reject SharedWorker. - } - } activeClient = new GatewayWebSocketClient(normalizedToken); return activeClient; } diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts deleted file mode 100644 index 3576cbcd..00000000 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts +++ /dev/null @@ -1,1277 +0,0 @@ -import type { - GatewaySettingsSyncPayload, - GatewaySettingsSyncUpdatePayload, -} from "@/lib/settings/sync"; -import type { HistoryMessageRef } from "@/lib/chat/conversationState"; -import type { - TerminalEvent, - TerminalSession, - TerminalSnapshot, - TerminalStreamChunk, - TerminalStreamHandle, - TerminalStreamInputState, - TerminalStreamSnapshot, -} from "@/lib/terminal/types"; - -import { GatewayWebSocketClient } from "./gatewaySocket"; -import type { - AgentStatus, - CronManagePayload, - MemoryManagePayload, -} from "./gatewayTypes"; - -type WorkerClientRequest = - | { - type: "connect"; - connection_id: string; - token: string; - } - | { - type: "request"; - connection_id: string; - request_id: string; - method: string; - payload?: unknown; - } - | { - type: "wakeup"; - connection_id: string; - } - | { - type: "terminal_stream_attach"; - connection_id: string; - stream_id: string; - session: TerminalSession; - max_bytes?: number; - } - | { - type: "terminal_stream_write"; - connection_id: string; - stream_id: string; - session_id: string; - bytes?: Uint8Array | ArrayBuffer; - } - | { - type: "terminal_stream_resize"; - connection_id: string; - stream_id: string; - session_id: string; - cols: number; - rows: number; - } - | { - type: "terminal_stream_detach"; - connection_id: string; - stream_id: string; - session_id: string; - } - | { - type: "dispose"; - connection_id: string; - }; - -type ManagedClient = { - token: string; - client: GatewayWebSocketClient; - ports: Set; - status: AgentStatus | null; - statusError: string | null; - idleTimer: ReturnType | null; - terminalSessions: Map; - terminalStreams: Map; - terminalStreamPageSessions: Map; -}; - -type PortState = { - connectionID: string; - client: ManagedClient; - terminalAllProjects: boolean; - terminalProjectKeys: Set; - terminalSessionIds: Set; - terminalStreamIds: Set; -}; - -type ManagedTerminalStream = { - session: TerminalSession; - maxBytes: number; - handle: TerminalStreamHandle | null; - attaching: Promise | null; - pageStreams: Map; - unsubscribe: (() => void) | null; - inputStateUnsubscribe: (() => void) | null; - lastInputState: TerminalStreamInputState; -}; - -type SharedWorkerScope = { - onconnect: ((event: MessageEvent & { ports: MessagePort[] }) => void) | null; -}; - -const clients = new Map(); -const portStates = new Map(); -const MANAGED_CLIENT_WARM_WINDOW_MS = 10 * 60_000; - -function asErrorMessage(error: unknown, fallback: string) { - if (error instanceof Error && error.message.trim()) { - return error.message.trim(); - } - const text = String(error ?? "").trim(); - return text || fallback; -} - -function postToPort(port: MessagePort, payload: unknown) { - try { - port.postMessage(payload); - } catch { - disconnectPort(port); - } -} - -function readNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined; -} - -function readString(value: unknown) { - return typeof value === "string" ? value.trim() : ""; -} - -function normalizeWorkerHistoryMessageRef(value: unknown): HistoryMessageRef { - const body = (value && typeof value === "object" ? value : {}) as Record; - const segmentIndex = readNumber(body.segmentIndex ?? body.segment_index); - const messageIndex = readNumber(body.messageIndex ?? body.message_index); - const segmentId = readString(body.segmentId ?? body.segment_id); - const messageId = readString(body.messageId ?? body.message_id); - const role = readString(body.role); - const contentHash = readString(body.contentHash ?? body.content_hash); - if ( - segmentIndex === undefined || - messageIndex === undefined || - segmentIndex < 0 || - messageIndex < 0 || - !segmentId || - !messageId || - !role || - !contentHash - ) { - throw new Error("history.prefix requires a complete base_message_ref"); - } - return { - segmentIndex, - messageIndex, - segmentId, - messageId, - role, - contentHash, - }; -} - -function broadcast(client: ManagedClient, payload: Record) { - for (const port of [...client.ports]) { - const state = portStates.get(port); - if (!state || state.client !== client) { - client.ports.delete(port); - continue; - } - postToPort(port, { - ...payload, - connection_id: state.connectionID, - }); - } -} - -function shouldPostTerminalEventToPort(state: PortState, event: TerminalEvent) { - const sessionID = event.sessionId?.trim() ?? ""; - if (event.kind === "output") { - return sessionID !== "" && state.terminalSessionIds.has(sessionID); - } - const projectPathKey = event.projectPathKey.trim(); - if (sessionID !== "" || projectPathKey !== "") { - return true; - } - if (state.terminalAllProjects) { - return true; - } - return ( - (sessionID !== "" && state.terminalSessionIds.has(sessionID)) || - (projectPathKey !== "" && state.terminalProjectKeys.has(projectPathKey)) - ); -} - -function applyTerminalSessionEvent( - sessions: Map, - event: TerminalEvent, -) { - if (event.kind === "output") return; - const sessionId = (event.sessionId || event.session?.id || "").trim(); - if (event.kind === "closed") { - if (sessionId) { - sessions.delete(sessionId); - } - return; - } - const session = event.session; - if (session?.id) { - sessions.set(session.id, session); - } -} - -function replayTerminalSessionsToPort(port: MessagePort, state: PortState) { - const sessions = [...state.client.terminalSessions.values()].sort((a, b) => { - const leftProject = (a.projectPathKey || a.cwd || "").trim(); - const rightProject = (b.projectPathKey || b.cwd || "").trim(); - return leftProject.localeCompare(rightProject) || a.createdAt - b.createdAt; - }); - for (const session of sessions) { - const event: TerminalEvent = { - kind: "created", - sessionId: session.id, - projectPathKey: session.projectPathKey, - session, - }; - if (!shouldPostTerminalEventToPort(state, event)) { - continue; - } - postToPort(port, { - type: "event", - event_type: "terminal", - payload: event, - connection_id: state.connectionID, - }); - } -} - -function broadcastTerminal(client: ManagedClient, event: TerminalEvent) { - for (const port of [...client.ports]) { - const state = portStates.get(port); - if (!state || state.client !== client) { - client.ports.delete(port); - continue; - } - if (!shouldPostTerminalEventToPort(state, event)) { - continue; - } - postToPort(port, { - type: "event", - event_type: "terminal", - payload: event, - connection_id: state.connectionID, - }); - } -} - -function normalizeTerminalStreamBytes(input: unknown) { - if (input instanceof Uint8Array) { - return input.byteLength === 0 ? input : input.slice(); - } - if (input instanceof ArrayBuffer) { - return input.byteLength === 0 ? new Uint8Array() : new Uint8Array(input.slice(0)); - } - if (ArrayBuffer.isView(input)) { - const view = input as ArrayBufferView; - return new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength)); - } - return new Uint8Array(); -} - -function postTerminalStreamSnapshot( - port: MessagePort, - connectionID: string, - streamID: string, - snapshot: TerminalStreamSnapshot, -) { - postToPort(port, { - type: "terminal_stream_snapshot", - connection_id: connectionID, - stream_id: streamID, - payload: snapshot, - }); -} - -function postTerminalStreamOutput( - port: MessagePort, - connectionID: string, - streamID: string, - chunk: TerminalStreamChunk, -) { - postToPort(port, { - type: "terminal_stream_output", - connection_id: connectionID, - stream_id: streamID, - payload: chunk, - }); -} - -function postTerminalStreamInputState( - port: MessagePort, - connectionID: string, - streamID: string, - state: TerminalStreamInputState, -) { - postToPort(port, { - type: "terminal_stream_input_state", - connection_id: connectionID, - stream_id: streamID, - payload: state, - }); -} - -function postTerminalStreamError( - port: MessagePort, - connectionID: string, - streamID: string, - error: string, -) { - postToPort(port, { - type: "terminal_stream_error", - connection_id: connectionID, - stream_id: streamID, - error, - }); -} - -function closeManagedTerminalStream( - client: ManagedClient, - sessionID: string, - entry: ManagedTerminalStream, -) { - if (client.terminalStreams.get(sessionID) !== entry) { - return; - } - client.terminalStreams.delete(sessionID); - for (const streamID of entry.pageStreams.keys()) { - client.terminalStreamPageSessions.delete(streamID); - } - entry.pageStreams.clear(); - entry.unsubscribe?.(); - entry.unsubscribe = null; - entry.inputStateUnsubscribe?.(); - entry.inputStateUnsubscribe = null; - entry.handle?.dispose(); - entry.handle = null; - entry.attaching = null; -} - -function closeAllManagedTerminalStreams(client: ManagedClient) { - for (const [sessionID, entry] of [...client.terminalStreams]) { - closeManagedTerminalStream(client, sessionID, entry); - } - client.terminalStreamPageSessions.clear(); -} - -function broadcastTerminalStreamChunk( - client: ManagedClient, - sessionID: string, - entry: ManagedTerminalStream, - chunk: TerminalStreamChunk, -) { - for (const [streamID, ref] of [...entry.pageStreams]) { - const state = portStates.get(ref.port); - if (!state || state.client !== client || state.connectionID !== ref.connectionID) { - entry.pageStreams.delete(streamID); - client.terminalStreamPageSessions.delete(streamID); - continue; - } - postTerminalStreamOutput(ref.port, ref.connectionID, streamID, chunk); - } - if (entry.pageStreams.size === 0) { - closeManagedTerminalStream(client, sessionID, entry); - } -} - -function broadcastTerminalStreamInputState( - client: ManagedClient, - sessionID: string, - entry: ManagedTerminalStream, - inputState: TerminalStreamInputState, -) { - for (const [streamID, ref] of [...entry.pageStreams]) { - const state = portStates.get(ref.port); - if (!state || state.client !== client || state.connectionID !== ref.connectionID) { - entry.pageStreams.delete(streamID); - client.terminalStreamPageSessions.delete(streamID); - continue; - } - postTerminalStreamInputState(ref.port, ref.connectionID, streamID, inputState); - } - if (entry.pageStreams.size === 0) { - closeManagedTerminalStream(client, sessionID, entry); - } -} - -function ensureManagedTerminalStreamAttached( - client: ManagedClient, - sessionID: string, - entry: ManagedTerminalStream, -) { - if (entry.handle) { - return; - } - if (entry.attaching) { - return; - } - - entry.attaching = client.client.terminalStream.attach(entry.session, { - maxBytes: entry.maxBytes, - }); - entry.attaching - .then((handle) => { - if (client.terminalStreams.get(sessionID) !== entry || entry.pageStreams.size === 0) { - handle.dispose(); - return; - } - entry.handle = handle; - entry.attaching = null; - entry.unsubscribe = handle.subscribeOutput((chunk) => { - broadcastTerminalStreamChunk(client, sessionID, entry, chunk); - }); - entry.inputStateUnsubscribe = handle.subscribeInputState((inputState) => { - entry.lastInputState = inputState; - broadcastTerminalStreamInputState(client, sessionID, entry, inputState); - }); - for (const [streamID, ref] of entry.pageStreams) { - postTerminalStreamSnapshot(ref.port, ref.connectionID, streamID, handle.snapshot); - } - }) - .catch((error) => { - const message = asErrorMessage(error, "Terminal stream attach failed"); - for (const [streamID, ref] of entry.pageStreams) { - postTerminalStreamError(ref.port, ref.connectionID, streamID, message); - } - closeManagedTerminalStream(client, sessionID, entry); - }); -} - -function handleTerminalStreamAttach( - port: MessagePort, - state: PortState, - message: Extract, -) { - const streamID = message.stream_id.trim(); - const session = message.session; - const sessionID = session?.id?.trim() ?? ""; - if (!streamID || !sessionID) { - postTerminalStreamError(port, state.connectionID, streamID, "Terminal stream attach is invalid"); - return; - } - - const previousSessionID = state.client.terminalStreamPageSessions.get(streamID); - if (previousSessionID) { - detachTerminalStreamPage(state, streamID, previousSessionID); - } - - state.terminalStreamIds.add(streamID); - state.terminalSessionIds.add(sessionID); - state.client.terminalStreamPageSessions.set(streamID, sessionID); - - let entry = state.client.terminalStreams.get(sessionID); - if (!entry) { - entry = { - session, - maxBytes: Math.max(0, Math.round(message.max_bytes ?? 256 * 1024)), - handle: null, - attaching: null, - pageStreams: new Map(), - unsubscribe: null, - inputStateUnsubscribe: null, - lastInputState: { - paused: false, - queuedBytes: 0, - highWaterBytes: 256 * 1024, - }, - }; - state.client.terminalStreams.set(sessionID, entry); - } else { - entry.session = session; - entry.maxBytes = Math.max(entry.maxBytes, Math.max(0, Math.round(message.max_bytes ?? 0))); - } - - entry.pageStreams.set(streamID, { port, connectionID: state.connectionID }); - if (entry.handle) { - postTerminalStreamSnapshot(port, state.connectionID, streamID, entry.handle.snapshot); - postTerminalStreamInputState(port, state.connectionID, streamID, entry.lastInputState); - return; - } - ensureManagedTerminalStreamAttached(state.client, sessionID, entry); -} - -function terminalStreamEntryForMessage( - state: PortState, - streamID: string, -) { - if (!state.terminalStreamIds.has(streamID)) { - return null; - } - const sessionID = state.client.terminalStreamPageSessions.get(streamID) ?? ""; - if (!sessionID) { - return null; - } - return state.client.terminalStreams.get(sessionID) ?? null; -} - -function handleTerminalStreamWrite( - state: PortState, - message: Extract, -) { - const streamID = message.stream_id.trim(); - const entry = terminalStreamEntryForMessage(state, streamID); - if (!entry?.handle) { - return; - } - const bytes = normalizeTerminalStreamBytes(message.bytes); - if (bytes.byteLength === 0) { - return; - } - const accepted = entry.handle.write(bytes); - if (!accepted) { - const inputState = entry.lastInputState.paused - ? entry.lastInputState - : { - paused: true, - queuedBytes: 0, - highWaterBytes: entry.lastInputState.highWaterBytes, - reason: "slow" as const, - }; - for (const [pageStreamID, pageStream] of entry.pageStreams) { - postTerminalStreamInputState(pageStream.port, pageStream.connectionID, pageStreamID, inputState); - } - } -} - -function handleTerminalStreamResize( - state: PortState, - message: Extract, -) { - const streamID = message.stream_id.trim(); - const entry = terminalStreamEntryForMessage(state, streamID); - if (!entry?.handle) { - return; - } - entry.handle.resize(message.cols, message.rows); -} - -function detachTerminalStreamPage( - state: PortState, - streamID: string, - fallbackSessionID: string, -) { - const sessionID = - state.client.terminalStreamPageSessions.get(streamID) || fallbackSessionID.trim(); - if (!sessionID) { - return; - } - state.terminalStreamIds.delete(streamID); - state.client.terminalStreamPageSessions.delete(streamID); - const entry = state.client.terminalStreams.get(sessionID); - if (!entry) { - return; - } - entry.pageStreams.delete(streamID); - if (entry.pageStreams.size === 0) { - closeManagedTerminalStream(state.client, sessionID, entry); - } -} - -function scheduleManagedClientCleanup(client: ManagedClient) { - if (client.ports.size > 0 || client.idleTimer !== null) { - return; - } - client.idleTimer = setTimeout(() => { - client.idleTimer = null; - if (client.ports.size > 0) { - return; - } - closeAllManagedTerminalStreams(client); - client.client.dispose(); - clients.delete(client.token); - }, MANAGED_CLIENT_WARM_WINDOW_MS); -} - -function clearManagedClientCleanup(client: ManagedClient) { - if (client.idleTimer === null) { - return; - } - clearTimeout(client.idleTimer); - client.idleTimer = null; -} - -function getManagedClient(token: string) { - const normalizedToken = token.trim(); - const existing = clients.get(normalizedToken); - if (existing) { - return existing; - } - - const managed: ManagedClient = { - token: normalizedToken, - client: new GatewayWebSocketClient(normalizedToken), - ports: new Set(), - status: null, - statusError: null, - idleTimer: null, - terminalSessions: new Map(), - terminalStreams: new Map(), - terminalStreamPageSessions: new Map(), - }; - - managed.client.subscribeStatus((status, error) => { - managed.status = status; - managed.statusError = error; - if (status?.online === false) { - managed.terminalSessions.clear(); - closeAllManagedTerminalStreams(managed); - } - broadcast(managed, { - type: "event", - event_type: "status", - payload: { status, error }, - }); - }); - managed.client.subscribeHistory((event) => { - broadcast(managed, { - type: "event", - event_type: "history", - payload: event, - }); - }); - managed.client.subscribeSettings((event) => { - broadcast(managed, { - type: "event", - event_type: "settings", - payload: event, - }); - }); - managed.client.subscribeTerminal((event) => { - applyTerminalSessionEvent(managed.terminalSessions, event); - broadcastTerminal(managed, event); - }); - managed.client.subscribeSftpTransfers((event) => { - broadcast(managed, { - type: "event", - event_type: "sftp", - payload: event, - }); - }); - managed.client.subscribeChatQueue((snapshot) => { - broadcast(managed, { - type: "event", - event_type: "chat_queue", - payload: snapshot, - }); - }); - - clients.set(normalizedToken, managed); - return managed; -} - -function connectPort( - port: MessagePort, - message: Extract, -) { - const client = getManagedClient(message.token); - clearManagedClientCleanup(client); - client.ports.add(port); - const state: PortState = { - connectionID: message.connection_id, - client, - terminalAllProjects: false, - terminalProjectKeys: new Set(), - terminalSessionIds: new Set(), - terminalStreamIds: new Set(), - }; - portStates.set(port, state); - postToPort(port, { - type: "ready", - connection_id: message.connection_id, - payload: { - status: client.status, - error: client.statusError, - }, - }); - replayTerminalSessionsToPort(port, state); -} - -function disconnectPort(port: MessagePort) { - const state = portStates.get(port); - if (!state) { - return; - } - const terminalStreamIds = [...state.terminalStreamIds]; - for (const streamID of terminalStreamIds) { - detachTerminalStreamPage(state, streamID, ""); - } - state.client.ports.delete(port); - portStates.delete(port); - scheduleManagedClientCleanup(state.client); -} - -async function resolveRequest(client: GatewayWebSocketClient, method: string, payload: unknown) { - const body = (payload ?? {}) as Record; - switch (method) { - case "status.get": - return client.getStatus(); - case "chat.prepare": - client.noteForegroundWakeup(); - return client.prepareChatRuntime("shared-worker"); - case "fs.roots": - return client.listFsRoots(); - case "fs.list_dirs": - return client.listDirs( - String(body.path ?? ""), - typeof body.max_results === "number" ? body.max_results : undefined, - ); - case "fs.create_project_folder": - return client.createProjectFolder(String(body.parent ?? ""), String(body.name ?? "")); - case "fs.list": - return client.listFiles( - String(body.workdir ?? ""), - typeof body.path === "string" ? body.path : undefined, - typeof body.depth === "number" ? body.depth : undefined, - typeof body.offset === "number" ? body.offset : undefined, - typeof body.max_results === "number" ? body.max_results : undefined, - ); - case "fs.write_text": - return client.writeTextFile({ - workdir: String(body.workdir ?? ""), - path: String(body.path ?? ""), - content: typeof body.content === "string" ? body.content : "", - mode: typeof body.mode === "string" ? body.mode : undefined, - expectedMtimeMs: - typeof body.expected_mtime_ms === "number" ? body.expected_mtime_ms : undefined, - expectedContentHash: - typeof body.expected_content_hash === "string" ? body.expected_content_hash : undefined, - }); - case "fs.read_editable_text": - return client.readEditableTextFile(String(body.workdir ?? ""), String(body.path ?? "")); - case "fs.read_workspace_image": - return client.readWorkspaceImageFile(String(body.workdir ?? ""), String(body.path ?? "")); - case "fs.create_dir": - return client.createDir(String(body.workdir ?? ""), String(body.path ?? "")); - case "fs.rename": - return client.renamePath( - String(body.workdir ?? ""), - String(body.from_path ?? ""), - String(body.to_path ?? ""), - ); - case "fs.delete": - return client.deletePath(String(body.workdir ?? ""), String(body.path ?? "")); - case "history.list": - return client.listHistory( - typeof body.page === "number" ? body.page : 0, - typeof body.page_size === "number" ? body.page_size : 0, - { - cwd: typeof body.cwd === "string" ? body.cwd : undefined, - cwdEmpty: body.cwd_empty === true, - }, - ); - case "history.workdirs": - return client.listHistoryWorkdirs(); - case "history.shared_list": - return client.listSharedHistory( - typeof body.page === "number" ? body.page : 0, - typeof body.page_size === "number" ? body.page_size : 0, - ); - case "history.get": - return client.getHistory(String(body.conversation_id ?? ""), { - maxMessages: typeof body.max_messages === "number" ? body.max_messages : undefined, - }); - case "history.prefix": - return client.getHistoryPrefix( - String(body.conversation_id ?? ""), - normalizeWorkerHistoryMessageRef(body.base_message_ref), - { - maxMessages: typeof body.max_messages === "number" ? body.max_messages : undefined, - }, - ); - case "history.rename": - return client.renameHistory(String(body.conversation_id ?? ""), String(body.title ?? "")); - case "history.pin": - return client.pinHistory(String(body.conversation_id ?? ""), body.is_pinned === true); - case "history.share.get": - return client.getHistoryShare(String(body.conversation_id ?? "")); - case "history.share.set": - return client.setHistoryShare( - String(body.conversation_id ?? ""), - body.enabled === true, - typeof body.redact_tool_content === "boolean" - ? { redactToolContent: body.redact_tool_content } - : undefined, - ); - case "history.delete": - await client.deleteHistory(String(body.conversation_id ?? "")); - return undefined; - case "chat_queue.get": - return client.chatQueueGet(String(body.conversation_id ?? "")); - case "chat_queue.get_item": - return client.chatQueueGetItem( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - ); - case "chat_queue.run_now": - return client.chatQueueRunNow( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - ); - case "chat_queue.move": - return client.chatQueueMove( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - body.direction === "down" ? "down" : "up", - ); - case "chat_queue.remove": - return client.chatQueueRemove( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - ); - case "chat_queue.edit_begin": - return client.chatQueueEditBegin( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - ); - case "chat_queue.edit_commit": - return client.chatQueueEditCommit({ - conversationId: String(body.conversation_id ?? ""), - itemId: String(body.item_id ?? ""), - revision: typeof body.revision === "number" ? body.revision : 0, - draftJson: String(body.draft_json ?? ""), - uploadedFilesJson: String(body.uploaded_files_json ?? ""), - }); - case "chat_queue.edit_cancel": - return client.chatQueueEditCancel( - String(body.conversation_id ?? ""), - String(body.item_id ?? ""), - ); - case "providers.list": - return client.listProviders(); - case "settings.get": - return client.getSettings(); - case "settings.update": - await client.updateSettings(payload as GatewaySettingsSyncUpdatePayload); - return undefined; - case "settings.ssh_known_host.reset": { - const body = (payload && typeof payload === "object" ? payload : {}) as Record< - string, - unknown - >; - return client.resetSshKnownHost({ - host: String(body.host ?? ""), - port: typeof body.port === "number" ? body.port : Number(body.port ?? 0), - }); - } - case "skills.list": - return client.listSkillFiles(); - case "skills.manage": - return client.manageSkill(payload as Record); - case "mentions.list": - return client.listMentionFiles( - String(body.workdir ?? ""), - typeof body.max_results === "number" ? body.max_results : undefined, - typeof body.query === "string" ? body.query : undefined, - ); - case "skills.read-metadata": - return client.readSkillMetadata(String(body.path ?? "")); - case "skills.read-text": - return client.readSkillText( - String(body.path ?? ""), - typeof body.offset === "number" ? body.offset : undefined, - typeof body.length === "number" ? body.length : undefined, - ); - case "files.preview": - return client.readUploadedImagePreview( - String(body.workdir ?? ""), - String(body.absolute_path ?? ""), - ); - case "cron.manage": - return client.cronManage(payload as CronManagePayload); - case "memory.manage": - return client.memoryManage(payload as MemoryManagePayload); - case "git.status": - case "git.branches": - case "git.init": - case "git.switch_branch": - case "git.create_branch": - case "git.diff": - case "git.log": - case "git.commit_details": - case "git.compare_commit_with_remote": - case "git.commit_diff": - case "git.stage": - case "git.stage_all": - case "git.unstage": - case "git.unstage_all": - case "git.discard": - case "git.discard_all": - case "git.add_to_gitignore": - case "git.commit": - case "git.fetch": - case "git.pull": - case "git.set_remote": - case "git.push": - return client.gitRequest( - method.slice("git.".length), - String(body.workdir ?? ""), - (body.args && typeof body.args === "object" ? body.args : {}) as Record, - ); - case "terminal.shell_options": - return client.terminalShellOptions(); - case "terminal.list": - return { - sessions: await client.listTerminals(String(body.project_path_key ?? "")), - }; - case "terminal.create": - return client.createTerminal({ - cwd: String(body.cwd ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - shell: typeof body.shell === "string" ? body.shell : undefined, - title: typeof body.title === "string" ? body.title : undefined, - cols: typeof body.cols === "number" ? body.cols : undefined, - rows: typeof body.rows === "number" ? body.rows : undefined, - }); - case "terminal.create_ssh": - return client.createSshTerminal({ - cwd: String(body.cwd ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - hostId: String(body.ssh_host_id ?? ""), - title: typeof body.title === "string" ? body.title : undefined, - cols: typeof body.cols === "number" ? body.cols : undefined, - rows: typeof body.rows === "number" ? body.rows : undefined, - sftpEnabled: body.sftp_enabled === true, - }); - case "terminal.answer_ssh_prompt": - return client.answerSshTerminalPrompt({ - promptId: String(body.prompt_id ?? ""), - answer: typeof body.prompt_answer === "string" ? body.prompt_answer : undefined, - trustHostKey: body.trust_host_key === true, - }); - case "terminal.cancel_ssh_prompt": - await client.cancelSshTerminalPrompt(String(body.prompt_id ?? "")); - return { action: "cancel_ssh_prompt" }; - case "terminal.ssh_latency": - return client.sshTerminalLatency( - String(body.session_id ?? ""), - String(body.project_path_key ?? ""), - ); - case "terminal.ssh_tabs_list": - return { - ssh_tabs: await client.listSshTerminalTabs(String(body.project_path_key ?? "")), - }; - case "terminal.ssh_tab_open": - return { - ssh_tabs: await client.openSshTerminalTab({ - sessionId: String(body.session_id ?? ""), - kind: body.tab_kind === "sftp" ? "sftp" : "bash", - }), - }; - case "terminal.ssh_tab_close": - return { - ssh_tabs: await client.closeSshTerminalTab(String(body.tab_id ?? "")), - }; - case "terminal.rename": - return { - session: await client.renameTerminal( - String(body.session_id ?? ""), - String(body.title ?? ""), - String(body.project_path_key ?? ""), - ), - }; - case "terminal.close": - return { - session: await client.closeTerminal( - String(body.session_id ?? ""), - String(body.project_path_key ?? ""), - ), - }; - case "terminal.close_project": - return { - sessions: await client.closeProjectTerminals(String(body.project_path_key ?? "")), - }; - case "sftp.list": - return client.sftpList({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - side: body.side === "local" ? "local" : "remote", - path: String( - body.side === "local" ? (body.local_path ?? "") : (body.remote_path ?? ""), - ), - }); - case "sftp.stat": - return client.sftpStat({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - side: body.side === "local" ? "local" : "remote", - path: String( - body.side === "local" ? (body.local_path ?? "") : (body.remote_path ?? ""), - ), - }); - case "sftp.mkdir": - return client.sftpMkdir({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - side: body.side === "local" ? "local" : "remote", - path: String( - body.side === "local" ? (body.local_path ?? "") : (body.remote_path ?? ""), - ), - }); - case "sftp.rename": - return client.sftpRename({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - side: body.side === "local" ? "local" : "remote", - fromPath: String(body.from_path ?? ""), - toPath: String(body.to_path ?? ""), - }); - case "sftp.delete": - return client.sftpDelete({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - side: body.side === "local" ? "local" : "remote", - path: String( - body.side === "local" ? (body.local_path ?? "") : (body.remote_path ?? ""), - ), - recursive: body.recursive === true, - }); - case "sftp.transfer": - return client.sftpTransfer({ - sessionId: String(body.session_id ?? ""), - projectPathKey: String(body.project_path_key ?? ""), - workdir: String(body.workdir ?? ""), - direction: body.direction === "download" ? "download" : "upload", - sourcePath: String(body.from_path ?? ""), - targetPath: String(body.target_path ?? ""), - recursive: body.recursive === true, - overwrite: body.overwrite === true, - }); - case "sftp.cancel": - await client.sftpCancelTransfer({ - sessionId: String(body.session_id ?? ""), - transferId: String(body.from_path ?? ""), - }); - return undefined; - case "tunnel.list": - return { - tunnels: await client.listTunnels(), - }; - case "tunnel.create": { - const projectPathKey = - typeof body.projectPathKey === "string" - ? body.projectPathKey.trim() - : typeof body.project_path_key === "string" - ? body.project_path_key.trim() - : ""; - return { - tunnel: await client.createTunnel({ - targetUrl: String(body.targetUrl ?? body.target_url ?? ""), - ttlSeconds: - body.ttlSeconds === 0 || - body.ttlSeconds === 900 || - body.ttlSeconds === 3600 || - body.ttlSeconds === 14400 - ? body.ttlSeconds - : body.ttl_seconds === 0 || - body.ttl_seconds === 900 || - body.ttl_seconds === 3600 || - body.ttl_seconds === 14400 - ? body.ttl_seconds - : 3600, - name: typeof body.name === "string" ? body.name : undefined, - ...(projectPathKey ? { projectPathKey } : {}), - }), - }; - } - case "tunnel.update": { - const projectPathKey = - typeof body.projectPathKey === "string" - ? body.projectPathKey.trim() - : typeof body.project_path_key === "string" - ? body.project_path_key.trim() - : ""; - return { - tunnel: await client.updateTunnel({ - id: String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), - targetUrl: String(body.targetUrl ?? body.target_url ?? ""), - ttlSeconds: - body.ttlSeconds === 0 || - body.ttlSeconds === 900 || - body.ttlSeconds === 3600 || - body.ttlSeconds === 14400 - ? body.ttlSeconds - : body.ttl_seconds === 0 || - body.ttl_seconds === 900 || - body.ttl_seconds === 3600 || - body.ttl_seconds === 14400 - ? body.ttl_seconds - : 3600, - name: typeof body.name === "string" ? body.name : undefined, - ...(projectPathKey ? { projectPathKey } : {}), - }), - }; - } - case "tunnel.close": - return { - tunnel: await client.closeTunnel( - String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), - ), - }; - case "tunnel.probe": - return { - tunnel: await client.probeTunnel( - String(body.id ?? body.tunnelId ?? body.tunnel_id ?? body.slug ?? ""), - ), - }; - case "provider.models": - return client.getProviderModels( - String(body.type ?? ""), - String(body.base_url ?? ""), - String(body.api_key ?? ""), - ); - default: - throw new Error(`Unsupported Gateway SharedWorker method: ${method}`); - } -} - -function terminalPayloadProjectPathKey(payload: unknown) { - const body = (payload ?? {}) as Record; - return typeof body.project_path_key === "string" ? body.project_path_key.trim() : ""; -} - -function terminalPayloadSessionId(payload: unknown) { - const body = (payload ?? {}) as Record; - return typeof body.session_id === "string" ? body.session_id.trim() : ""; -} - -function terminalResponseSession(payload: unknown): TerminalSession | null { - const maybeSnapshot = payload as Partial | null | undefined; - const session = maybeSnapshot?.session; - return session && typeof session.id === "string" ? session : null; -} - -function updatePortTerminalInterest( - state: PortState, - method: string, - requestPayload: unknown, - responsePayload: unknown, -) { - if (!method.startsWith("terminal.")) { - return; - } - - const requestProjectPathKey = terminalPayloadProjectPathKey(requestPayload); - const requestSessionId = terminalPayloadSessionId(requestPayload); - const responseSession = terminalResponseSession(responsePayload); - const responseProjectPathKey = responseSession?.projectPathKey.trim() ?? ""; - const responseSessionId = responseSession?.id.trim() ?? ""; - const projectPathKey = requestProjectPathKey || responseProjectPathKey; - const sessionId = requestSessionId || responseSessionId; - - switch (method) { - case "terminal.list": - if (!projectPathKey) { - state.terminalAllProjects = true; - return; - } - state.terminalProjectKeys.add(projectPathKey); - return; - case "terminal.create": - case "terminal.close_project": - if (projectPathKey) { - state.terminalProjectKeys.add(projectPathKey); - } - return; - case "terminal.close": - if (sessionId) { - state.terminalSessionIds.delete(sessionId); - } - return; - } -} - -function respond( - port: MessagePort, - connectionID: string, - requestID: string, - payload?: unknown, - error?: string, -) { - postToPort(port, { - type: "response", - connection_id: connectionID, - request_id: requestID, - payload, - error, - }); -} - -async function handleRequest( - port: MessagePort, - state: PortState, - message: Extract, -) { - try { - const payload = await resolveRequest(state.client.client, message.method, message.payload); - updatePortTerminalInterest(state, message.method, message.payload, payload); - respond(port, state.connectionID, message.request_id, payload); - } catch (error) { - respond( - port, - state.connectionID, - message.request_id, - undefined, - asErrorMessage(error, "Gateway SharedWorker request failed"), - ); - } -} - -function handlePortMessage(port: MessagePort, raw: unknown) { - const message = raw as WorkerClientRequest; - if (!message || typeof message !== "object") { - return; - } - if (message.type === "connect") { - connectPort(port, message); - return; - } - if (message.type === "dispose") { - disconnectPort(port); - port.close(); - return; - } - - const state = portStates.get(port); - if (!state || state.connectionID !== message.connection_id) { - return; - } - switch (message.type) { - case "request": - void handleRequest(port, state, message); - return; - case "wakeup": - state.client.client.noteForegroundWakeup(); - return; - case "terminal_stream_attach": - handleTerminalStreamAttach(port, state, message); - return; - case "terminal_stream_write": - handleTerminalStreamWrite(state, message); - return; - case "terminal_stream_resize": - handleTerminalStreamResize(state, message); - return; - case "terminal_stream_detach": - detachTerminalStreamPage(state, message.stream_id.trim(), message.session_id); - return; - } -} - -const workerScope = globalThis as unknown as SharedWorkerScope; -workerScope.onconnect = (event) => { - const port = event.ports[0]; - if (!port) { - return; - } - port.onmessage = (message) => handlePortMessage(port, message.data); - port.onmessageerror = () => disconnectPort(port); - port.start(); -}; diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index e0c06b09..79184b08 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -1211,6 +1211,21 @@ impl GatewayController { Some(proto::gateway_envelope::Payload::ChatQueue(request)) => { self.handle_chat_queue_request(request_id, request).await } + Some(proto::gateway_envelope::Payload::ChatEventReplay(request)) => { + match self.handle_chat_event_replay(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::ChatEventReplayResp(response), + ), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } Some(proto::gateway_envelope::Payload::CronManage(request)) => { let should_refresh_settings = matches!(request.action.trim(), "create" | "update" | "delete"); @@ -1240,185 +1255,325 @@ impl GatewayController { } } Some(proto::gateway_envelope::Payload::HistoryList(request)) => { - match gateway_bridge::handle_history_list(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryListResp( - response, - )), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_list(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryListResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.list handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryWorkdirs(_request)) => { - match gateway_bridge::handle_history_workdirs().await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryWorkdirsResp( - response, - )), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_workdirs().await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryWorkdirsResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.workdirs handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryGet(request)) => { - match gateway_bridge::handle_history_get(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryGetResp(response)), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_get(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryGetResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.get handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryPrefix(request)) => { - match gateway_bridge::handle_history_prefix(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryPrefixResp( - response, - )), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_prefix(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryPrefixResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.prefix handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryRename(request)) => { - match gateway_bridge::handle_history_rename(request).await { - Ok(response) => { - if let Some(conversation) = response.conversation.as_ref() { - self.publish_history_sync(build_history_sync_upsert_from_proto( - conversation, - )) - .await; + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_rename(request).await { + Ok(response) => { + if let Some(conversation) = response.conversation.as_ref() { + controller + .publish_history_sync(build_history_sync_upsert_from_proto( + conversation, + )) + .await; + } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryRenameResp(response), + ), + }) + .await } - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryRenameResp( - response, - )), - }) - .await + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.rename handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryPin(request)) => { - match gateway_bridge::handle_history_pin(request).await { - Ok(response) => { - if let Some(conversation) = response.conversation.as_ref() { - self.publish_history_sync(build_history_sync_upsert_from_proto( - conversation, - )) - .await; + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_pin(request).await { + Ok(response) => { + if let Some(conversation) = response.conversation.as_ref() { + controller + .publish_history_sync(build_history_sync_upsert_from_proto( + conversation, + )) + .await; + } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryPinResp(response), + ), + }) + .await } - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryPinResp(response)), - }) - .await + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.pin handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryShareGet(request)) => { - match gateway_bridge::handle_history_share_get(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryShareGetResp( - response, - )), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_get(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareGetResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.get handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryShareSet(request)) => { - match gateway_bridge::handle_history_share_set(request).await { - Ok(response) => { - if let Some(share) = response.share.as_ref() { - match chat_history::chat_history_get_summary_inner( - share.conversation_id.clone(), - ) - .await - { - Ok(summary) => { - self.publish_history_sync(build_history_sync_upsert(&summary)) - .await; - } - Err(error) => { - eprintln!("publish history share sync event failed: {error}") + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_set(request).await { + Ok(response) => { + if let Some(share) = response.share.as_ref() { + match chat_history::chat_history_get_summary_inner( + share.conversation_id.clone(), + ) + .await + { + Ok(summary) => { + controller + .publish_history_sync(build_history_sync_upsert( + &summary, + )) + .await; + } + Err(error) => { + eprintln!( + "publish history share sync event failed: {error}" + ) + } } } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareSetResp( + response, + ), + ), + }) + .await } - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryShareSetResp( - response, - )), - }) - .await + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.set handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryShareResolve(request)) => { - match gateway_bridge::handle_history_share_resolve(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryShareResolveResp( - response, - )), - }) - .await - } - Err(error) => { - let code = history_share_resolve_error_code(&error); - self.send_error_response(request_id, code, error).await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_resolve(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareResolveResp( + response, + ), + ), + }) + .await + } + Err(error) => { + let code = history_share_resolve_error_code(&error); + controller + .send_error_response(request_id.clone(), code, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.resolve handler failed: {err}"); } - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::HistoryDelete(request)) => { let deleted_conversation_id = request.conversation_id.trim().to_string(); - match gateway_bridge::handle_history_delete(request).await { - Ok(response) => { - self.publish_history_sync(build_history_sync_delete( - deleted_conversation_id, - )) - .await; - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryDeleteResp( - response, - )), - }) - .await + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_delete(request).await { + Ok(response) => { + controller + .publish_history_sync(build_history_sync_delete( + deleted_conversation_id, + )) + .await; + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryDeleteResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.delete handler failed: {err}"); } - Err(error) => self.send_error_response(request_id, 500, error).await, - } + }); + Ok(()) } Some(proto::gateway_envelope::Payload::ProviderList(_request)) => { match gateway_bridge::handle_provider_list().await { @@ -2525,6 +2680,43 @@ impl GatewayController { Ok(snapshot) } + async fn handle_chat_event_replay( + &self, + request: proto::ChatEventReplayRequest, + ) -> Result { + let conversation_id = request.conversation_id.trim().to_string(); + if conversation_id.is_empty() { + return Err("conversation_id is required".to_string()); + } + let after_seq = request.after_seq; + + let record = chat_history::chat_history_get(conversation_id.clone()).await?; + let mut events = Vec::new(); + let mut seq: i64 = 0; + + for segment in &record.segments { + let messages: Vec = + serde_json::from_str(&segment.messages_json).unwrap_or_default(); + for message in messages { + seq += 1; + if seq <= after_seq { + continue; + } + let event_json = serde_json::to_string(&message).unwrap_or_default(); + if !event_json.is_empty() { + events.push(proto::ChatReplayEvent { seq, event_json }); + } + } + } + + Ok(proto::ChatEventReplayResponse { + run_id: request.run_id, + conversation_id, + events, + complete: true, + }) + } + async fn handle_chat_command( self: &Arc, request_id: String, From ef974a4f3c9d4e1a35f4471408e3d2d2e5fb707e Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 11:02:41 +0800 Subject: [PATCH 14/64] refactor(gateway): extract chat payload shaping into chatwire package Move chatEventPayload/chatControlPayload/chatEventType/trim helpers out of internal/server into a standalone internal/chatwire package so the session layer can shape events at ingress without an import cycle. Co-Authored-By: Claude Fable 5 --- .../chat_payloads.go => chatwire/payloads.go} | 38 ++++--- .../internal/chatwire/payloads_test.go | 105 ++++++++++++++++++ .../internal/server/http_chat.go | 9 +- .../internal/server/websocket_payload_test.go | 51 --------- 4 files changed, 134 insertions(+), 69 deletions(-) rename crates/agent-gateway/internal/{server/chat_payloads.go => chatwire/payloads.go} (78%) create mode 100644 crates/agent-gateway/internal/chatwire/payloads_test.go diff --git a/crates/agent-gateway/internal/server/chat_payloads.go b/crates/agent-gateway/internal/chatwire/payloads.go similarity index 78% rename from crates/agent-gateway/internal/server/chat_payloads.go rename to crates/agent-gateway/internal/chatwire/payloads.go index e53b5b3c..291e37cd 100644 --- a/crates/agent-gateway/internal/server/chat_payloads.go +++ b/crates/agent-gateway/internal/chatwire/payloads.go @@ -1,4 +1,7 @@ -package server +// Package chatwire shapes agent chat protobuf events into the JSON payloads +// sent to webui clients. Shaping (decode, normalize, trim) happens exactly once +// at ingress so every subscriber observes identical bytes. +package chatwire import ( "encoding/json" @@ -7,7 +10,8 @@ import ( gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -func isTerminalChatControlPayload(control *gatewayv1.ChatControlEvent) bool { +// IsTerminalControl reports whether a control event carries a terminal run state. +func IsTerminalControl(control *gatewayv1.ChatControlEvent) bool { switch strings.TrimSpace(control.GetState()) { case "completed", "failed", "cancelled": return true @@ -16,7 +20,8 @@ func isTerminalChatControlPayload(control *gatewayv1.ChatControlEvent) bool { } } -func chatControlPayload( +// ControlPayload shapes a ChatControlEvent into a wire payload. +func ControlPayload( control *gatewayv1.ChatControlEvent, seq int64, workdirInput ...string, @@ -47,8 +52,10 @@ func chatControlPayload( return payload } -func chatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { - protoType := chatEventType(event.GetType()) +// EventPayload shapes a ChatEvent into a wire payload, decoding the JSON data +// blob and trimming oversized tool content. +func EventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { + protoType := EventTypeName(event.GetType()) payload := map[string]any{ "type": protoType, } @@ -77,12 +84,12 @@ func chatEventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...str payload["conversation_id"] = conversationID } - trimLargeToolContentForSSE(payload, protoType) + TrimLargeToolContent(payload, protoType) return payload } -const sseToolContentMaxChars = 200 +const toolContentMaxChars = 200 var toolFieldsToTrim = map[string][]string{ "Write": {"content"}, @@ -90,7 +97,9 @@ var toolFieldsToTrim = map[string][]string{ "NotebookEdit": {"new_source"}, } -func trimLargeToolContentForSSE(payload map[string]any, protoType string) { +// TrimLargeToolContent truncates oversized tool arguments/results in place, +// attaching a __liveagent_stream_preview meta block describing the original size. +func TrimLargeToolContent(payload map[string]any, protoType string) { eventType, _ := payload["type"].(string) if eventType == "tool_call" || eventType == "tool_call_delta" || @@ -119,7 +128,7 @@ func trimToolCallPayload(payload map[string]any) { } for _, field := range fields { - trimStringFieldWithPreview(args, field, sseToolContentMaxChars) + trimStringFieldWithPreview(args, field, toolContentMaxChars) } } @@ -141,9 +150,9 @@ func tryParseJSONStringArg(payload map[string]any, keys ...string) map[string]an func trimToolResultPayload(payload map[string]any) { switch content := payload["content"].(type) { case string: - if len(content) > sseToolContentMaxChars { + if len(content) > toolContentMaxChars { lines := countLines(content) - payload["content"] = content[:sseToolContentMaxChars] + payload["content"] = content[:toolContentMaxChars] ensurePreviewMeta(payload, "content", len(content), lines, true) } case []any: @@ -152,9 +161,9 @@ func trimToolResultPayload(payload map[string]any) { if !ok { continue } - if text, ok := block["text"].(string); ok && len(text) > sseToolContentMaxChars { + if text, ok := block["text"].(string); ok && len(text) > toolContentMaxChars { lines := countLines(text) - block["text"] = text[:sseToolContentMaxChars] + block["text"] = text[:toolContentMaxChars] ensurePreviewMeta(block, "text", len(text), lines, true) } } @@ -226,7 +235,8 @@ func firstMap(candidates ...any) map[string]any { return nil } -func chatEventType(eventType gatewayv1.ChatEvent_ChatEventType) string { +// EventTypeName maps the protobuf ChatEvent type enum to its wire name. +func EventTypeName(eventType gatewayv1.ChatEvent_ChatEventType) string { switch eventType { case gatewayv1.ChatEvent_TOKEN: return "token" diff --git a/crates/agent-gateway/internal/chatwire/payloads_test.go b/crates/agent-gateway/internal/chatwire/payloads_test.go new file mode 100644 index 00000000..d83170bc --- /dev/null +++ b/crates/agent-gateway/internal/chatwire/payloads_test.go @@ -0,0 +1,105 @@ +package chatwire + +import ( + "strings" + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func TestEventPayloadPreservesHostedSearch(t *testing.T) { + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_HOSTED_SEARCH, + ConversationId: "conversation-1", + Data: `{"id":"search-1","provider":"codex","status":"completed","queries":["设计模式定义"],"sources":[{"url":"https://example.com/pattern","title":"设计模式"}],"round":2}`, + }, 7) + + if payload["type"] != "hosted_search" { + t.Fatalf("expected hosted_search type, got %#v", payload["type"]) + } + if payload["conversation_id"] != "conversation-1" { + t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) + } + if payload["id"] != "search-1" { + t.Fatalf("expected search id, got %#v", payload["id"]) + } + if payload["provider"] != "codex" { + t.Fatalf("expected provider, got %#v", payload["provider"]) + } + if payload["status"] != "completed" { + t.Fatalf("expected status, got %#v", payload["status"]) + } + if payload["seq"] != int64(7) { + t.Fatalf("expected seq 7, got %#v", payload["seq"]) + } +} + +func TestEventPayloadPreservesToolCallDeltaType(t *testing.T) { + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOOL_CALL, + ConversationId: "conversation-1", + Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"con"},"round":1}`, + }, 8) + + if payload["type"] != "tool_call_delta" { + t.Fatalf("expected tool_call_delta type, got %#v", payload["type"]) + } + if payload["conversation_id"] != "conversation-1" { + t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) + } + if payload["id"] != "call-write" { + t.Fatalf("expected tool call id, got %#v", payload["id"]) + } + if payload["name"] != "Write" { + t.Fatalf("expected tool name, got %#v", payload["name"]) + } + if payload["seq"] != int64(8) { + t.Fatalf("expected seq 8, got %#v", payload["seq"]) + } +} + +func TestTrimLargeToolContentTruncatesWriteArgs(t *testing.T) { + longContent := strings.Repeat("x", 500) + "\nline2" + payload := map[string]any{ + "type": "tool_call", + "name": "Write", + "arguments": map[string]any{ + "path": "src/app.ts", + "content": longContent, + }, + } + + TrimLargeToolContent(payload, "tool_call") + + args := payload["arguments"].(map[string]any) + content := args["content"].(string) + if len(content) != 200 { + t.Fatalf("trimmed content length = %d, want 200", len(content)) + } + meta, ok := args["__liveagent_stream_preview"].(map[string]any) + if !ok { + t.Fatalf("expected preview meta, got %#v", args) + } + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if info["chars"] != len(longContent) || info["truncated"] != true { + t.Fatalf("preview meta = %#v", info) + } +} + +func TestTrimLargeToolContentTruncatesToolResult(t *testing.T) { + longText := strings.Repeat("r", 300) + payload := map[string]any{ + "type": "tool_result", + "content": longText, + } + + TrimLargeToolContent(payload, "tool_result") + + if content := payload["content"].(string); len(content) != 200 { + t.Fatalf("trimmed result length = %d, want 200", len(content)) + } + if _, ok := payload["__liveagent_stream_preview"]; !ok { + t.Fatalf("expected preview meta on tool_result payload") + } +} diff --git a/crates/agent-gateway/internal/server/http_chat.go b/crates/agent-gateway/internal/server/http_chat.go index 65f4d630..a260da61 100644 --- a/crates/agent-gateway/internal/server/http_chat.go +++ b/crates/agent-gateway/internal/server/http_chat.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/liveagent/agent-gateway/internal/chatwire" "github.com/liveagent/agent-gateway/internal/config" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" @@ -173,15 +174,15 @@ func chatBroadcastPayload(event *session.ChatBroadcastEvent) (map[string]any, bo payload["workdir"] = workdir } } - trimLargeToolContentForSSE(payload, "") + chatwire.TrimLargeToolContent(payload, "") return publicChatPayload(payload), isTerminalChatPayload(payload) } if event.Control != nil { - payload := chatControlPayload(event.Control, event.Seq, event.Workdir) - return publicChatPayload(payload), isTerminalChatControlPayload(event.Control) + payload := chatwire.ControlPayload(event.Control, event.Seq, event.Workdir) + return publicChatPayload(payload), chatwire.IsTerminalControl(event.Control) } if event.Event != nil { - payload := chatEventPayload(event.Event, event.Seq, event.Workdir) + payload := chatwire.EventPayload(event.Event, event.Seq, event.Workdir) return publicChatPayload(payload), event.Event.GetType() == gatewayv1.ChatEvent_DONE || event.Event.GetType() == gatewayv1.ChatEvent_ERROR } diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index fb3eaaf9..cc01d664 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -7,57 +7,6 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -func TestChatEventPayloadPreservesHostedSearch(t *testing.T) { - payload := chatEventPayload(&gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_HOSTED_SEARCH, - ConversationId: "conversation-1", - Data: `{"id":"search-1","provider":"codex","status":"completed","queries":["设计模式定义"],"sources":[{"url":"https://example.com/pattern","title":"设计模式"}],"round":2}`, - }, 7) - - if payload["type"] != "hosted_search" { - t.Fatalf("expected hosted_search type, got %#v", payload["type"]) - } - if payload["conversation_id"] != "conversation-1" { - t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) - } - if payload["id"] != "search-1" { - t.Fatalf("expected search id, got %#v", payload["id"]) - } - if payload["provider"] != "codex" { - t.Fatalf("expected provider, got %#v", payload["provider"]) - } - if payload["status"] != "completed" { - t.Fatalf("expected status, got %#v", payload["status"]) - } - if payload["seq"] != int64(7) { - t.Fatalf("expected seq 7, got %#v", payload["seq"]) - } -} - -func TestChatEventPayloadPreservesToolCallDeltaType(t *testing.T) { - payload := chatEventPayload(&gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOOL_CALL, - ConversationId: "conversation-1", - Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"con"},"round":1}`, - }, 8) - - if payload["type"] != "tool_call_delta" { - t.Fatalf("expected tool_call_delta type, got %#v", payload["type"]) - } - if payload["conversation_id"] != "conversation-1" { - t.Fatalf("expected conversation id, got %#v", payload["conversation_id"]) - } - if payload["id"] != "call-write" { - t.Fatalf("expected tool call id, got %#v", payload["id"]) - } - if payload["name"] != "Write" { - t.Fatalf("expected tool name, got %#v", payload["name"]) - } - if payload["seq"] != int64(8) { - t.Fatalf("expected seq 8, got %#v", payload["seq"]) - } -} - func TestActiveChatRunSummaryPayloadIncludesReplayCursor(t *testing.T) { payload := websocketActiveChatRunSummariesPayload([]session.ActiveChatRunSummary{ { From a4a9480889c6274801d04cc8aea89602144be2dc Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 11:19:40 +0800 Subject: [PATCH 15/64] feat(gateway): add per-conversation stream core with normalized run lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New conversation stream store: one ordered event log per conversation with gateway-assigned monotonic seq, exactly-once run_finished, supersession-based run handoff, persistent subscribers with overflow-resume, retention decoupled from run lifecycle, snapshot hydration for late joiners, pending-run binding for draft conversations, and a chat activity hub whose events are composed inside the locked transitions (run ids from birth). Additive — the legacy chat run store still serves traffic until the cutover. Co-Authored-By: Claude Fable 5 --- .../internal/session/activity_hub.go | 91 ++ .../internal/session/conversation_ingress.go | 332 ++++++ .../internal/session/conversation_stream.go | 980 ++++++++++++++++++ .../session/conversation_stream_test.go | 613 +++++++++++ .../agent-gateway/internal/session/manager.go | 15 +- 5 files changed, 2025 insertions(+), 6 deletions(-) create mode 100644 crates/agent-gateway/internal/session/activity_hub.go create mode 100644 crates/agent-gateway/internal/session/conversation_ingress.go create mode 100644 crates/agent-gateway/internal/session/conversation_stream.go create mode 100644 crates/agent-gateway/internal/session/conversation_stream_test.go diff --git a/crates/agent-gateway/internal/session/activity_hub.go b/crates/agent-gateway/internal/session/activity_hub.go new file mode 100644 index 00000000..80f826c3 --- /dev/null +++ b/crates/agent-gateway/internal/session/activity_hub.go @@ -0,0 +1,91 @@ +package session + +import "sync" + +// chatActivityHub fans conversation activity transitions (running/idle with +// run ids) out to every authenticated webui connection. Events are composed +// inside the stream store's locked transitions, so per-conversation ordering +// is the log order. Activity is state-based: when a slow subscriber's buffer +// fills, the oldest pending event is dropped so the latest state still lands. +type chatActivityHub struct { + mu sync.Mutex + nextSubID int + subscribers map[int]chan ConversationActivityEvent +} + +func newChatActivityHub() *chatActivityHub { + return &chatActivityHub{ + subscribers: make(map[int]chan ConversationActivityEvent), + } +} + +// SubscribeChatActivity registers an activity listener. The current activity +// of every active conversation is replayed first so a fresh connection needs +// no separate hydration round-trip. +func (m *Manager) SubscribeChatActivity() (<-chan ConversationActivityEvent, func()) { + hub := m.convStreams.activityHub + ch := make(chan ConversationActivityEvent, 64) + + // Replay current activities before registering so a concurrent transition + // is delivered after its predecessor state, never before. + m.convStreams.mu.Lock() + replay := make([]ConversationActivityEvent, 0, len(m.convStreams.streams)) + for _, stream := range m.convStreams.streams { + if stream.activity == nil { + continue + } + event := ConversationActivityEvent{ + ConversationID: stream.conversationID, + RunID: stream.activity.RunID, + Running: true, + State: stream.activity.State, + Workdir: stream.activity.Workdir, + UpdatedAt: stream.activity.UpdatedAt, + } + if event.Workdir == "" { + event.Workdir = stream.workdir + } + replay = append(replay, event) + } + hub.mu.Lock() + subID := hub.nextSubID + hub.nextSubID++ + hub.subscribers[subID] = ch + for _, event := range replay { + ch <- event + } + hub.mu.Unlock() + m.convStreams.mu.Unlock() + + cleanup := func() { + hub.mu.Lock() + // The channel is never closed: publish may hold a reference collected + // before cleanup ran. Subscribers exit via their own done signal. + delete(hub.subscribers, subID) + hub.mu.Unlock() + } + return ch, cleanup +} + +// publish is called while the stream store mutex is held (store.mu → hub.mu +// is the only lock order). Sends never block: on a full buffer the oldest +// pending event is discarded — activity is a state signal, latest wins. +func (hub *chatActivityHub) publish(event ConversationActivityEvent) { + hub.mu.Lock() + defer hub.mu.Unlock() + for _, ch := range hub.subscribers { + select { + case ch <- event: + continue + default: + } + select { + case <-ch: + default: + } + select { + case ch <- event: + default: + } + } +} diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go new file mode 100644 index 00000000..43dde295 --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -0,0 +1,332 @@ +package session + +import ( + "strings" + "time" + + "github.com/liveagent/agent-gateway/internal/chatwire" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// Ingress normalization: the three agent-facing gRPC payloads (ChatEvent, +// ChatControlEvent, ChatRuntimeSnapshot) converge here into one append API on +// the conversation stream store. Payload shaping and tool-content trimming +// happen exactly once, so every subscriber observes identical events. + +func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) { + if event == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(requestID) + if runID == "" { + return + } + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID := s.resolveConversationLocked(runID, strings.TrimSpace(event.GetConversationId()), now) + if conversationID == "" { + return + } + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + payload := chatwire.EventPayload(event, 0) + eventType, _ := payload["type"].(string) + if eventType == "" { + eventType = chatwire.EventTypeName(event.GetType()) + } + + switch event.GetType() { + case gatewayv1.ChatEvent_DONE: + delete(payload, "type") + delete(payload, "seq") + s.runFinishedLocked(stream, runID, "completed", "", "", payload, now) + return + case gatewayv1.ChatEvent_ERROR: + message, _ := payload["message"].(string) + delete(payload, "type") + delete(payload, "seq") + delete(payload, "message") + s.runFinishedLocked(stream, runID, "failed", "", strings.TrimSpace(message), payload, now) + return + case gatewayv1.ChatEvent_USER_MESSAGE: + if record := s.runs[runID]; record != nil && record.userMessageSeeded { + // The gateway already appended this run's user_message at accept + // time; swallow the agent echo so the message appears once. + return + } + } + + if stream.runFinishedRecently(runID) { + // Late straggler after a forced or duplicate terminal; drop it. + return + } + + workdir, _ := payload["workdir"].(string) + s.runStartedLocked(stream, runID, strings.TrimSpace(workdir), now) + if stream.activity == nil || stream.activity.RunID != runID { + // runStartedLocked declined (e.g. the run finished during + // supersession bookkeeping); do not attribute events to another run. + return + } + + if event.GetType() == gatewayv1.ChatEvent_TOOL_STATUS { + status, _ := payload["status"].(string) + isCompaction, _ := payload["isCompaction"].(bool) + stream.activity.ToolStatus = strings.TrimSpace(status) + stream.activity.ToolStatusIsCompaction = isCompaction + stream.activity.UpdatedAt = now + } + + delete(payload, "seq") + s.appendEventLocked(stream, runID, eventType, payload, now) +} + +func (m *Manager) ingestChatControl(requestID string, control *gatewayv1.ChatControlEvent) { + if control == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(requestID) + if runID == "" { + runID = strings.TrimSpace(control.GetRequestId()) + } + if runID == "" { + return + } + controlType := strings.TrimSpace(control.GetType()) + if controlType == "" { + controlType = strings.TrimSpace(control.GetState()) + } + errorCode := strings.TrimSpace(control.GetErrorCode()) + message := strings.TrimSpace(control.GetMessage()) + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID := s.resolveConversationLocked(runID, strings.TrimSpace(control.GetConversationId()), now) + if conversationID == "" { + // A control for a run the gateway has no conversation for yet (the + // binding signal must carry a conversation id); ignore. + return + } + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + switch controlType { + case "started": + s.runStartedLocked(stream, runID, "", now) + case "completed", "failed", "cancelled": + s.runFinishedLocked(stream, runID, controlType, errorCode, message, nil, now) + case "queued_in_gui": + s.markRunQueuedInGUILocked(stream, runID, now) + case "accepted", "delivered", "claimed", "starting": + record := s.runRecordLocked(runID, conversationID) + s.markRunQueuedLocked(stream, runID, record.clientRequestID, now) + } +} + +// markRunQueuedInGUILocked handles a command the desktop app parked in its +// prompt queue: the run will not start now. Any provisionally seeded entries +// are compensated with a run_queued event so clients drop them (the prompt is +// visible in the queue UI instead), and the agent's later user_message echo — +// when the queued item finally runs — must pass through. +func (s *conversationStreamStore) markRunQueuedInGUILocked( + stream *conversationStream, + runID string, + now time.Time, +) { + if stream.runFinishedRecently(runID) { + return + } + record := s.runRecordLocked(runID, stream.conversationID) + record.queuedInGUI = true + seeded := record.userMessageSeeded + record.userMessageSeeded = false + + if seeded { + payload := map[string]any{} + if record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + s.appendEventLocked(stream, runID, StreamEventRunQueued, payload, now) + } + if stream.activity != nil && stream.activity.RunID == runID { + stream.activity = nil + s.publishActivityLocked(stream, now) + } + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: runID, + ClientRequestID: record.clientRequestID, + ConversationID: stream.conversationID, + Phase: "queued_in_gui", + }) +} + +func (m *Manager) ingestRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapshot) { + if snapshot == nil { + return + } + s := m.convStreams + runID := strings.TrimSpace(snapshot.GetRunId()) + conversationID := strings.TrimSpace(snapshot.GetConversationId()) + if runID == "" || conversationID == "" { + return + } + state := strings.TrimSpace(snapshot.GetState()) + now := time.Now() + epoch := m.currentSessionEpoch() + + s.mu.Lock() + defer s.mu.Unlock() + + conversationID = s.resolveConversationLocked(runID, conversationID, now) + existingStream := s.streams[conversationID] + streamWasUnknown := existingStream == nil || (existingStream.lastSeq == 0 && existingStream.activity == nil) + stream := s.streamLocked(conversationID, now) + s.noteAgentEpochLocked(stream, epoch) + + switch state { + case "completed", "failed", "cancelled": + s.runFinishedLocked(stream, runID, state, "", "", nil, now) + return + } + if stream.runFinishedRecently(runID) { + return + } + + next := &RunSnapshot{ + RunID: runID, + Revision: snapshot.GetRevision(), + EntriesJSON: strings.TrimSpace(snapshot.GetEntriesJson()), + ToolStatus: strings.TrimSpace(snapshot.GetToolStatus()), + ToolStatusIsCompaction: snapshot.GetToolStatusIsCompaction(), + Workdir: strings.TrimSpace(snapshot.GetCwd()), + UpdatedAt: now, + } + if current := stream.latestSnapshot; current != nil && + current.RunID == runID && + current.Revision > next.Revision { + // Stale revision; keep the newer snapshot. + return + } + stream.latestSnapshot = next + stream.updatedAt = now + stream.lastEventAt = now + + if state == "running" || state == "" { + if streamWasUnknown { + // The gateway (re)started while this run was already streaming; + // buffered history is gone, so late joiners need the snapshot. + stream.runNeedsSnapshot = true + } + s.runStartedLocked(stream, runID, next.Workdir, now) + if stream.activity != nil && stream.activity.RunID == runID { + if next.ToolStatus != "" || stream.activity.ToolStatus != "" { + stream.activity.ToolStatus = next.ToolStatus + stream.activity.ToolStatusIsCompaction = next.ToolStatusIsCompaction + } + stream.activity.UpdatedAt = now + } + } + + if stream.snapshotDirty { + // The agent reconnected mid-run: tokens streamed during the outage are + // unrecoverable, so push the snapshot inline for attached subscribers. + stream.snapshotDirty = false + s.publishSnapshotLocked(stream, runID, next, now) + } +} + +// publishSnapshotLocked delivers a seq-less snapshot event to current +// subscribers without storing it in the log. +func (s *conversationStreamStore) publishSnapshotLocked( + stream *conversationStream, + runID string, + snapshot *RunSnapshot, + now time.Time, +) { + payload := map[string]any{ + "conversation_id": stream.conversationID, + "run_id": runID, + "type": StreamEventSnapshot, + "revision": snapshot.Revision, + "entries_json": snapshot.EntriesJSON, + "tool_status": snapshot.ToolStatus, + "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, + } + event := &ConversationEvent{ + ConversationID: stream.conversationID, + RunID: runID, + Seq: 0, + Type: StreamEventSnapshot, + Payload: payload, + ReceivedAt: now, + } + s.publishLocked(stream, event) +} + +// resolveConversationLocked determines the conversation a run belongs to, +// binding a pending webui command when the first agent signal carries a +// conversation id. +func (s *conversationStreamStore) resolveConversationLocked( + runID string, + conversationID string, + now time.Time, +) string { + if pending := s.pendingRuns[runID]; pending != nil && conversationID != "" { + s.bindPendingRunLocked(pending, conversationID, now) + } + if conversationID != "" { + s.runRecordLocked(runID, conversationID) + return conversationID + } + if record := s.runs[runID]; record != nil { + return record.conversationID + } + return "" +} + +func (s *conversationStreamStore) bindPendingRunLocked( + pending *pendingChatRun, + conversationID string, + now time.Time, +) { + delete(s.pendingRuns, pending.runID) + stream := s.streamLocked(conversationID, now) + if pending.workdir != "" { + stream.workdir = pending.workdir + } + record := s.runRecordLocked(pending.runID, conversationID) + record.clientRequestID = pending.clientRequestID + s.markRunQueuedLocked(stream, pending.runID, pending.clientRequestID, now) + s.appendSeededPayloadsLocked(stream, pending.runID, pending.clientRequestID, pending.seeded, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(pending.seeded) + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: pending.runID, + ClientRequestID: pending.clientRequestID, + ConversationID: conversationID, + Phase: "bound", + }) +} + +// noteAgentEpochLocked tracks the agent session epoch per stream: when the +// agent reconnects mid-run, tokens streamed during the outage are lost, so +// the next runtime snapshot is pushed inline and offered to late joiners. +func (s *conversationStreamStore) noteAgentEpochLocked(stream *conversationStream, epoch uint64) { + if epoch == 0 || stream.agentEpoch == epoch { + return + } + if stream.agentEpoch != 0 && stream.activity != nil { + stream.snapshotDirty = true + stream.runNeedsSnapshot = true + } + stream.agentEpoch = epoch +} diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go new file mode 100644 index 00000000..3b6dedd7 --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -0,0 +1,980 @@ +package session + +import ( + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// The conversation stream store is the authoritative relay state for chat: +// one ordered event log per conversation with a monotonic seq, a single +// current-run activity record, and persistent per-conversation subscribers. +// Runs are events inside the stream, not stream boundaries. +// +// Invariants (all enforced under the single store mutex): +// 1. Seq is conversation-scoped and monotonic; runs do not own seq. +// 2. run_finished is emitted exactly once per run — the first terminal +// signal wins, later duplicates are swallowed via the finished-run ring. +// 3. Run handoff is supersession: run_started(B) while A is running +// atomically synthesizes run_finished(A) first. +// 4. Activity events are composed inside the locked transition that changed +// them, so they always carry the run id. +// 5. Subscriber sends happen under the mutex (non-blocking); an overflowing +// subscriber is closed and resumes by re-subscribing with after_seq. +const ( + conversationEventRetention = 10 * time.Minute + conversationMaxEvents = 4096 + conversationMaxEventBytes = 8 << 20 + conversationIdleRetention = 30 * time.Minute + conversationStaleRunTimeout = 10 * time.Minute + conversationRuntimeIdleGrace = 30 * time.Second + conversationReaperInterval = time.Minute + conversationFinishedRunMemory = 8 + conversationSubscriberBuffer = 256 + pendingChatRunRetention = 5 * time.Minute +) + +const ( + RunActivityQueued = "queued" + RunActivityRunning = "running" + RunActivityCancelling = "cancelling" +) + +// Normalized event types appended to the conversation log. +const ( + StreamEventRunStarted = "run_started" + StreamEventRunFinished = "run_finished" + StreamEventRunQueued = "run_queued" + StreamEventSnapshot = "snapshot" +) + +// RunActivity describes the current run of a conversation. A nil activity +// means the conversation is idle. +type RunActivity struct { + ConversationID string + RunID string + ClientRequestID string + State string + ToolStatus string + ToolStatusIsCompaction bool + StartedSeq int64 + Workdir string + UpdatedAt time.Time +} + +// RunSnapshot is the latest runtime snapshot for a conversation's run. It is +// not part of the seq log; it hydrates late joiners when the buffer cannot +// cover the active run from its start. +type RunSnapshot struct { + RunID string + Revision int64 + EntriesJSON string + ToolStatus string + ToolStatusIsCompaction bool + Workdir string + UpdatedAt time.Time +} + +// ConversationEvent is one entry of a conversation log. Payload is the final +// wire shape (including conversation_id/run_id/seq/type) and is frozen after +// append — subscribers must never mutate it. +type ConversationEvent struct { + ConversationID string + RunID string + Seq int64 + Type string + Payload map[string]any + ReceivedAt time.Time + + approxBytes int +} + +// ConversationActivityEvent is the broadcast shape for the chat.activity hub. +type ConversationActivityEvent struct { + ConversationID string + RunID string + Running bool + State string + Workdir string + UpdatedAt time.Time +} + +// ChatCommandUpdate notifies the connection that issued a chat command about +// pre-stream outcomes. +type ChatCommandUpdate struct { + RunID string + ClientRequestID string + ConversationID string + Phase string // "bound" | "queued_in_gui" | "failed" + ErrorCode string + Message string +} + +type streamSubscriber struct { + id int + ch chan *ConversationEvent + overflowed bool + closed bool +} + +type conversationStream struct { + conversationID string + streamEpoch string + workdir string + lastSeq int64 + events []*ConversationEvent + eventsBytes int + evictedThroughSeq int64 + activity *RunActivity + finishedRuns []string + latestSnapshot *RunSnapshot + agentEpoch uint64 + snapshotDirty bool + // runNeedsSnapshot marks an active run whose early events the buffer + // cannot reproduce (gateway restarted mid-run, or the agent reconnected + // mid-run and tokens were lost) — late joiners hydrate from the snapshot. + runNeedsSnapshot bool + subscribers map[int]*streamSubscriber + lastEventAt time.Time + updatedAt time.Time +} + +type chatRunRecord struct { + conversationID string + clientRequestID string + // userMessageSeeded marks runs whose user_message the gateway appended at + // accept time; the agent's later USER_MESSAGE echo is swallowed so the + // message appears exactly once. + userMessageSeeded bool + // queuedInGUI marks commands the desktop app parked in its prompt queue; + // the startup watchdog must leave them alone. + queuedInGUI bool +} + +type pendingChatRun struct { + runID string + clientRequestID string + workdir string + seeded []map[string]any + createdAt time.Time +} + +type conversationStreamStore struct { + mu sync.Mutex + streams map[string]*conversationStream + pendingRuns map[string]*pendingChatRun + runs map[string]*chatRunRecord + commandWatchers map[string][]chan ChatCommandUpdate + nextSubID int + + activityHub *chatActivityHub + + runtimeIdleSince time.Time + + reaperOnce sync.Once + isOnline func() bool + + // tunable in tests + eventRetention time.Duration + maxEvents int + maxEventBytes int + idleRetention time.Duration + staleRunTimeout time.Duration + runtimeIdleGrace time.Duration + reaperInterval time.Duration +} + +func newConversationStreamStore(isOnline func() bool) *conversationStreamStore { + return &conversationStreamStore{ + streams: make(map[string]*conversationStream), + pendingRuns: make(map[string]*pendingChatRun), + runs: make(map[string]*chatRunRecord), + commandWatchers: make(map[string][]chan ChatCommandUpdate), + activityHub: newChatActivityHub(), + isOnline: isOnline, + eventRetention: conversationEventRetention, + maxEvents: conversationMaxEvents, + maxEventBytes: conversationMaxEventBytes, + idleRetention: conversationIdleRetention, + staleRunTimeout: conversationStaleRunTimeout, + runtimeIdleGrace: conversationRuntimeIdleGrace, + reaperInterval: conversationReaperInterval, + } +} + +func (s *conversationStreamStore) streamLocked(conversationID string, now time.Time) *conversationStream { + stream := s.streams[conversationID] + if stream == nil { + stream = &conversationStream{ + conversationID: conversationID, + streamEpoch: uuid.NewString(), + subscribers: make(map[int]*streamSubscriber), + updatedAt: now, + } + s.streams[conversationID] = stream + s.startReaper() + } + return stream +} + +func (s *conversationStreamStore) evictStreamLocked(stream *conversationStream, now time.Time) { + cutoff := now.Add(-s.eventRetention) + activeStart := int64(0) + if stream.activity != nil { + activeStart = stream.activity.StartedSeq + } + drop := 0 + for drop < len(stream.events) { + event := stream.events[drop] + overCap := len(stream.events)-drop > s.maxEvents || + stream.eventsBytes > s.maxEventBytes + expired := event.ReceivedAt.Before(cutoff) + if !overCap && !expired { + break + } + if !overCap && activeStart > 0 && event.Seq >= activeStart { + // Retention never evicts events of the active run; only hard caps do. + break + } + stream.eventsBytes -= event.approxBytes + if event.Seq > stream.evictedThroughSeq { + stream.evictedThroughSeq = event.Seq + } + drop++ + } + if drop > 0 { + remaining := len(stream.events) - drop + copy(stream.events, stream.events[drop:]) + for i := remaining; i < len(stream.events); i++ { + stream.events[i] = nil + } + stream.events = stream.events[:remaining] + } +} + +// ConversationSubscription is the result of subscribing to a conversation +// stream. The subscription persists across runs; EventCh closes only on +// Cleanup or when the subscriber overflows (check Overflowed, then +// re-subscribe with after_seq to resume without loss). +type ConversationSubscription struct { + ConversationID string + StreamEpoch string + LatestSeq int64 + Reset bool + Activity *RunActivity + Snapshot *RunSnapshot + Events []*ConversationEvent + EventCh <-chan *ConversationEvent + Cleanup func() + Overflowed func() bool +} + +func (m *Manager) SubscribeConversationStream( + conversationID string, + afterSeq int64, + clientEpoch string, +) *ConversationSubscription { + s := m.convStreams + conversationID = strings.TrimSpace(conversationID) + clientEpoch = strings.TrimSpace(clientEpoch) + if afterSeq < 0 { + afterSeq = 0 + } + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + stream := s.streamLocked(conversationID, now) + s.evictStreamLocked(stream, now) + + reset := false + if clientEpoch != "" && clientEpoch != stream.streamEpoch { + reset = true + } + if afterSeq > stream.lastSeq { + reset = true + } + if afterSeq > 0 && afterSeq < stream.evictedThroughSeq { + reset = true + } + if reset { + afterSeq = 0 + } + + replay := make([]*ConversationEvent, 0, len(stream.events)) + for _, event := range stream.events { + if event.Seq > afterSeq { + replay = append(replay, event) + } + } + + var snapshot *RunSnapshot + if stream.activity != nil && + stream.latestSnapshot != nil && + stream.latestSnapshot.RunID == stream.activity.RunID && + afterSeq < stream.activity.StartedSeq && + (stream.evictedThroughSeq >= stream.activity.StartedSeq || stream.runNeedsSnapshot) { + // The buffer cannot reproduce the active run from its start; hand the + // client the runtime snapshot to rebuild the live tail. + snapshotCopy := *stream.latestSnapshot + snapshot = &snapshotCopy + } + + var activity *RunActivity + if stream.activity != nil { + activityCopy := *stream.activity + activity = &activityCopy + } + + s.nextSubID++ + sub := &streamSubscriber{ + id: s.nextSubID, + ch: make(chan *ConversationEvent, conversationSubscriberBuffer), + } + stream.subscribers[sub.id] = sub + + cleanup := func() { + s.mu.Lock() + defer s.mu.Unlock() + current := s.streams[conversationID] + if current == nil { + return + } + if existing, ok := current.subscribers[sub.id]; ok && existing == sub { + delete(current.subscribers, sub.id) + if !sub.closed { + sub.closed = true + close(sub.ch) + } + } + } + overflowed := func() bool { + s.mu.Lock() + defer s.mu.Unlock() + return sub.overflowed + } + + return &ConversationSubscription{ + ConversationID: conversationID, + StreamEpoch: stream.streamEpoch, + LatestSeq: stream.lastSeq, + Reset: reset, + Activity: activity, + Snapshot: snapshot, + Events: replay, + EventCh: sub.ch, + Cleanup: cleanup, + Overflowed: overflowed, + } +} + +// ActiveConversationActivities returns the current activity of every +// conversation with an active run (for history.list hydration). +func (m *Manager) ActiveConversationActivities() []RunActivity { + s := m.convStreams + s.mu.Lock() + defer s.mu.Unlock() + activities := make([]RunActivity, 0, len(s.streams)) + for _, stream := range s.streams { + if stream.activity != nil { + activities = append(activities, *stream.activity) + } + } + return activities +} + +// appendEventLocked assigns the next seq, freezes the payload, stores the +// event, and fans it out to subscribers. +func (s *conversationStreamStore) appendEventLocked( + stream *conversationStream, + runID string, + eventType string, + payload map[string]any, + now time.Time, +) *ConversationEvent { + if payload == nil { + payload = make(map[string]any, 4) + } + stream.lastSeq++ + payload["conversation_id"] = stream.conversationID + payload["run_id"] = runID + payload["seq"] = stream.lastSeq + payload["type"] = eventType + event := &ConversationEvent{ + ConversationID: stream.conversationID, + RunID: runID, + Seq: stream.lastSeq, + Type: eventType, + Payload: payload, + ReceivedAt: now, + approxBytes: approxPayloadBytes(payload), + } + stream.events = append(stream.events, event) + stream.eventsBytes += event.approxBytes + stream.lastEventAt = now + stream.updatedAt = now + s.evictStreamLocked(stream, now) + s.publishLocked(stream, event) + return event +} + +// publishLocked delivers an event to every subscriber without blocking. A +// subscriber whose buffer is full is closed; the client resumes via +// re-subscribe with after_seq (the ring still holds the events). +func (s *conversationStreamStore) publishLocked(stream *conversationStream, event *ConversationEvent) { + for id, sub := range stream.subscribers { + if sub.closed { + continue + } + select { + case sub.ch <- event: + default: + sub.overflowed = true + sub.closed = true + close(sub.ch) + delete(stream.subscribers, id) + } + } +} + +func approxPayloadBytes(payload map[string]any) int { + total := 64 + for key, value := range payload { + total += len(key) + approxValueBytes(value, 2) + } + return total +} + +func approxValueBytes(value any, depth int) int { + switch v := value.(type) { + case string: + return len(v) + 8 + case map[string]any: + if depth <= 0 { + return 64 + } + total := 16 + for key, nested := range v { + total += len(key) + approxValueBytes(nested, depth-1) + } + return total + case []any: + if depth <= 0 { + return 64 + } + total := 16 + for _, nested := range v { + total += approxValueBytes(nested, depth-1) + } + return total + default: + return 16 + } +} + +func (stream *conversationStream) runFinishedRecently(runID string) bool { + for _, finished := range stream.finishedRuns { + if finished == runID { + return true + } + } + return false +} + +// runStartedLocked registers runID as the conversation's current run, +// superseding a still-active previous run. Idempotent per run. +func (s *conversationStreamStore) runStartedLocked( + stream *conversationStream, + runID string, + workdir string, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity != nil && stream.activity.RunID == runID { + switch stream.activity.State { + case RunActivityQueued: + // The gateway-accepted command actually started: append the + // run_started log event now. StartedSeq keeps covering the seeded + // user_message so the whole run stays replayable. + payload := map[string]any{} + if stream.activity.ClientRequestID != "" { + payload["client_request_id"] = stream.activity.ClientRequestID + } + if stream.workdir != "" { + payload["workdir"] = stream.workdir + } + s.appendEventLocked(stream, runID, StreamEventRunStarted, payload, now) + stream.activity.State = RunActivityRunning + stream.activity.UpdatedAt = now + s.publishActivityLocked(stream, now) + case RunActivityCancelling: + // A cancel is in flight; keep the cancelling state. + } + return + } + if stream.activity != nil && + (stream.activity.State == RunActivityRunning || stream.activity.State == RunActivityCancelling) { + // Supersession: the agent started a new run (e.g. a queued prompt + // auto-send) before the previous run's terminal signal arrived. + s.runFinishedLocked(stream, stream.activity.RunID, "completed", "", "", map[string]any{ + "reason": "superseded", + }, now) + } + if workdir = strings.TrimSpace(workdir); workdir != "" { + stream.workdir = workdir + } + record := s.runRecordLocked(runID, stream.conversationID) + payload := map[string]any{} + if record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + if stream.workdir != "" { + payload["workdir"] = stream.workdir + } + startEvent := s.appendEventLocked(stream, runID, StreamEventRunStarted, payload, now) + stream.activity = &RunActivity{ + ConversationID: stream.conversationID, + RunID: runID, + ClientRequestID: record.clientRequestID, + State: RunActivityRunning, + StartedSeq: startEvent.Seq, + Workdir: stream.workdir, + UpdatedAt: now, + } + s.publishActivityLocked(stream, now) +} + +// runFinishedLocked appends run_finished exactly once per run and clears the +// activity when the finished run is the current one. +func (s *conversationStreamStore) runFinishedLocked( + stream *conversationStream, + runID string, + status string, + errorCode string, + message string, + extra map[string]any, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity == nil || stream.activity.RunID != runID { + // Terminal signal for a run this stream never started (e.g. the + // gateway restarted mid-run). Synthesize the start so clients see a + // coherent pair, unless another run is currently active — then the + // stray terminal is recorded without touching the active run. + if stream.activity == nil { + s.runStartedLocked(stream, runID, "", now) + } + } + payload := map[string]any{ + "status": status, + } + if errorCode != "" { + payload["error_code"] = errorCode + } + if message != "" { + payload["message"] = message + } + for key, value := range extra { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + if record := s.runs[runID]; record != nil && record.clientRequestID != "" { + payload["client_request_id"] = record.clientRequestID + } + s.appendEventLocked(stream, runID, StreamEventRunFinished, payload, now) + stream.finishedRuns = append(stream.finishedRuns, runID) + if len(stream.finishedRuns) > conversationFinishedRunMemory { + evicted := stream.finishedRuns[0] + stream.finishedRuns = stream.finishedRuns[1:] + delete(s.runs, evicted) + } + if stream.latestSnapshot != nil && stream.latestSnapshot.RunID == runID { + stream.latestSnapshot = nil + } + if stream.activity != nil && stream.activity.RunID == runID { + stream.activity = nil + stream.runNeedsSnapshot = false + stream.snapshotDirty = false + s.publishActivityLocked(stream, now) + } +} + +// markRunQueuedLocked records that a run's command is pending in the gateway +// (accepted but not yet started). No log event — activity only. +func (s *conversationStreamStore) markRunQueuedLocked( + stream *conversationStream, + runID string, + clientRequestID string, + now time.Time, +) { + if runID == "" || stream.runFinishedRecently(runID) { + return + } + if stream.activity != nil { + return + } + stream.activity = &RunActivity{ + ConversationID: stream.conversationID, + RunID: runID, + ClientRequestID: clientRequestID, + State: RunActivityQueued, + StartedSeq: stream.lastSeq + 1, + Workdir: stream.workdir, + UpdatedAt: now, + } + s.publishActivityLocked(stream, now) +} + +func (s *conversationStreamStore) runRecordLocked(runID string, conversationID string) *chatRunRecord { + record := s.runs[runID] + if record == nil { + record = &chatRunRecord{conversationID: conversationID} + s.runs[runID] = record + } else if record.conversationID == "" { + record.conversationID = conversationID + } + return record +} + +func (s *conversationStreamStore) publishActivityLocked(stream *conversationStream, now time.Time) { + event := ConversationActivityEvent{ + ConversationID: stream.conversationID, + Workdir: stream.workdir, + UpdatedAt: now, + } + if stream.activity != nil { + event.RunID = stream.activity.RunID + event.Running = true + event.State = stream.activity.State + if stream.activity.Workdir != "" { + event.Workdir = stream.activity.Workdir + } + } + s.activityHub.publish(event) +} + +// --- command lifecycle ----------------------------------------------------- + +// WatchChatCommand registers a watcher for pre-stream command outcomes +// (bound / queued_in_gui / failed). Call before StartChatCommand. +func (m *Manager) WatchChatCommand(runID string) (<-chan ChatCommandUpdate, func()) { + s := m.convStreams + runID = strings.TrimSpace(runID) + ch := make(chan ChatCommandUpdate, 4) + + s.mu.Lock() + s.commandWatchers[runID] = append(s.commandWatchers[runID], ch) + s.mu.Unlock() + + cleanup := func() { + s.mu.Lock() + defer s.mu.Unlock() + watchers := s.commandWatchers[runID] + for i, watcher := range watchers { + if watcher == ch { + s.commandWatchers[runID] = append(watchers[:i], watchers[i+1:]...) + break + } + } + if len(s.commandWatchers[runID]) == 0 { + delete(s.commandWatchers, runID) + } + } + return ch, cleanup +} + +func (s *conversationStreamStore) fireCommandUpdateLocked(update ChatCommandUpdate) { + for _, watcher := range s.commandWatchers[update.RunID] { + select { + case watcher <- update: + default: + } + } +} + +// ChatCommandStart is the accepted-command result returned to the transport. +type ChatCommandStart struct { + RunID string + ConversationID string + AcceptedSeq int64 +} + +// StartChatCommand registers a webui-issued chat command. For a known +// conversation the seeded payloads (rebased/user_message) are appended to the +// log immediately; for a draft conversation they are buffered until the first +// agent signal binds the run to a real conversation id. +func (m *Manager) StartChatCommand( + runID string, + conversationID string, + workdir string, + clientRequestID string, + seededPayloads []map[string]any, +) ChatCommandStart { + s := m.convStreams + runID = strings.TrimSpace(runID) + conversationID = strings.TrimSpace(conversationID) + workdir = strings.TrimSpace(workdir) + clientRequestID = strings.TrimSpace(clientRequestID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + if conversationID == "" { + s.pendingRuns[runID] = &pendingChatRun{ + runID: runID, + clientRequestID: clientRequestID, + workdir: workdir, + seeded: seededPayloads, + createdAt: now, + } + s.startReaper() + return ChatCommandStart{RunID: runID} + } + + stream := s.streamLocked(conversationID, now) + if workdir != "" { + stream.workdir = workdir + } + record := s.runRecordLocked(runID, conversationID) + record.clientRequestID = clientRequestID + // Mark queued before seeding so the activity's StartedSeq covers the + // seeded user_message — the whole run replays from one cursor. + s.markRunQueuedLocked(stream, runID, clientRequestID, now) + acceptedSeq := s.appendSeededPayloadsLocked(stream, runID, clientRequestID, seededPayloads, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(seededPayloads) + return ChatCommandStart{ + RunID: runID, + ConversationID: conversationID, + AcceptedSeq: acceptedSeq, + } +} + +func (s *conversationStreamStore) appendSeededPayloadsLocked( + stream *conversationStream, + runID string, + clientRequestID string, + seededPayloads []map[string]any, + now time.Time, +) int64 { + acceptedSeq := stream.lastSeq + for _, payload := range seededPayloads { + if len(payload) == 0 { + continue + } + eventType, _ := payload["type"].(string) + if eventType == "" { + continue + } + cloned := make(map[string]any, len(payload)+5) + for key, value := range payload { + cloned[key] = value + } + if eventType == "user_message" && clientRequestID != "" { + cloned["client_request_id"] = clientRequestID + } + event := s.appendEventLocked(stream, runID, eventType, cloned, now) + acceptedSeq = event.Seq + } + return acceptedSeq +} + +func seededPayloadsIncludeUserMessage(seededPayloads []map[string]any) bool { + for _, payload := range seededPayloads { + if eventType, _ := payload["type"].(string); eventType == "user_message" { + return true + } + } + return false +} + +// FailChatCommand fails a command that never produced a bound run (agent +// unreachable, startup watchdog) or force-finishes its run when bound. +func (m *Manager) FailChatCommand(runID string, errorCode string, message string) { + s := m.convStreams + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + if pending := s.pendingRuns[runID]; pending != nil { + delete(s.pendingRuns, runID) + s.fireCommandUpdateLocked(ChatCommandUpdate{ + RunID: runID, + ClientRequestID: pending.clientRequestID, + Phase: "failed", + ErrorCode: errorCode, + Message: message, + }) + return + } + + record := s.runs[runID] + if record == nil || record.conversationID == "" { + return + } + stream := s.streams[record.conversationID] + if stream == nil { + return + } + s.runFinishedLocked(stream, runID, "failed", errorCode, message, nil, now) +} + +// ChatCommandSettled reports whether a command reached a state the startup +// watchdog must not interfere with: its run started, finished, or was parked +// in the desktop prompt queue. +func (m *Manager) ChatCommandSettled(runID string) bool { + s := m.convStreams + runID = strings.TrimSpace(runID) + s.mu.Lock() + defer s.mu.Unlock() + record := s.runs[runID] + if record == nil { + return false + } + if record.queuedInGUI { + return true + } + if record.conversationID == "" { + return false + } + stream := s.streams[record.conversationID] + if stream == nil { + return false + } + if stream.runFinishedRecently(runID) { + return true + } + return stream.activity != nil && + stream.activity.RunID == runID && + stream.activity.State != RunActivityQueued +} + +// MarkConversationCancelling flips the active run into the cancelling state +// and returns its run id for the caller's watchdog. The agent's real terminal +// signal wins; ForceFinishRun is the fallback. +func (m *Manager) MarkConversationCancelling(conversationID string, runID string) (string, bool) { + s := m.convStreams + conversationID = strings.TrimSpace(conversationID) + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + stream := s.streams[conversationID] + if stream == nil || stream.activity == nil { + return "", false + } + if runID != "" && stream.activity.RunID != runID { + return "", false + } + stream.activity.State = RunActivityCancelling + stream.activity.UpdatedAt = now + s.publishActivityLocked(stream, now) + return stream.activity.RunID, true +} + +// ForceFinishRun finishes a run from a gateway-side watchdog. No-op when the +// run already finished (exactly-once guard). +func (m *Manager) ForceFinishRun(runID string, status string, errorCode string, message string) { + s := m.convStreams + runID = strings.TrimSpace(runID) + now := time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + record := s.runs[runID] + if record == nil || record.conversationID == "" { + return + } + stream := s.streams[record.conversationID] + if stream == nil { + return + } + s.runFinishedLocked(stream, runID, status, errorCode, message, nil, now) +} + +// --- maintenance ----------------------------------------------------------- + +// onRuntimeStatus feeds the agent's reported active-run count into staleness +// tracking: a runtime that keeps reporting zero active runs while a stream +// still shows one means the run died without a terminal signal. +func (s *conversationStreamStore) onRuntimeStatus(activeRunCount uint32, now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if activeRunCount > 0 { + s.runtimeIdleSince = time.Time{} + return + } + if s.runtimeIdleSince.IsZero() { + s.runtimeIdleSince = now + } +} + +func (s *conversationStreamStore) startReaper() { + s.reaperOnce.Do(func() { + interval := s.reaperInterval + if interval <= 0 { + interval = conversationReaperInterval + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + s.reap(time.Now()) + } + }() + }) +} + +func (s *conversationStreamStore) reap(now time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + + online := s.isOnline != nil && s.isOnline() + runtimeIdleLongEnough := online && + !s.runtimeIdleSince.IsZero() && + now.Sub(s.runtimeIdleSince) > s.runtimeIdleGrace + + for conversationID, stream := range s.streams { + s.evictStreamLocked(stream, now) + + if stream.activity != nil && online { + staleBySilence := !stream.lastEventAt.IsZero() && + now.Sub(stream.lastEventAt) > s.staleRunTimeout + activityIsLive := stream.activity.State == RunActivityRunning || + stream.activity.State == RunActivityCancelling + staleByRuntimeIdle := runtimeIdleLongEnough && + activityIsLive && + stream.activity.UpdatedAt.Before(s.runtimeIdleSince) && + stream.lastEventAt.Before(s.runtimeIdleSince) + if staleBySilence || staleByRuntimeIdle { + s.runFinishedLocked(stream, stream.activity.RunID, "failed", "stale_run", + "The desktop runtime stopped reporting this run.", nil, now) + } + } + + if stream.activity == nil && + len(stream.subscribers) == 0 && + now.Sub(stream.updatedAt) > s.idleRetention { + for _, finished := range stream.finishedRuns { + delete(s.runs, finished) + } + delete(s.streams, conversationID) + } + } + + for runID, pending := range s.pendingRuns { + if now.Sub(pending.createdAt) > pendingChatRunRetention { + delete(s.pendingRuns, runID) + } + } +} diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go new file mode 100644 index 00000000..8ce7f60f --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -0,0 +1,613 @@ +package session + +import ( + "encoding/json" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func tokenEvent(conversationID string, text string) *gatewayv1.ChatEvent { + data, _ := json.Marshal(map[string]any{"text": text}) + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: string(data), + } +} + +func doneEvent(conversationID string) *gatewayv1.ChatEvent { + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: `{"title":"Final title"}`, + } +} + +func startedControl(runID string, conversationID string) *gatewayv1.ChatControlEvent { + return &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "started", + State: "running", + } +} + +func completedControl(runID string, conversationID string) *gatewayv1.ChatControlEvent { + return &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "completed", + State: "completed", + } +} + +func drainEvents(t *testing.T, ch <-chan *ConversationEvent, count int) []*ConversationEvent { + t.Helper() + events := make([]*ConversationEvent, 0, count) + timeout := time.After(2 * time.Second) + for len(events) < count { + select { + case event, ok := <-ch: + if !ok { + t.Fatalf("event channel closed after %d events, want %d", len(events), count) + } + events = append(events, event) + case <-timeout: + t.Fatalf("timed out after %d events, want %d", len(events), count) + } + } + return events +} + +func eventTypes(events []*ConversationEvent) []string { + types := make([]string, 0, len(events)) + for _, event := range events { + types = append(types, event.Type) + } + return types +} + +func TestConversationStreamSeqMonotonicAcrossRuns(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hello")) + m.ingestChatEvent("run-1", doneEvent("conv-1")) + m.ingestChatControl("run-2", startedControl("run-2", "conv-1")) + m.ingestChatEvent("run-2", tokenEvent("conv-1", "again")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + var lastSeq int64 + for _, event := range sub.Events { + if event.Seq <= lastSeq { + t.Fatalf("seq not monotonic: %d after %d (types %v)", event.Seq, lastSeq, eventTypes(sub.Events)) + } + lastSeq = event.Seq + } + types := eventTypes(sub.Events) + want := []string{"run_started", "token", "run_finished", "run_started", "token"} + if len(types) != len(want) { + t.Fatalf("replayed types = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replayed types = %v, want %v", types, want) + } + } + if sub.Activity == nil || sub.Activity.RunID != "run-2" || sub.Activity.State != RunActivityRunning { + t.Fatalf("activity = %#v, want running run-2", sub.Activity) + } +} + +func TestRunFinishedExactlyOnceForDuplicateTerminals(t *testing.T) { + cases := []struct { + name string + first func(m *Manager) + second func(m *Manager) + }{ + { + name: "done event then completed control", + first: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + second: func(m *Manager) { m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) }, + }, + { + name: "completed control then terminal snapshot", + first: func(m *Manager) { m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) }, + second: func(m *Manager) { + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", ConversationId: "conv-1", State: "completed", + }) + }, + }, + { + name: "terminal snapshot then done event", + first: func(m *Manager) { + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", ConversationId: "conv-1", State: "cancelled", + }) + }, + second: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + }, + { + name: "force finish then late done", + first: func(m *Manager) { m.ForceFinishRun("run-1", "cancelled", "", "") }, + second: func(m *Manager) { m.ingestChatEvent("run-1", doneEvent("conv-1")) }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hello")) + tc.first(m) + tc.second(m) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + finished := 0 + for _, event := range sub.Events { + if event.Type == StreamEventRunFinished { + finished++ + } + } + if finished != 1 { + t.Fatalf("run_finished count = %d, want 1 (types %v)", finished, eventTypes(sub.Events)) + } + if sub.Activity != nil { + t.Fatalf("activity should be cleared, got %#v", sub.Activity) + } + }) + } +} + +func TestSupersessionFinishesPreviousRunFirst(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "a")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + // A queued prompt auto-send: run-b starts before run-a's terminal arrives. + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "b")) + + live := drainEvents(t, sub.EventCh, 3) + types := eventTypes(live) + if types[0] != StreamEventRunFinished || live[0].RunID != "run-a" { + t.Fatalf("first live event = %s/%s, want run_finished/run-a", types[0], live[0].RunID) + } + if live[0].Payload["reason"] != "superseded" { + t.Fatalf("superseded reason missing: %#v", live[0].Payload) + } + if types[1] != StreamEventRunStarted || live[1].RunID != "run-b" { + t.Fatalf("second live event = %s/%s, want run_started/run-b", types[1], live[1].RunID) + } + if types[2] != "token" || live[2].RunID != "run-b" { + t.Fatalf("third live event = %s/%s, want token/run-b", types[2], live[2].RunID) + } + + // The late terminal for run-a is swallowed. + m.ingestChatControl("run-a", completedControl("run-a", "conv-1")) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "b2")) + next := drainEvents(t, sub.EventCh, 1) + if next[0].Type != "token" || next[0].RunID != "run-b" { + t.Fatalf("late terminal leaked: got %s/%s", next[0].Type, next[0].RunID) + } +} + +func TestSubscribeResumeAndResetSemantics(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "one")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "two")) + + base := m.SubscribeConversationStream("conv-1", 0, "") + base.Cleanup() + epoch := base.StreamEpoch + latest := base.LatestSeq + + resume := m.SubscribeConversationStream("conv-1", latest-1, epoch) + resume.Cleanup() + if resume.Reset { + t.Fatalf("resume within buffer should not reset") + } + if len(resume.Events) != 1 || resume.Events[0].Seq != latest { + t.Fatalf("resume replay = %v", eventTypes(resume.Events)) + } + + ahead := m.SubscribeConversationStream("conv-1", latest+100, epoch) + ahead.Cleanup() + if !ahead.Reset || len(ahead.Events) != int(latest) { + t.Fatalf("client ahead of gateway must reset with full replay, got reset=%v events=%d", ahead.Reset, len(ahead.Events)) + } + + wrongEpoch := m.SubscribeConversationStream("conv-1", latest, "different-epoch") + wrongEpoch.Cleanup() + if !wrongEpoch.Reset { + t.Fatalf("epoch mismatch must reset") + } + + // Gap: force eviction of the early events. + m.convStreams.mu.Lock() + stream := m.convStreams.streams["conv-1"] + stream.evictedThroughSeq = 2 + m.convStreams.mu.Unlock() + gap := m.SubscribeConversationStream("conv-1", 1, epoch) + gap.Cleanup() + if !gap.Reset { + t.Fatalf("resume below evicted floor must reset") + } +} + +func TestStartChatCommandSeedsAndAgentEchoSwallowed(t *testing.T) { + m := NewManager() + start := m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + if start.AcceptedSeq <= 0 { + t.Fatalf("accepted seq = %d, want > 0", start.AcceptedSeq) + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if len(sub.Events) != 1 || sub.Events[0].Type != "user_message" { + t.Fatalf("seeded replay = %v", eventTypes(sub.Events)) + } + if sub.Events[0].Payload["client_request_id"] != "client-1" { + t.Fatalf("seeded user_message missing client_request_id: %#v", sub.Events[0].Payload) + } + if sub.Activity == nil || sub.Activity.State != RunActivityQueued { + t.Fatalf("activity = %#v, want queued", sub.Activity) + } + + // Agent starts the run and echoes the user message: the echo is swallowed. + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + userEcho, _ := json.Marshal(map[string]any{"message": "hello"}) + m.ingestChatEvent("run-1", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(userEcho), + }) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "hi")) + + live := drainEvents(t, sub.EventCh, 2) + types := eventTypes(live) + if types[0] != StreamEventRunStarted || types[1] != "token" { + t.Fatalf("live types = %v, want [run_started token]", types) + } +} + +func TestPendingRunBindsOnFirstAgentSignal(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-1") + defer cleanupWatch() + + start := m.StartChatCommand("run-1", "", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + if start.ConversationID != "" || start.AcceptedSeq != 0 { + t.Fatalf("pending start = %#v", start) + } + + m.ingestChatControl("run-1", startedControl("run-1", "conv-new")) + + select { + case update := <-updates: + if update.Phase != "bound" || update.ConversationID != "conv-new" || update.ClientRequestID != "client-1" { + t.Fatalf("bound update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no bound update") + } + + sub := m.SubscribeConversationStream("conv-new", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{"user_message", "run_started"} + if len(types) != len(want) || types[0] != want[0] || types[1] != want[1] { + t.Fatalf("bound replay = %v, want %v", types, want) + } +} + +func TestQueuedInGUICompensatesSeededEntries(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-1") + defer cleanupWatch() + + m.StartChatCommand("run-1", "conv-1", "", "client-1", []map[string]any{ + {"type": "user_message", "message": "queued prompt"}, + }) + m.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "queued_in_gui", + }) + + select { + case update := <-updates: + if update.Phase != "queued_in_gui" { + t.Fatalf("update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no queued_in_gui update") + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + if len(types) != 2 || types[0] != "user_message" || types[1] != StreamEventRunQueued { + t.Fatalf("replay = %v, want [user_message run_queued]", types) + } + if sub.Activity != nil { + t.Fatalf("queued_in_gui must clear activity, got %#v", sub.Activity) + } + if m.ChatCommandSettled("run-1") != true { + t.Fatalf("queued_in_gui command must count as settled") + } + + // Later auto-send: the agent echo must now pass through. + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + userEcho, _ := json.Marshal(map[string]any{"message": "queued prompt"}) + m.ingestChatEvent("run-1", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(userEcho), + }) + live := drainEvents(t, sub.EventCh, 2) + types = eventTypes(live) + if types[0] != StreamEventRunStarted || types[1] != "user_message" { + t.Fatalf("auto-send live types = %v, want [run_started user_message]", types) + } +} + +func TestFailChatCommandPendingAndBound(t *testing.T) { + m := NewManager() + updates, cleanupWatch := m.WatchChatCommand("run-pending") + defer cleanupWatch() + m.StartChatCommand("run-pending", "", "", "client-1", nil) + m.FailChatCommand("run-pending", "desktop_runtime_unavailable", "agent offline") + select { + case update := <-updates: + if update.Phase != "failed" || update.ErrorCode != "desktop_runtime_unavailable" { + t.Fatalf("failed update = %#v", update) + } + case <-time.After(2 * time.Second): + t.Fatalf("no failed update") + } + + m.StartChatCommand("run-bound", "conv-1", "", "client-2", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + m.FailChatCommand("run-bound", "startup_timeout", "did not start") + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + last := sub.Events[len(sub.Events)-1] + if last.Type != StreamEventRunFinished || last.Payload["status"] != "failed" { + t.Fatalf("bound failure tail = %s %#v", last.Type, last.Payload) + } + if !m.ChatCommandSettled("run-bound") { + t.Fatalf("failed command should be settled") + } +} + +func TestActivityHubCarriesRunIDs(t *testing.T) { + m := NewManager() + activity, cleanup := m.SubscribeChatActivity() + defer cleanup() + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", doneEvent("conv-1")) + + running := <-activity + if !running.Running || running.RunID != "run-1" || running.State != RunActivityRunning { + t.Fatalf("running activity = %#v", running) + } + idle := <-activity + if idle.Running || idle.ConversationID != "conv-1" { + t.Fatalf("idle activity = %#v", idle) + } + + // A late subscriber replays current activity. + m.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + late, lateCleanup := m.SubscribeChatActivity() + defer lateCleanup() + replayed := <-late + if replayed.ConversationID != "conv-2" || replayed.RunID != "run-2" || !replayed.Running { + t.Fatalf("late replay = %#v", replayed) + } +} + +func TestEvictionProtectsActiveRunUntilHardCap(t *testing.T) { + m := NewManager() + m.convStreams.eventRetention = time.Millisecond + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "one")) + time.Sleep(5 * time.Millisecond) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "two")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + sub.Cleanup() + if len(sub.Events) != 3 { + t.Fatalf("active run events must survive retention, got %v", eventTypes(sub.Events)) + } + + // After the run finishes, retention applies again. + m.ingestChatEvent("run-1", doneEvent("conv-1")) + time.Sleep(5 * time.Millisecond) + m.convStreams.mu.Lock() + stream := m.convStreams.streams["conv-1"] + m.convStreams.evictStreamLocked(stream, time.Now()) + remaining := len(stream.events) + evictedThrough := stream.evictedThroughSeq + m.convStreams.mu.Unlock() + if remaining != 0 || evictedThrough == 0 { + t.Fatalf("idle stream should evict all expired events, remaining=%d evictedThrough=%d", remaining, evictedThrough) + } + + // Hard cap evicts even active-run events and flags truncation via the + // evicted floor so subscribers get a reset. + m2 := NewManager() + m2.convStreams.maxEvents = 4 + m2.ingestChatControl("run-1", startedControl("run-1", "conv-x")) + for i := 0; i < 10; i++ { + m2.ingestChatEvent("run-1", tokenEvent("conv-x", "t")) + } + m2.convStreams.mu.Lock() + streamX := m2.convStreams.streams["conv-x"] + if len(streamX.events) > 4 { + m2.convStreams.mu.Unlock() + t.Fatalf("hard cap not enforced: %d events", len(streamX.events)) + } + if streamX.evictedThroughSeq == 0 { + m2.convStreams.mu.Unlock() + t.Fatalf("evictedThroughSeq not advanced under hard cap") + } + m2.convStreams.mu.Unlock() +} + +func TestReaperForceFinishesStaleRunsOnlyWhenOnline(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = time.Millisecond + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(5 * time.Millisecond) + + // Agent offline: the run is left alone. + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("offline reap must not finish runs, activities=%d", len(activities)) + } + + // Agent online: the silent run is force-finished. + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("online reap must finish stale runs, activities=%d", len(activities)) + } + + sub := m.SubscribeConversationStream("conv-1", 0, "") + sub.Cleanup() + last := sub.Events[len(sub.Events)-1] + if last.Type != StreamEventRunFinished || last.Payload["error_code"] != "stale_run" { + t.Fatalf("stale finish tail = %s %#v", last.Type, last.Payload) + } +} + +func TestCancellingStateAndWatchdog(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + + runID, ok := m.MarkConversationCancelling("conv-1", "") + if !ok || runID != "run-1" { + t.Fatalf("cancelling = %q %v", runID, ok) + } + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if sub.Activity == nil || sub.Activity.State != RunActivityCancelling { + t.Fatalf("activity = %#v, want cancelling", sub.Activity) + } + + // The agent's real terminal wins over the watchdog. + m.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", ConversationId: "conv-1", Type: "cancelled", State: "cancelled", + }) + m.ForceFinishRun("run-1", "cancelled", "cancel_timeout", "watchdog") + + finished := 0 + for _, event := range drainEvents(t, sub.EventCh, 1) { + if event.Type == StreamEventRunFinished { + finished++ + if event.Payload["error_code"] == "cancel_timeout" { + t.Fatalf("watchdog overrode the agent terminal: %#v", event.Payload) + } + } + } + if finished != 1 { + t.Fatalf("run_finished count = %d", finished) + } +} + +func TestGatewayRestartSnapshotRebuildsStream(t *testing.T) { + // A fresh manager simulates a restarted gateway: the first thing it sees + // for the conversation is a runtime snapshot of an in-flight run. + m := NewManager() + m.ingestRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", + ConversationId: "conv-1", + State: "running", + Revision: 7, + EntriesJson: `[{"kind":"assistant","text":"partial"}]`, + ToolStatus: "Vibing", + }) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if sub.Activity == nil || sub.Activity.RunID != "run-1" { + t.Fatalf("activity = %#v", sub.Activity) + } + if sub.Activity.ToolStatus != "Vibing" { + t.Fatalf("tool status = %q", sub.Activity.ToolStatus) + } + if sub.Snapshot == nil || sub.Snapshot.Revision != 7 { + t.Fatalf("late joiner must get the snapshot, got %#v", sub.Snapshot) + } + + // Live continuation streams normally afterwards. + m.ingestChatEvent("run-1", tokenEvent("conv-1", "more")) + live := drainEvents(t, sub.EventCh, 1) + if live[0].Type != "token" { + t.Fatalf("live continuation = %v", eventTypes(live)) + } +} + +func TestSubscriberOverflowClosesAndResumes(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + for i := 0; i < conversationSubscriberBuffer+8; i++ { + m.ingestChatEvent("run-1", tokenEvent("conv-1", "x")) + } + + deadline := time.After(2 * time.Second) + closed := false + received := 0 + for !closed { + select { + case _, ok := <-sub.EventCh: + if !ok { + closed = true + break + } + received++ + if received > conversationSubscriberBuffer+8 { + t.Fatalf("received more events than sent") + } + case <-deadline: + t.Fatalf("subscriber channel never closed on overflow") + } + } + if !sub.Overflowed() { + t.Fatalf("overflow flag not set") + } + + // Resume from the last seen seq replays the tail without loss. + resume := m.SubscribeConversationStream("conv-1", int64(received)+1, sub.StreamEpoch) + resume.Cleanup() + if resume.Reset { + t.Fatalf("resume after overflow should not reset while buffer covers the gap") + } + total := received + len(resume.Events) + if total < conversationSubscriberBuffer+8 { + t.Fatalf("lost events across overflow: saw %d", total) + } +} diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index 51cf50b5..ef707d0b 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -34,11 +34,12 @@ type AuthSnapshot struct { } type Manager struct { - registry *sessionRegistry - syncHub *syncHub - chatStore *chatRunStore - tunnels *tunnelStore - cmdQueue *commandQueue + registry *sessionRegistry + syncHub *syncHub + chatStore *chatRunStore + convStreams *conversationStreamStore + tunnels *tunnelStore + cmdQueue *commandQueue } type AgentSession struct { @@ -146,12 +147,14 @@ type Status struct { } func NewManager() *Manager { - return &Manager{ + m := &Manager{ registry: newSessionRegistry(), syncHub: newSyncHub(), chatStore: newChatRunStore(), tunnels: newTunnelStore(), cmdQueue: newCommandQueue(defaultCommandQueueTimeout), } + m.convStreams = newConversationStreamStore(m.IsOnline) + return m } From c0a0521738b93fe16368b3ed6ca016a922f4b70b Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 11:39:15 +0800 Subject: [PATCH 16/64] refactor(gateway): cut chat relay over to conversation streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-run chat relay with the conversation stream store end to end: - delete manager_chat_runs.go (run state machine, conversation claim map, runEpoch, per-run subscribers) and route agent dispatch through the normalizing ingress; agent-sent history running/idle events are dropped — activity now derives from run lifecycle transitions with run ids from birth - new WS contract: persistent per-conversation chat.subscribe with stream_epoch/after_seq resume and reset semantics, first-class conversation_id/run_id/seq on every chat.event, chat.activity broadcast hub, chat.command_update for pre-stream command outcomes; chat.replay and chat.subscription_end are gone - cancel keeps the run alive in a cancelling state until the agent's terminal signal, with a 15s force-finish watchdog; startup watchdog fails commands whose run never settles - remove the unused POST /api/chat/commands endpoint, its rate limiter and CSRF helper (origin checks move to http_origin.go), and the per-connection history running/idle run_id enrichment - rewrite gateway chat tests against the new contract (websocket chat suite, session integration suite) Co-Authored-By: Claude Fable 5 --- .../internal/server/chat_commands.go | 81 +- .../internal/server/chat_rate_limiter.go | 67 - .../internal/server/chat_rate_limiter_test.go | 23 - crates/agent-gateway/internal/server/http.go | 2 - .../internal/server/http_chat.go | 335 ---- .../internal/server/http_origin.go | 96 ++ .../internal/server/http_test.go | 679 -------- .../internal/server/websocket.go | 78 +- .../server/websocket_chat_handlers.go | 330 ++-- .../server/websocket_history_handlers.go | 14 +- .../internal/server/websocket_payload_test.go | 125 +- .../internal/server/websocket_payloads.go | 125 +- .../internal/server/websocket_roundtrip.go | 3 - .../internal/server/websocket_routes.go | 1 - .../internal/server/websocket_routes_test.go | 1 - .../agent-gateway/internal/session/manager.go | 80 +- .../internal/session/manager_chat_runs.go | 1333 --------------- .../internal/session/manager_dispatch.go | 88 + .../internal/session/manager_history_sync.go | 111 +- .../internal/session/manager_state.go | 18 - .../internal/session/manager_test.go | 82 +- .../test/session/manager_test.go | 1441 +++-------------- .../agent-gateway/test/websocket/chat_test.go | 347 ++++ docs/architecture/gateway.md | 3 +- docs/architecture/protocols.md | 8 +- docs/architecture/webui.md | 2 +- 26 files changed, 1074 insertions(+), 4399 deletions(-) delete mode 100644 crates/agent-gateway/internal/server/chat_rate_limiter.go delete mode 100644 crates/agent-gateway/internal/server/chat_rate_limiter_test.go delete mode 100644 crates/agent-gateway/internal/server/http_chat.go create mode 100644 crates/agent-gateway/internal/server/http_origin.go delete mode 100644 crates/agent-gateway/internal/session/manager_chat_runs.go create mode 100644 crates/agent-gateway/internal/session/manager_dispatch.go create mode 100644 crates/agent-gateway/test/websocket/chat_test.go diff --git a/crates/agent-gateway/internal/server/chat_commands.go b/crates/agent-gateway/internal/server/chat_commands.go index c02f9418..2b015c36 100644 --- a/crates/agent-gateway/internal/server/chat_commands.go +++ b/crates/agent-gateway/internal/server/chat_commands.go @@ -26,14 +26,6 @@ type chatCommandMessageRef struct { ContentHash string `json:"content_hash"` } -type chatCommandStart struct { - RunID string - ConversationID string - AcceptedSeq int64 - Created bool - State string -} - func newChatTraceID() string { return strings.ReplaceAll(uuid.NewString(), "-", "") } @@ -90,49 +82,21 @@ func normalizeChatQueuePolicy(value string) string { } } -func startAcceptedChatCommand( - sm *session.Manager, - requestID string, - body handler.ChatRequestBody, - initialPayloads []map[string]any, -) (chatCommandStart, error) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - requestID = "chat-command-" + uuid.NewString() - } - snapshot, created, acceptedSeq, err := sm.StartAcceptedChatCommandRun( - requestID, - body.ConversationID, - body.Workdir, - initialPayloads, - ) - if err != nil { - return chatCommandStart{}, err - } - runID := snapshot.RequestID - if runID == "" { - runID = requestID - } - return chatCommandStart{ - RunID: runID, - ConversationID: strings.TrimSpace(snapshot.ConversationID), - AcceptedSeq: acceptedSeq, - Created: created, - State: strings.TrimSpace(snapshot.State), - }, nil -} - +// dispatchAcceptedChatCommand delivers the accepted command to the agent and +// arms the startup watchdog. cleanupWatch closes the caller's command-update +// watch once the command has either settled or been failed. func dispatchAcceptedChatCommand( parent context.Context, cfg *config.Config, sm *session.Manager, - start chatCommandStart, + cleanupWatch func(), + start session.ChatCommandStart, body handler.ChatRequestBody, baseMessageRef *chatCommandMessageRef, traceID string, ) { - if !start.Created { - return + if cleanupWatch != nil { + defer cleanupWatch() } timeout := 2 * time.Minute if cfg != nil && cfg.RequestTimeout > 0 { @@ -146,13 +110,20 @@ func dispatchAcceptedChatCommand( commandType = "chat.edit_resend" } if err := sm.SendToAgentContext(ctx, buildChatCommandEnvelope(start.RunID, commandType, body, baseMessageRef)); err != nil { - failAcceptedChatCommand(sm, start.RunID, body.ConversationID, "desktop_runtime_unavailable", err) + message := "chat command failed" + if err != nil && strings.TrimSpace(err.Error()) != "" { + message = strings.TrimSpace(err.Error()) + } + sm.FailChatCommand(start.RunID, "desktop_runtime_unavailable", message) return } logChatCommandSpan(traceID, "command_delivered", start.RunID, start.ConversationID, body.ClientRequestID, commandType) watchAcceptedChatCommandStartup(parent, cfg, sm, start.RunID) } +// watchAcceptedChatCommandStartup fails a command whose run never settled +// (started, finished, or parked in the desktop prompt queue) within the +// configured startup window. func watchAcceptedChatCommandStartup( parent context.Context, cfg *config.Config, @@ -165,13 +136,17 @@ func watchAcceptedChatCommandStartup( if !waitChatCommandWatchdog(parent, chatStartTimeout(cfg)) { return } - if sm.FailStartingChatRun(runID, "Desktop backend did not accept the remote chat request. Please retry.") { + if sm.ChatCommandSettled(runID) { return } if !waitChatCommandWatchdog(parent, chatRenderStartTimeout(cfg)) { return } - sm.FailUnstartedChatRun(runID, "Desktop app accepted the remote chat request but did not start it. Please retry.") + if sm.ChatCommandSettled(runID) { + return + } + sm.FailChatCommand(runID, "startup_timeout", + "The desktop app did not start the remote chat request. Please retry.") } func waitChatCommandWatchdog(ctx context.Context, timeout time.Duration) bool { @@ -239,20 +214,6 @@ func buildUserMessageAppendedPayload( return payload } -func failAcceptedChatCommand( - sm *session.Manager, - runID string, - conversationID string, - errorCode string, - err error, -) { - message := "chat command failed" - if err != nil && strings.TrimSpace(err.Error()) != "" { - message = strings.TrimSpace(err.Error()) - } - sm.MarkChatRunControl(runID, conversationID, "failed", errorCode, message) -} - func buildChatCommandEnvelope( requestID string, commandType string, diff --git a/crates/agent-gateway/internal/server/chat_rate_limiter.go b/crates/agent-gateway/internal/server/chat_rate_limiter.go deleted file mode 100644 index 37027f92..00000000 --- a/crates/agent-gateway/internal/server/chat_rate_limiter.go +++ /dev/null @@ -1,67 +0,0 @@ -package server - -import ( - "net" - "net/http" - "strings" - "sync" - "time" -) - -type chatRateLimiter struct { - mu sync.Mutex - buckets map[string]chatRateLimitBucket -} - -type chatRateLimitBucket struct { - windowStart time.Time - count int -} - -func newChatRateLimiter() *chatRateLimiter { - return &chatRateLimiter{ - buckets: make(map[string]chatRateLimitBucket), - } -} - -func (l *chatRateLimiter) allow(key string, limit int, window time.Duration, now time.Time) bool { - if l == nil || limit <= 0 || window <= 0 { - return true - } - key = strings.TrimSpace(key) - if key == "" { - key = "unknown" - } - l.mu.Lock() - defer l.mu.Unlock() - for bucketKey, bucket := range l.buckets { - if now.Sub(bucket.windowStart) > 2*window { - delete(l.buckets, bucketKey) - } - } - bucket := l.buckets[key] - if bucket.windowStart.IsZero() || now.Sub(bucket.windowStart) >= window { - l.buckets[key] = chatRateLimitBucket{windowStart: now, count: 1} - return true - } - if bucket.count >= limit { - return false - } - bucket.count += 1 - l.buckets[key] = bucket - return true -} - -func chatRateLimitKey(r *http.Request, scope string) string { - host := "" - if r != nil { - host = strings.TrimSpace(r.RemoteAddr) - } - if parsedHost, _, err := net.SplitHostPort(host); err == nil { - host = parsedHost - } - if host == "" { - host = "unknown" - } - return strings.TrimSpace(scope) + ":" + host -} diff --git a/crates/agent-gateway/internal/server/chat_rate_limiter_test.go b/crates/agent-gateway/internal/server/chat_rate_limiter_test.go deleted file mode 100644 index c110dde9..00000000 --- a/crates/agent-gateway/internal/server/chat_rate_limiter_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package server - -import ( - "testing" - "time" -) - -func TestChatRateLimiterFixedWindow(t *testing.T) { - limiter := newChatRateLimiter() - now := time.Unix(100, 0) - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now) { - t.Fatal("first request should be allowed") - } - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(time.Second)) { - t.Fatal("second request should be allowed") - } - if limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(2*time.Second)) { - t.Fatal("third request should be rate limited") - } - if !limiter.allow("chat.commands:127.0.0.1", 2, time.Minute, now.Add(time.Minute)) { - t.Fatal("request in next window should be allowed") - } -} diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index a42bc2fc..e5f0c7d7 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -38,10 +38,8 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { rootMux.HandleFunc("GET /api/public/history-shares/{token}", publicHistoryShare(cfg, sm)) apiMux := http.NewServeMux() - chatLimiter := newChatRateLimiter() apiMux.HandleFunc("GET /api/status", handler.Status(sm)) apiMux.HandleFunc("POST /api/files/import", handler.ImportReadableFiles(sm, cfg.RequestTimeout)) - apiMux.HandleFunc("POST /api/chat/commands", chatCommandsHTTP(cfg, sm, chatLimiter)) rootMux.Handle("/api/", auth.HTTPMiddleware(cfg.Token, apiMux)) webFS, err := fs.Sub(gateway.WebUIAssets, "web/dist") diff --git a/crates/agent-gateway/internal/server/http_chat.go b/crates/agent-gateway/internal/server/http_chat.go deleted file mode 100644 index a260da61..00000000 --- a/crates/agent-gateway/internal/server/http_chat.go +++ /dev/null @@ -1,335 +0,0 @@ -package server - -import ( - "context" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/google/uuid" - "github.com/liveagent/agent-gateway/internal/chatwire" - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -const maxChatCommandBytes = 2 * 1024 * 1024 -const maxChatCommandsPerMinute = 120 - -func chatCommandsHTTP(cfg *config.Config, sm *session.Manager, limiter *chatRateLimiter) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if !allowStateChangingRequest(w, r) { - return - } - if limiter != nil && !limiter.allow(chatRateLimitKey(r, "chat.commands"), maxChatCommandsPerMinute, time.Minute, time.Now()) { - writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "chat command rate limit exceeded"}) - return - } - raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxChatCommandBytes)) - if err != nil { - writeJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "chat command payload is too large"}) - return - } - commandType, body, baseMessageRef, err := decodeChatCommandPayload(raw) - if err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat command payload"}) - return - } - - switch commandType { - case "chat.submit": - baseMessageRef = nil - case "chat.edit_resend": - if baseMessageRef == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "base_message_ref is required"}) - return - } - if err := validateChatMessageRef(baseMessageRef); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) - return - } - case "chat.cancel": - handleChatCancelCommandHTTP(w, r, cfg, sm, raw) - return - default: - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "unsupported chat command"}) - return - } - - if err := normalizeChatRequestBody(&body); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) - return - } - traceID := newChatTraceID() - logChatCommandSpan(traceID, "command_received", "", body.ConversationID, body.ClientRequestID, commandType) - if !sm.IsOnline() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "agent offline"}) - return - } - - requestID := "chat-command-" + uuid.NewString() - initialPayloads := buildAcceptedChatCommandPayloads(body, baseMessageRef) - start, err := startAcceptedChatCommand(sm, requestID, body, initialPayloads) - if err != nil { - writeJSON(w, http.StatusConflict, map[string]any{"error": websocketErrorMessage(err)}) - return - } - if start.Created { - logChatCommandSpan(traceID, "initial_persist_done", start.RunID, start.ConversationID, body.ClientRequestID, commandType) - go dispatchAcceptedChatCommand(context.Background(), cfg, sm, start, body, baseMessageRef, traceID) - } else { - logChatCommandSpan(traceID, "command_deduped", start.RunID, start.ConversationID, body.ClientRequestID, commandType) - } - - writeJSON(w, http.StatusAccepted, map[string]any{ - "run_id": start.RunID, - "conversation_id": start.ConversationID, - "client_request_id": body.ClientRequestID, - "accepted_seq": start.AcceptedSeq, - "state": start.State, - "deduped": !start.Created, - }) - } -} - -func handleChatCancelCommandHTTP( - w http.ResponseWriter, - r *http.Request, - cfg *config.Config, - sm *session.Manager, - raw []byte, -) { - type cancelPayload struct { - Type string `json:"type"` - Payload *struct { - RunID string `json:"run_id"` - ConversationID string `json:"conversation_id"` - } `json:"payload"` - } - var payload cancelPayload - if err := decodeStrictJSON(raw, &payload); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat.cancel payload"}) - return - } - if strings.TrimSpace(payload.Type) != "chat.cancel" || payload.Payload == nil { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid chat.cancel payload"}) - return - } - conversationID := strings.TrimSpace(payload.Payload.ConversationID) - runID := strings.TrimSpace(payload.Payload.RunID) - if conversationID == "" { - writeJSON(w, http.StatusBadRequest, map[string]any{"error": "conversation_id is required"}) - return - } - if !sm.IsOnline() { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "agent offline"}) - return - } - if runID == "" { - if snapshot, ok := sm.RunningChatRunSnapshot(conversationID); ok { - runID = strings.TrimSpace(snapshot.RequestID) - if conversationID == "" { - conversationID = strings.TrimSpace(snapshot.ConversationID) - } - } - } - if runID == "" { - writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "run_id": "", "conversation_id": conversationID}) - return - } - requestID := runID - timeout := 10 * time.Second - if cfg != nil && cfg.WebSocketWriteTimeout > 0 { - timeout = cfg.WebSocketWriteTimeout - } - ctx, cancel := context.WithTimeout(r.Context(), timeout) - defer cancel() - if err := sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: buildChatCancelCommandPayload(conversationID), - }); err != nil { - writeJSON(w, http.StatusBadGateway, map[string]any{"error": websocketErrorMessage(err)}) - return - } - if runID != "" { - sm.MarkChatRunControl(runID, conversationID, "cancelled", "", "") - } - writeJSON(w, http.StatusAccepted, map[string]any{"accepted": true, "run_id": runID, "conversation_id": conversationID}) -} - - -func chatBroadcastPayload(event *session.ChatBroadcastEvent) (map[string]any, bool) { - if len(event.Payload) > 0 { - payload := cloneChatPayload(event.Payload) - if seq := event.Seq; seq > 0 { - payload["seq"] = seq - } - if len(event.Workdir) > 0 { - if workdir := strings.TrimSpace(event.Workdir); workdir != "" { - payload["workdir"] = workdir - } - } - chatwire.TrimLargeToolContent(payload, "") - return publicChatPayload(payload), isTerminalChatPayload(payload) - } - if event.Control != nil { - payload := chatwire.ControlPayload(event.Control, event.Seq, event.Workdir) - return publicChatPayload(payload), chatwire.IsTerminalControl(event.Control) - } - if event.Event != nil { - payload := chatwire.EventPayload(event.Event, event.Seq, event.Workdir) - return publicChatPayload(payload), event.Event.GetType() == gatewayv1.ChatEvent_DONE || - event.Event.GetType() == gatewayv1.ChatEvent_ERROR - } - return nil, false -} - -func isTerminalChatPayload(payload map[string]any) bool { - eventType, _ := payload["type"].(string) - switch strings.TrimSpace(eventType) { - case "done", "completed", "error", "failed", "cancelled": - return true - case "runtime_snapshot": - state, _ := payload["state"].(string) - switch strings.TrimSpace(state) { - case "completed", "failed", "cancelled": - return true - default: - return false - } - default: - return false - } -} - -func cloneChatPayload(input map[string]any) map[string]any { - if len(input) == 0 { - return map[string]any{} - } - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out -} - -func publicChatPayload(payload map[string]any) map[string]any { - delete(payload, "request_id") - return payload -} - -func parseAfterSeq(value string) int64 { - value = strings.TrimSpace(value) - if value == "" { - return 0 - } - if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { - return parsed - } - return 0 -} - -func allowStateChangingRequest(w http.ResponseWriter, r *http.Request) bool { - if !originAllowed(r) { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "forbidden origin"}) - return false - } - if strings.TrimSpace(r.Header.Get("X-LiveAgent-CSRF")) == "" { - writeJSON(w, http.StatusForbidden, map[string]any{"error": "missing csrf header"}) - return false - } - return true -} - -func originAllowed(r *http.Request) bool { - origin := strings.TrimSpace(r.Header.Get("Origin")) - if origin == "" { - return true - } - parsed, err := url.Parse(origin) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return false - } - requestURL := requestURLForOriginCheck(r) - if requestURL == nil { - return false - } - if sameOrigin(parsed, requestURL) { - return true - } - originHost := strings.TrimSpace(parsed.Hostname()) - requestHost := strings.TrimSpace(requestURL.Hostname()) - if originHost == "" || requestHost == "" { - return false - } - return isLoopbackHost(originHost) && isLoopbackHost(requestHost) -} - -func requestURLForOriginCheck(r *http.Request) *url.URL { - if r == nil { - return nil - } - scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) - switch scheme { - case "http", "https": - default: - return nil - } - host := strings.TrimSpace(r.Host) - if host == "" { - return nil - } - return &url.URL{Scheme: scheme, Host: host} -} - -func sameOrigin(a *url.URL, b *url.URL) bool { - if a == nil || b == nil { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { - return false - } - if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { - return false - } - return originPort(a) == originPort(b) -} - -func originPort(u *url.URL) string { - if u == nil { - return "" - } - if port := strings.TrimSpace(u.Port()); port != "" { - return port - } - switch strings.ToLower(strings.TrimSpace(u.Scheme)) { - case "http", "ws": - return "80" - case "https", "wss": - return "443" - default: - return "" - } -} - -func isLoopbackHost(host string) bool { - host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") - if host == "localhost" { - return true - } - ip := net.ParseIP(host) - return ip != nil && ip.IsLoopback() -} diff --git a/crates/agent-gateway/internal/server/http_origin.go b/crates/agent-gateway/internal/server/http_origin.go new file mode 100644 index 00000000..5c4fc932 --- /dev/null +++ b/crates/agent-gateway/internal/server/http_origin.go @@ -0,0 +1,96 @@ +package server + +import ( + "net" + "net/http" + "net/url" + "strings" +) + +func originAllowed(r *http.Request) bool { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + return true + } + parsed, err := url.Parse(origin) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return false + } + requestURL := requestURLForOriginCheck(r) + if requestURL == nil { + return false + } + if sameOrigin(parsed, requestURL) { + return true + } + originHost := strings.TrimSpace(parsed.Hostname()) + requestHost := strings.TrimSpace(requestURL.Hostname()) + if originHost == "" || requestHost == "" { + return false + } + return isLoopbackHost(originHost) && isLoopbackHost(requestHost) +} + +func requestURLForOriginCheck(r *http.Request) *url.URL { + if r == nil { + return nil + } + scheme := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")) + if scheme == "" { + if r.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + scheme = strings.ToLower(strings.TrimSpace(strings.Split(scheme, ",")[0])) + switch scheme { + case "http", "https": + default: + return nil + } + host := strings.TrimSpace(r.Host) + if host == "" { + return nil + } + return &url.URL{Scheme: scheme, Host: host} +} + +func sameOrigin(a *url.URL, b *url.URL) bool { + if a == nil || b == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Scheme), strings.TrimSpace(b.Scheme)) { + return false + } + if !strings.EqualFold(strings.TrimSpace(a.Hostname()), strings.TrimSpace(b.Hostname())) { + return false + } + return originPort(a) == originPort(b) +} + +func originPort(u *url.URL) string { + if u == nil { + return "" + } + if port := strings.TrimSpace(u.Port()); port != "" { + return port + } + switch strings.ToLower(strings.TrimSpace(u.Scheme)) { + case "http", "ws": + return "80" + case "https", "wss": + return "443" + default: + return "" + } +} + +func isLoopbackHost(host string) bool { + host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]") + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index 513577a3..3dddd44b 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -302,50 +302,6 @@ func TestPublicHistoryShareReturnsUnavailableWhenAgentOffline(t *testing.T) { } } -func TestChatCommandRequiresCSRFHeader(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "csrf") { - t.Fatalf("body = %q, want csrf error", rec.Body.String()) - } -} - -func TestChatCommandRejectsForeignOrigin(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - req.Header.Set("Origin", "https://evil.example") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("expected status %d, got %d body %s", http.StatusForbidden, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "origin") { - t.Fatalf("body = %q, want origin error", rec.Body.String()) - } -} - func TestOriginAllowedRequiresStrictOriginForPublicHosts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://gateway.test:8080/api/chat/commands", nil) @@ -376,638 +332,3 @@ func TestOriginAllowedUsesForwardedProtoForSameOrigin(t *testing.T) { } } -func TestChatCommandsRejectLegacyEnvelopeShapes(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - legacyBodies := []string{ - `{"message":"hello","client_request_id":"client-1"}`, - `{"command":"chat.submit","payload":{"message":"hello","client_request_id":"client-1"}}`, - `{"type":"chat.submit","message":"hello","client_request_id":"client-1"}`, - `{"type":"chat.submit","payload":{"message":"hello","request_id":"legacy-run"}}`, - `{"type":"chat.submit","payload":null}`, - } - - for _, body := range legacyBodies { - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(body), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("body %s expected status %d, got %d body %s", body, http.StatusBadRequest, rec.Code, rec.Body.String()) - } - } -} - -func TestChatCommandsRequireClientRequestID(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "client_request_id") { - t.Fatalf("body = %q, want client_request_id error", rec.Body.String()) - } -} - - -func TestChatEventsAfterSeqParsingIsStrictNumeric(t *testing.T) { - if got := parseAfterSeq("41"); got != 41 { - t.Fatalf("parseAfterSeq numeric = %d, want 41", got) - } - if got := parseAfterSeq("run-1/41"); got != 0 { - t.Fatalf("parseAfterSeq legacy slash id = %d, want 0", got) - } -} - -func TestChatCancelCommandForwardsCancelRequest(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1","run_id":"run-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - handler.ServeHTTP(rec, req) - close(done) - }() - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - cancelReq := command.GetCancel() - if outbound.GetRequestId() != "run-1" || - command.GetType() != "chat.cancel" || - cancelReq == nil || - cancelReq.GetConversationId() != "conversation-1" { - t.Fatalf("cancel outbound id=%q payload=%#v", outbound.GetRequestId(), command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel chat request") - } - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel response") - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - var decoded map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { - t.Fatalf("decode cancel response: %v", err) - } - if decoded["accepted"] != true || decoded["run_id"] != "run-1" || decoded["conversation_id"] != "conversation-1" { - t.Fatalf("cancel response = %#v", decoded) - } -} - -func TestChatCancelRejectsLegacyRequestIDAlias(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1","request_id":"legacy-run"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - t.Fatalf("unexpected outbound cancel for rejected legacy payload: %#v", outbound) - default: - } - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } -} - -func TestChatCancelCommandFindsRunByConversation(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("run-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun created=%v err=%v", created, err) - } - sm.MarkChatRunControl("run-1", "conversation-1", "started", "", "") - startedSnapshot, ok := sm.ChatRunSnapshot("run-1", "conversation-1") - if !ok { - t.Fatal("expected started run snapshot") - } - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - done := make(chan struct{}) - go func() { - handler.ServeHTTP(rec, req) - close(done) - }() - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - if outbound.GetRequestId() != "run-1" { - t.Fatalf("cancel request id = %q, want run-1", outbound.GetRequestId()) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel chat request") - } - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("timed out waiting for cancel response") - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - - cancelSub, err := sm.SubscribeChatRun("run-1", "conversation-1", startedSnapshot.LatestSeq) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer cancelSub.Cleanup() - select { - case event := <-cancelSub.EventCh: - if event.Control == nil || event.Control.GetType() != "cancelled" { - t.Fatalf("cancel event = %#v, want cancelled control", event) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for local cancelled event") - } -} - -func TestChatCancelByConversationIgnoresDesktopQueuedRun(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("queued-run", "conversation-1", "client-queued"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun queued created=%v err=%v", created, err) - } - sm.MarkChatRunControl("queued-run", "conversation-1", "queued_in_gui", "", "") - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - t.Fatalf("unexpected outbound cancel for desktop queued run: %#v", outbound) - default: - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - var decoded map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { - t.Fatalf("decode cancel response: %v", err) - } - if decoded["accepted"] != true || decoded["run_id"] != "" || decoded["conversation_id"] != "conversation-1" { - t.Fatalf("cancel response = %#v", decoded) - } - snapshot, ok := sm.ChatRunSnapshot("queued-run", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateDesktopQueued { - t.Fatalf("queued snapshot = %#v ok=%v, want desktop queued", snapshot, ok) - } -} - -func TestChatCancelByConversationStillIgnoresDesktopQueuedRunAfterHistoryRunning(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - if _, created, err := sm.StartPendingChatCommandRun("queued-run", "conversation-1", "client-queued"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun queued created=%v err=%v", created, err) - } - sm.MarkChatRunControl("queued-run", "conversation-1", "queued_in_gui", "", "") - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-running-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - }, - }, - }, - }) - - handler := NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.cancel","payload":{"conversation_id":"conversation-1"}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - t.Fatalf("unexpected outbound cancel for desktop queued run: %#v", outbound) - default: - } - if rec.Code != http.StatusAccepted { - t.Fatalf("expected status %d, got %d body %s", http.StatusAccepted, rec.Code, rec.Body.String()) - } - snapshot, ok := sm.ChatRunSnapshot("queued-run", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateDesktopQueued { - t.Fatalf("queued snapshot = %#v ok=%v, want desktop queued", snapshot, ok) - } -} - -func TestChatEditResendRejectsNegativeBaseMessageRef(t *testing.T) { - handler := NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager()) - - req := httptest.NewRequest( - http.MethodPost, - "http://gateway.test/api/chat/commands", - strings.NewReader(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":-1,"message_index":4,"segment_id":"segment-a","message_id":"user-a","role":"user","content_hash":"fnv1a32:00000000"}}}`), - ) - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected status %d, got %d body %s", http.StatusBadRequest, rec.Code, rec.Body.String()) - } - if !strings.Contains(rec.Body.String(), "base_message_ref") { - t.Fatalf("body = %q, want base_message_ref error", rec.Body.String()) - } -} - -func TestChatCommandSubmitAcceptsAndSSEReplaysControl(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-1","workdir":"/workspace"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var accepted struct { - RunID string `json:"run_id"` - AcceptedSeq int64 `json:"accepted_seq"` - Conversation string `json:"conversation_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" || accepted.AcceptedSeq != 1 || accepted.Conversation != "conversation-1" { - t.Fatalf("accepted response = %#v", accepted) - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - chatReq := command.GetRequest() - if chatReq == nil { - t.Fatalf("outbound payload = %T, want ChatCommandRequest with ChatRequest", outbound.GetPayload()) - } - if command.GetType() != "chat.submit" { - t.Fatalf("chat command type = %q, want chat.submit", command.GetType()) - } - if chatReq.GetMessage() != "hello" || - chatReq.GetConversationId() != "conversation-1" || - chatReq.GetClientRequestId() != "client-1" || - chatReq.GetWorkdir() != "/workspace" { - t.Fatalf("chat request = %#v", chatReq) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat command outbound request") - } -} - -func TestChatCommandStartWatchdogFailsUndeliveredRun(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - ChatStartTimeout: 20 * time.Millisecond, - ChatRenderStartTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.submit","payload":{"message":"hello","conversation_id":"conversation-1","client_request_id":"client-watchdog-1"}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var accepted struct { - RunID string `json:"run_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" { - t.Fatalf("accepted response missing run_id") - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat command outbound request") - } - - deadline := time.After(time.Second) - for { - snapshot, ok := sm.ChatRunSnapshot(accepted.RunID, "conversation-1") - if ok && snapshot.State == session.ChatRunStateFailed { - break - } - select { - case <-deadline: - t.Fatalf("timed out waiting for watchdog failure, last snapshot ok=%v value=%#v", ok, snapshot) - case <-time.After(10 * time.Millisecond): - } - } - - wdSub, err := sm.SubscribeChatRun(accepted.RunID, "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer wdSub.Cleanup() - for { - select { - case event := <-wdSub.EventCh: - if event.Event != nil && event.Event.GetType() == gatewayv1.ChatEvent_ERROR { - if !strings.Contains(event.Event.GetData(), "Desktop backend did not accept") { - t.Fatalf("watchdog error data = %q", event.Event.GetData()) - } - return - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for watchdog error replay") - } - } -} - - -func TestChatCommandSeqContinuesAcrossConversationRuns(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - postCommand := func(raw string) map[string]any { - t.Helper() - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(raw), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - var decoded map[string]any - if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { - t.Fatalf("decode response: %v", err) - } - return decoded - } - - first := postCommand(`{"type":"chat.submit","payload":{"message":"first","conversation_id":"conversation-1","client_request_id":"client-submit-1"}}`) - firstRunID, _ := first["run_id"].(string) - if firstRunID == "" || first["accepted_seq"] != float64(1) { - t.Fatalf("first response = %#v, want accepted_seq 1", first) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - case <-time.After(time.Second): - t.Fatal("timed out waiting for first chat command") - } - sm.MarkChatRunControl(firstRunID, "conversation-1", "completed", "", "") - - second := postCommand(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":0,"message_index":0,"segment_id":"segment-a","message_id":"user-a","role":"user","content_hash":"fnv1a32:00000000"}}}`) - secondRunID, _ := second["run_id"].(string) - if secondRunID == "" || secondRunID == firstRunID || second["accepted_seq"] != float64(4) { - t.Fatalf("second response = %#v, want new run accepted_seq 4", second) - } - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - if command.GetType() != "chat.edit_resend" { - t.Fatalf("second outbound command type = %q, want chat.edit_resend", command.GetType()) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for second chat command") - } -} - - - -func TestChatEditResendSendsSingleChatCommand(t *testing.T) { - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - ts := httptest.NewServer(NewHTTPServer(&config.Config{ - Token: "dev-token", - RequestTimeout: time.Second, - }, sm)) - defer ts.Close() - - req, err := http.NewRequest( - http.MethodPost, - ts.URL+"/api/chat/commands", - strings.NewReader(`{"type":"chat.edit_resend","payload":{"message":"edited","conversation_id":"conversation-1","client_request_id":"client-edit-1","base_message_ref":{"segment_index":2,"message_index":4,"segment_id":"segment-c","message_id":"user-c","role":"user","content_hash":"fnv1a32:00000000"}}}`), - ) - if err != nil { - t.Fatal(err) - } - req.Header.Set("Authorization", "Bearer dev-token") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-LiveAgent-CSRF", "1") - resp, err := ts.Client().Do(req) - if err != nil { - t.Fatalf("post chat edit command: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - - var accepted struct { - RunID string `json:"run_id"` - } - if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { - t.Fatalf("decode accepted response: %v", err) - } - if accepted.RunID == "" { - t.Fatalf("accepted response missing run_id") - } - - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - command := outbound.GetChatCommand() - chatReq := command.GetRequest() - baseRef := command.GetBaseMessageRef() - if command.GetType() != "chat.edit_resend" || chatReq == nil || baseRef == nil { - t.Fatalf("outbound payload = %#v, want chat.edit_resend command", command) - } - if outbound.GetRequestId() != accepted.RunID || - chatReq.GetMessage() != "edited" || - chatReq.GetConversationId() != "conversation-1" || - chatReq.GetClientRequestId() != "client-edit-1" || - baseRef.GetSegmentIndex() != 2 || - baseRef.GetMessageIndex() != 4 || - baseRef.GetSegmentId() != "segment-c" || - baseRef.GetMessageId() != "user-c" || - baseRef.GetRole() != "user" || - baseRef.GetContentHash() != "fnv1a32:00000000" { - t.Fatalf("chat command id=%q command=%#v", outbound.GetRequestId(), command) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for chat edit command") - } - select { - case outbound := <-agentSession.Outbound(): - t.Fatalf("unexpected extra outbound after edit command %s payload %T", outbound.GetRequestId(), outbound.GetPayload()) - case <-time.After(100 * time.Millisecond): - } -} - diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 57aab902..ced53a09 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -112,22 +112,24 @@ type websocketConnection struct { lastPongAt time.Time lastPongMu sync.Mutex - historyEvents <-chan *gatewayv1.HistorySyncEvent - historyEventsCleanup func() - settingsEvents <-chan *gatewayv1.SettingsSyncEvent - settingsEventsCleanup func() - terminalEvents <-chan *gatewayv1.TerminalEvent - terminalEventsCleanup func() - sftpEvents <-chan *gatewayv1.SftpEvent - sftpEventsCleanup func() - chatQueueEvents <-chan *gatewayv1.ChatQueueEvent - chatQueueEventsCleanup func() - heartbeatOnce sync.Once + historyEvents <-chan *gatewayv1.HistorySyncEvent + historyEventsCleanup func() + settingsEvents <-chan *gatewayv1.SettingsSyncEvent + settingsEventsCleanup func() + terminalEvents <-chan *gatewayv1.TerminalEvent + terminalEventsCleanup func() + sftpEvents <-chan *gatewayv1.SftpEvent + sftpEventsCleanup func() + chatQueueEvents <-chan *gatewayv1.ChatQueueEvent + chatQueueEventsCleanup func() + chatActivityEvents <-chan session.ConversationActivityEvent + chatActivityEventsCleanup func() + heartbeatOnce sync.Once terminalInterest *websocketTerminalInterestTracker - chatSubsMu sync.Mutex - chatSubs map[string]*chatSubscription + chatStreamsMu sync.Mutex + chatStreams map[string]*chatStreamSubscription } const maxHistoryListLimit = 200 @@ -238,7 +240,11 @@ func (c *websocketConnection) close() { c.chatQueueEventsCleanup() c.chatQueueEventsCleanup = nil } - c.cleanupChatSubscriptions() + if c.chatActivityEventsCleanup != nil { + c.chatActivityEventsCleanup() + c.chatActivityEventsCleanup = nil + } + c.cleanupChatStreamSubscriptions() _ = c.conn.Close() }) } @@ -265,6 +271,7 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { c.startTerminalEventForwarder() c.startSftpEventForwarder() c.startChatQueueEventForwarder() + c.startChatActivityForwarder() c.startWebSocketHeartbeat() if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { c.close() @@ -291,26 +298,33 @@ func (c *websocketConnection) startHistorySyncForwarder() { if !ok { return } - payload := websocketHistorySyncPayload(event, c.sm.ActiveChatRunSummaries()...) - if payload["kind"] == "running" && payload["run_id"] == nil { - conversationID := historySyncPayloadConversationID(payload, event) - if conversationID != "" { - if summary, ok := c.sm.ConversationRunSummary(conversationID); ok { - enrichHistorySyncRunningPayload(payload, summary) - } - } + if err := c.writeEvent("history.event", websocketHistorySyncPayload(event)); err != nil { + return } - if payload["kind"] == "idle" && payload["run_id"] == nil { - conversationID := historySyncPayloadConversationID(payload, event) - if conversationID != "" { - if summary, ok := c.sm.ConversationRunSummary(conversationID); ok { - if rid := strings.TrimSpace(summary.RequestID); rid != "" { - payload["run_id"] = rid - } - } - } + } + } + }() +} + +func (c *websocketConnection) startChatActivityForwarder() { + if c.chatActivityEvents != nil || c.chatActivityEventsCleanup != nil { + return + } + + activityEvents, cleanup := c.sm.SubscribeChatActivity() + c.chatActivityEvents = activityEvents + c.chatActivityEventsCleanup = cleanup + + go func() { + for { + select { + case <-c.done: + return + case event, ok := <-activityEvents: + if !ok { + return } - if err := c.writeEvent("history.event", payload); err != nil { + if err := c.writeEvent("chat.activity", websocketChatActivityPayload(event)); err != nil { return } } diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go index 97475179..a0b9a0a5 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -3,7 +3,6 @@ package server import ( "context" "encoding/json" - "errors" "strings" "time" @@ -12,104 +11,92 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -type chatSubscription struct { - runID string +// chatStreamSubscription is one persistent per-conversation subscription on a +// websocket connection. It survives run boundaries and ends only on +// chat.unsubscribe, a replacing chat.subscribe, or connection close. +type chatStreamSubscription struct { conversationID string - sub *session.ChatRunSubscribeResult - cancel context.CancelFunc + cancel func() } func (c *websocketConnection) handleChatSubscribe(req websocketRequest) { var payload struct { - RunID string `json:"run_id"` ConversationID string `json:"conversation_id"` AfterSeq int64 `json:"after_seq"` + StreamEpoch string `json:"stream_epoch"` } if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { _ = c.writeError(req.ID, "invalid chat.subscribe payload") return } - payload.RunID = strings.TrimSpace(payload.RunID) - payload.ConversationID = strings.TrimSpace(payload.ConversationID) - if payload.RunID == "" && payload.ConversationID == "" { - _ = c.writeError(req.ID, "run_id or conversation_id is required") - return - } - - sub, err := c.sm.SubscribeChatRun(payload.RunID, payload.ConversationID, payload.AfterSeq) - if err != nil { - if errors.Is(err, session.ErrChatRunNotFound) { - _ = c.writeError(req.ID, "chat run not found") - } else { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - } + conversationID := strings.TrimSpace(payload.ConversationID) + if conversationID == "" { + _ = c.writeError(req.ID, "conversation_id is required") return } - subID := "chat-sub-" + uuid.NewString()[:8] - ctx, cancel := context.WithCancel(context.Background()) + sub := c.sm.SubscribeConversationStream(conversationID, payload.AfterSeq, payload.StreamEpoch) - c.chatSubsMu.Lock() - if c.chatSubs == nil { - c.chatSubs = make(map[string]*chatSubscription) + events := make([]map[string]any, 0, len(sub.Events)) + for _, event := range sub.Events { + events = append(events, event.Payload) } - c.chatSubs[subID] = &chatSubscription{ - runID: sub.Snapshot.RequestID, - conversationID: sub.Snapshot.ConversationID, - sub: sub, - cancel: cancel, + resp := map[string]any{ + "conversation_id": sub.ConversationID, + "stream_epoch": sub.StreamEpoch, + "latest_seq": sub.LatestSeq, + "reset": sub.Reset, + "activity": websocketRunActivityPayload(sub.Activity), + "snapshot": websocketRunSnapshotPayload(sub.Snapshot), + "events": events, } - c.chatSubsMu.Unlock() - resp := map[string]any{ - "subscription_id": subID, - "snapshot": map[string]any{ - "run_id": sub.Snapshot.RequestID, - "conversation_id": sub.Snapshot.ConversationID, - "state": sub.Snapshot.State, - "latest_seq": sub.Snapshot.LatestSeq, - }, + // Register (replacing any previous subscription for this conversation) + // before responding so no live event published after the replay boundary + // is dropped. + c.chatStreamsMu.Lock() + if c.chatStreams == nil { + c.chatStreams = make(map[string]*chatStreamSubscription) + } + if previous := c.chatStreams[conversationID]; previous != nil { + previous.cancel() } - if sub.GapDetected { - resp["gap"] = true - resp["oldest_buffered_seq"] = sub.OldestSeq + c.chatStreams[conversationID] = &chatStreamSubscription{ + conversationID: conversationID, + cancel: sub.Cleanup, } - _ = c.writeResponse(req.ID, resp) + c.chatStreamsMu.Unlock() - go c.forwardChatEvents(ctx, subID, sub) + if err := c.writeResponse(req.ID, resp); err != nil { + sub.Cleanup() + return + } + + go c.forwardConversationEvents(conversationID, sub) } -func (c *websocketConnection) forwardChatEvents(ctx context.Context, subID string, sub *session.ChatRunSubscribeResult) { +// forwardConversationEvents pushes live stream events to the client. When the +// subscriber channel closes because it overflowed, the client is told to +// re-subscribe (resume by after_seq replays the missed tail from the buffer). +func (c *websocketConnection) forwardConversationEvents( + conversationID string, + sub *session.ConversationSubscription, +) { defer sub.Cleanup() - defer func() { - _ = c.writeEvent("chat.subscription_end", map[string]any{ - "subscription_id": subID, - }) - }() for { select { - case <-ctx.Done(): - return case <-c.done: return - case <-sub.Done: - c.drainChatSubscriptionEvents(subID, sub) - return case event, ok := <-sub.EventCh: if !ok { + if sub.Overflowed() { + _ = c.writeEvent("chat.subscription_reset", map[string]any{ + "conversation_id": conversationID, + }) + } return } - payload, terminal := chatBroadcastPayload(event) - if payload == nil { - continue - } - payload["subscription_id"] = subID - payload["seq"] = event.Seq - payload["run_id"] = strings.TrimSpace(event.RequestID) - if err := c.writeEvent("chat.event", payload); err != nil { - return - } - if terminal { + if err := c.writeEvent("chat.event", event.Payload); err != nil { return } } @@ -118,24 +105,33 @@ func (c *websocketConnection) forwardChatEvents(ctx context.Context, subID strin func (c *websocketConnection) handleChatUnsubscribe(req websocketRequest) { var payload struct { - SubscriptionID string `json:"subscription_id"` + ConversationID string `json:"conversation_id"` } if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { _ = c.writeError(req.ID, "invalid chat.unsubscribe payload") return } - payload.SubscriptionID = strings.TrimSpace(payload.SubscriptionID) + conversationID := strings.TrimSpace(payload.ConversationID) - c.chatSubsMu.Lock() - if cs, ok := c.chatSubs[payload.SubscriptionID]; ok { - cs.cancel() - delete(c.chatSubs, payload.SubscriptionID) + c.chatStreamsMu.Lock() + if sub := c.chatStreams[conversationID]; sub != nil { + sub.cancel() + delete(c.chatStreams, conversationID) } - c.chatSubsMu.Unlock() + c.chatStreamsMu.Unlock() _ = c.writeResponse(req.ID, map[string]any{"ok": true}) } +func (c *websocketConnection) cleanupChatStreamSubscriptions() { + c.chatStreamsMu.Lock() + for conversationID, sub := range c.chatStreams { + sub.cancel() + delete(c.chatStreams, conversationID) + } + c.chatStreamsMu.Unlock() +} + func (c *websocketConnection) handleChatCommand(req websocketRequest) { commandType, body, baseMessageRef, err := decodeChatCommandPayload(req.Payload) if err != nil { @@ -173,33 +169,80 @@ func (c *websocketConnection) handleChatCommand(req websocketRequest) { return } - requestID := "chat-command-" + uuid.NewString() - initialPayloads := buildAcceptedChatCommandPayloads(body, baseMessageRef) - start, err := startAcceptedChatCommand(c.sm, requestID, body, initialPayloads) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } + runID := "chat-command-" + uuid.NewString() + updates, cleanupWatch := c.sm.WatchChatCommand(runID) + start := c.sm.StartChatCommand( + runID, + body.ConversationID, + body.Workdir, + body.ClientRequestID, + buildAcceptedChatCommandPayloads(body, baseMessageRef), + ) _ = c.writeResponse(req.ID, map[string]any{ "run_id": start.RunID, "conversation_id": start.ConversationID, - "state": start.State, "accepted_seq": start.AcceptedSeq, - "deduped": !start.Created, + "deduped": false, }) - if start.Created { - go dispatchAcceptedChatCommand(context.Background(), c.cfg, c.sm, start, body, baseMessageRef, newChatTraceID()) + go c.forwardChatCommandUpdates(updates) + go dispatchAcceptedChatCommand( + context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, newChatTraceID(), + ) +} + +// forwardChatCommandUpdates relays pre-stream command outcomes (bound / +// queued_in_gui / failed) to the connection that issued the command. The +// watch is closed by the command's startup watchdog. +func (c *websocketConnection) forwardChatCommandUpdates(updates <-chan session.ChatCommandUpdate) { + for { + select { + case <-c.done: + return + case update, ok := <-updates: + if !ok { + return + } + payload := map[string]any{ + "run_id": update.RunID, + "client_request_id": update.ClientRequestID, + "phase": update.Phase, + } + if update.ConversationID != "" { + payload["conversation_id"] = update.ConversationID + } + if update.ErrorCode != "" { + payload["error_code"] = update.ErrorCode + } + if update.Message != "" { + payload["message"] = update.Message + } + if err := c.writeEvent("chat.command_update", payload); err != nil { + return + } + } } } func (c *websocketConnection) handleChatCancel(req websocketRequest) { + raw := req.Payload + // chat.cancel arrives either directly or wrapped in a chat.command + // envelope ({type, payload}); unwrap the latter. + var wrapper struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(raw, &wrapper); err == nil && + strings.TrimSpace(wrapper.Type) == "chat.cancel" && len(wrapper.Payload) > 0 { + raw = wrapper.Payload + } + var payload struct { ConversationID string `json:"conversation_id"` RunID string `json:"run_id"` } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { + if err := decodeWebSocketPayload(raw, &payload); err != nil { _ = c.writeError(req.ID, "invalid chat.cancel payload") return } @@ -214,14 +257,14 @@ func (c *websocketConnection) handleChatCancel(req websocketRequest) { return } - runID := payload.RunID - if runID == "" { - if snapshot, ok := c.sm.RunningChatRunSnapshot(payload.ConversationID); ok { - runID = strings.TrimSpace(snapshot.RequestID) - } - } - if runID == "" { - _ = c.writeResponse(req.ID, map[string]any{"ok": true, "run_id": "", "conversation_id": payload.ConversationID}) + // The run is not terminalized here: the activity flips to "cancelling", + // the agent's real terminal signal wins, and a watchdog force-finishes if + // the agent never reports back. + runID, active := c.sm.MarkConversationCancelling(payload.ConversationID, payload.RunID) + if !active { + _ = c.writeResponse(req.ID, map[string]any{ + "ok": true, "run_id": "", "conversation_id": payload.ConversationID, + }) return } @@ -240,104 +283,17 @@ func (c *websocketConnection) handleChatCancel(req websocketRequest) { _ = c.writeError(req.ID, websocketErrorMessage(err)) return } - c.sm.MarkChatRunControl(runID, payload.ConversationID, "cancelled", "", "") - _ = c.writeResponse(req.ID, map[string]any{"ok": true, "run_id": runID, "conversation_id": payload.ConversationID}) -} - -func (c *websocketConnection) handleChatReplay(req websocketRequest) { - var payload struct { - RunID string `json:"run_id"` - ConversationID string `json:"conversation_id"` - AfterSeq int64 `json:"after_seq"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid chat.replay payload") - return - } - payload.RunID = strings.TrimSpace(payload.RunID) - payload.ConversationID = strings.TrimSpace(payload.ConversationID) - if payload.ConversationID == "" { - _ = c.writeError(req.ID, "conversation_id is required") - return - } - - if !c.sm.IsOnline() { - _ = c.writeError(req.ID, "agent offline") - return - } - - replayRequestID := "chat-replay-" + uuid.NewString()[:8] - timeout := 30 * time.Second - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - env, err := awaitAgentUnaryResponse(ctx, c.sm, replayRequestID, &gatewayv1.GatewayEnvelope{ - RequestId: replayRequestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_ChatEventReplay{ - ChatEventReplay: &gatewayv1.ChatEventReplayRequest{ - RunId: payload.RunID, - ConversationId: payload.ConversationID, - AfterSeq: payload.AfterSeq, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - replayResp := env.GetChatEventReplayResp() - if replayResp == nil { - _ = c.writeError(req.ID, "unexpected replay response") - return - } - - events := make([]map[string]any, 0, len(replayResp.GetEvents())) - for _, re := range replayResp.GetEvents() { - var parsed map[string]any - if err := json.Unmarshal([]byte(re.GetEventJson()), &parsed); err != nil { - continue - } - parsed["seq"] = re.GetSeq() - events = append(events, parsed) - } + go watchChatCancel(c.sm, runID) _ = c.writeResponse(req.ID, map[string]any{ - "run_id": replayResp.GetRunId(), - "conversation_id": replayResp.GetConversationId(), - "events": events, - "complete": replayResp.GetComplete(), + "ok": true, "run_id": runID, "conversation_id": payload.ConversationID, }) } -func (c *websocketConnection) drainChatSubscriptionEvents(subID string, sub *session.ChatRunSubscribeResult) { - for { - select { - case event, ok := <-sub.EventCh: - if !ok { - return - } - payload, _ := chatBroadcastPayload(event) - if payload == nil { - continue - } - payload["subscription_id"] = subID - payload["seq"] = event.Seq - payload["run_id"] = strings.TrimSpace(event.RequestID) - _ = c.writeEvent("chat.event", payload) - default: - return - } - } -} +const chatCancelWatchdogTimeout = 15 * time.Second -func (c *websocketConnection) cleanupChatSubscriptions() { - c.chatSubsMu.Lock() - for id, cs := range c.chatSubs { - cs.cancel() - delete(c.chatSubs, id) - } - c.chatSubsMu.Unlock() +func watchChatCancel(sm *session.Manager, runID string) { + time.Sleep(chatCancelWatchdogTimeout) + sm.ForceFinishRun(runID, "cancelled", "cancel_timeout", + "The desktop runtime did not confirm the cancellation in time.") } - diff --git a/crates/agent-gateway/internal/server/websocket_history_handlers.go b/crates/agent-gateway/internal/server/websocket_history_handlers.go index 04dbdb1f..a2694592 100644 --- a/crates/agent-gateway/internal/server/websocket_history_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_history_handlers.go @@ -64,10 +64,22 @@ func (c *websocketConnection) handleHistoryList(req websocketRequest) { conversations = append(conversations, websocketConversationSummaryPayload(conversation)) } + activities := c.sm.ActiveConversationActivities() + runningConversations := make([]map[string]any, 0, len(activities)) + for _, activity := range activities { + runningConversations = append(runningConversations, map[string]any{ + "conversation_id": activity.ConversationID, + "run_id": activity.RunID, + "state": activity.State, + "cwd": activity.Workdir, + "updated_at": activity.UpdatedAt.UnixMilli(), + }) + } + _ = c.writeResponse(req.ID, map[string]any{ "conversations": conversations, "total_count": resp.GetTotalCount(), - "running_conversations": websocketActiveChatRunSummariesPayload(c.sm.ActiveChatRunSummaries()), + "running_conversations": runningConversations, }) } diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go index cc01d664..8a7efdce 100644 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ b/crates/agent-gateway/internal/server/websocket_payload_test.go @@ -2,92 +2,79 @@ package server import ( "testing" + "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/session" ) -func TestActiveChatRunSummaryPayloadIncludesReplayCursor(t *testing.T) { - payload := websocketActiveChatRunSummariesPayload([]session.ActiveChatRunSummary{ - { - ConversationID: "conversation-1", - RequestID: "run-1", - Workdir: "/workspace", - FirstSeq: 4, - LatestSeq: 9, - RunEpoch: 2, - UpdatedAt: 123, - }, +func TestWebsocketChatActivityPayloadCarriesRunIdentity(t *testing.T) { + updatedAt := time.UnixMilli(123456) + payload := websocketChatActivityPayload(session.ConversationActivityEvent{ + ConversationID: "conversation-1", + RunID: "run-1", + Running: true, + State: "running", + Workdir: "/workspace", + UpdatedAt: updatedAt, }) - if len(payload) != 1 { - t.Fatalf("payload len = %d, want 1", len(payload)) + if payload["conversation_id"] != "conversation-1" || + payload["run_id"] != "run-1" || + payload["running"] != true || + payload["state"] != "running" || + payload["workdir"] != "/workspace" || + payload["updated_at"] != int64(123456) { + t.Fatalf("chat activity payload = %#v", payload) } - item := payload[0] - if item["run_id"] != "run-1" || - item["first_seq"] != int64(4) || - item["latest_seq"] != int64(9) || - item["run_epoch"] != int64(2) { - t.Fatalf("active run payload = %#v", item) - } -} -func TestHistoryRunningPayloadIncludesReplayCursor(t *testing.T) { - payload := websocketHistorySyncPayload(&gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, session.ActiveChatRunSummary{ + idle := websocketChatActivityPayload(session.ConversationActivityEvent{ ConversationID: "conversation-1", - RequestID: "run-1", - FirstSeq: 2, - LatestSeq: 1, - RunEpoch: 5, - UpdatedAt: 123, + Running: false, + UpdatedAt: updatedAt, }) - - if payload["run_id"] != "run-1" || - payload["first_seq"] != int64(2) || - payload["latest_seq"] != int64(1) || - payload["run_epoch"] != int64(5) || - payload["updated_at"] != int64(123) { - t.Fatalf("history running payload = %#v", payload) + if idle["running"] != false { + t.Fatalf("idle activity payload = %#v", idle) } -} - -func TestHistoryRunningPayloadFallbackEnrichment(t *testing.T) { - event := &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", + if _, hasRunID := idle["run_id"]; hasRunID { + t.Fatalf("idle activity should omit empty run_id: %#v", idle) } - payload := websocketHistorySyncPayload(event) +} - if payload["run_id"] != nil { - t.Fatalf("expected no run_id before enrichment, got %#v", payload["run_id"]) +func TestWebsocketRunActivityAndSnapshotPayloads(t *testing.T) { + updatedAt := time.UnixMilli(7890) + activity := websocketRunActivityPayload(&session.RunActivity{ + RunID: "run-1", + ClientRequestID: "client-1", + State: "running", + ToolStatus: "Vibing", + ToolStatusIsCompaction: false, + StartedSeq: 17, + UpdatedAt: updatedAt, + }) + if activity["run_id"] != "run-1" || + activity["state"] != "running" || + activity["started_seq"] != int64(17) || + activity["tool_status"] != "Vibing" || + activity["client_request_id"] != "client-1" { + t.Fatalf("run activity payload = %#v", activity) } - - cid := historySyncPayloadConversationID(payload, event) - if cid != "conversation-1" { - t.Fatalf("expected conversation_id conversation-1, got %q", cid) + if payload := websocketRunActivityPayload(nil); payload != nil { + t.Fatalf("nil activity payload = %#v, want nil", payload) } - enrichHistorySyncRunningPayload(payload, session.ActiveChatRunSummary{ - ConversationID: "conversation-1", - RequestID: "run-fallback", - FirstSeq: 10, - LatestSeq: 15, - RunEpoch: 3, - UpdatedAt: 456, + snapshot := websocketRunSnapshotPayload(&session.RunSnapshot{ + RunID: "run-1", + Revision: 3, + EntriesJSON: `[{"kind":"assistant"}]`, + ToolStatus: "Compacting", }) - - if payload["run_id"] != "run-fallback" || - payload["first_seq"] != int64(10) || - payload["latest_seq"] != int64(15) || - payload["run_epoch"] != int64(3) || - payload["updated_at"] != int64(456) { - t.Fatalf("fallback enriched payload = %#v", payload) + if snapshot["run_id"] != "run-1" || + snapshot["revision"] != int64(3) || + snapshot["entries_json"] != `[{"kind":"assistant"}]` { + t.Fatalf("run snapshot payload = %#v", snapshot) + } + if payload := websocketRunSnapshotPayload(nil); payload != nil { + t.Fatalf("nil snapshot payload = %#v, want nil", payload) } } diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index fa16c5c3..340d999c 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -105,112 +105,69 @@ func websocketConversationSummaryPayload(conversation *gatewayv1.ConversationSum return websocketProtoPayload(conversation, true) } -func websocketActiveChatRunSummariesPayload(summaries []session.ActiveChatRunSummary) []map[string]any { - payload := make([]map[string]any, 0, len(summaries)) - for _, summary := range summaries { - conversationID := strings.TrimSpace(summary.ConversationID) - if conversationID == "" { - continue - } - item := map[string]any{ - "conversation_id": conversationID, - "cwd": strings.TrimSpace(summary.Workdir), - "updated_at": summary.UpdatedAt, - } - if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { - item["run_id"] = requestID - } - if summary.FirstSeq > 0 { - item["first_seq"] = summary.FirstSeq - } - if summary.LatestSeq > 0 { - item["latest_seq"] = summary.LatestSeq - } - if summary.RunEpoch > 0 { - item["run_epoch"] = summary.RunEpoch - } - payload = append(payload, item) - } - return payload -} - func websocketHistoryShareStatusPayload(share *gatewayv1.HistoryShareStatus) map[string]any { return websocketProtoPayload(share, true) } -func websocketHistorySyncPayload( - event *gatewayv1.HistorySyncEvent, - activeRuns ...session.ActiveChatRunSummary, -) map[string]any { +func websocketHistorySyncPayload(event *gatewayv1.HistorySyncEvent) map[string]any { payload := map[string]any{ "kind": strings.TrimSpace(event.GetKind()), "conversation_id": strings.TrimSpace(event.GetConversationId()), } - if conversation := event.GetConversation(); conversation != nil { payload["conversation"] = websocketConversationSummaryPayload(conversation) } - if payload["kind"] == "running" { - conversationID := strings.TrimSpace(event.GetConversationId()) - if conversationID == "" && event.GetConversation() != nil { - conversationID = strings.TrimSpace(event.GetConversation().GetId()) - } - for _, summary := range activeRuns { - if strings.TrimSpace(summary.ConversationID) != conversationID { - continue - } - if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { - payload["run_id"] = requestID - } - if summary.FirstSeq > 0 { - payload["first_seq"] = summary.FirstSeq - } - if summary.LatestSeq > 0 { - payload["latest_seq"] = summary.LatestSeq - } - if summary.RunEpoch > 0 { - payload["run_epoch"] = summary.RunEpoch - } - if summary.UpdatedAt > 0 { - payload["updated_at"] = summary.UpdatedAt - } - break - } - } - return payload } -func historySyncPayloadConversationID(payload map[string]any, event *gatewayv1.HistorySyncEvent) string { - if cid, ok := payload["conversation_id"].(string); ok && cid != "" { - return cid +func websocketChatActivityPayload(event session.ConversationActivityEvent) map[string]any { + payload := map[string]any{ + "conversation_id": event.ConversationID, + "running": event.Running, + "updated_at": event.UpdatedAt.UnixMilli(), } - if event != nil { - if cid := strings.TrimSpace(event.GetConversationId()); cid != "" { - return cid - } - if event.GetConversation() != nil { - return strings.TrimSpace(event.GetConversation().GetId()) - } + if event.RunID != "" { + payload["run_id"] = event.RunID + } + if event.State != "" { + payload["state"] = event.State } - return "" + if event.Workdir != "" { + payload["workdir"] = event.Workdir + } + return payload } -func enrichHistorySyncRunningPayload(payload map[string]any, summary session.ActiveChatRunSummary) { - if requestID := strings.TrimSpace(summary.RequestID); requestID != "" { - payload["run_id"] = requestID +func websocketRunActivityPayload(activity *session.RunActivity) map[string]any { + if activity == nil { + return nil } - if summary.FirstSeq > 0 { - payload["first_seq"] = summary.FirstSeq + payload := map[string]any{ + "run_id": activity.RunID, + "state": activity.State, + "started_seq": activity.StartedSeq, + "updated_at": activity.UpdatedAt.UnixMilli(), } - if summary.LatestSeq > 0 { - payload["latest_seq"] = summary.LatestSeq + if activity.ToolStatus != "" { + payload["tool_status"] = activity.ToolStatus + payload["tool_status_is_compaction"] = activity.ToolStatusIsCompaction } - if summary.RunEpoch > 0 { - payload["run_epoch"] = summary.RunEpoch + if activity.ClientRequestID != "" { + payload["client_request_id"] = activity.ClientRequestID } - if summary.UpdatedAt > 0 { - payload["updated_at"] = summary.UpdatedAt + return payload +} + +func websocketRunSnapshotPayload(snapshot *session.RunSnapshot) map[string]any { + if snapshot == nil { + return nil + } + return map[string]any{ + "run_id": snapshot.RunID, + "revision": snapshot.Revision, + "entries_json": snapshot.EntriesJSON, + "tool_status": snapshot.ToolStatus, + "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, } } diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go index d3c273ca..fb790b1c 100644 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ b/crates/agent-gateway/internal/server/websocket_roundtrip.go @@ -73,9 +73,6 @@ func websocketErrorMessage(err error) string { if errors.Is(err, session.ErrAgentOffline) { return "agent offline" } - if errors.Is(err, session.ErrChatRunNotFound) { - return "chat stream not available" - } return err.Error() } diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 1f2a961b..402cc5e2 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -90,7 +90,6 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "chat.unsubscribe": (*websocketConnection).handleChatUnsubscribe, "chat.command": (*websocketConnection).handleChatCommand, "chat.cancel": (*websocketConnection).handleChatCancel, - "chat.replay": (*websocketConnection).handleChatReplay, "chat_queue.get": (*websocketConnection).handleChatQueueRequest, "chat_queue.get_item": (*websocketConnection).handleChatQueueRequest, "chat_queue.run_now": (*websocketConnection).handleChatQueueRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index e2b62604..28ee9abd 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -94,7 +94,6 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "chat.unsubscribe", "chat.command", "chat.cancel", - "chat.replay", "chat_queue.get", "chat_queue.get_item", "chat_queue.run_now", diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index ef707d0b..ae9c02dc 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -9,7 +9,6 @@ import ( ) var ErrAgentOffline = errors.New("agent offline") -var ErrChatRunNotFound = errors.New("chat run not found") var ErrTunnelNotFound = errors.New("tunnel not found") var ErrTunnelExpired = errors.New("tunnel expired") var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") @@ -18,10 +17,6 @@ var ErrCommandQueueFull = errors.New("command queue full") var ErrCommandQueueTimeout = errors.New("command queue timeout: agent did not reconnect") const ( - defaultRelayBufferRetention = 30 * time.Second - chatRunDoneRetention = 5 * time.Minute - chatRunStaleRetention = 30 * time.Minute - chatRuntimeReadyTTL = 15 * time.Second agentSessionHeartbeatTTL = 90 * time.Second defaultRuntimeReadyState = "ready" @@ -36,7 +31,6 @@ type AuthSnapshot struct { type Manager struct { registry *sessionRegistry syncHub *syncHub - chatStore *chatRunStore convStreams *conversationStreamStore tunnels *tunnelStore cmdQueue *commandQueue @@ -65,71 +59,6 @@ type agentStream struct { closeOnce sync.Once } -type ChatBroadcastEvent struct { - RequestID string - Event *gatewayv1.ChatEvent - Control *gatewayv1.ChatControlEvent - Payload map[string]any - Seq int64 - Workdir string - ReceivedAt time.Time -} - -type ChatRunSnapshot struct { - RequestID string - ConversationID string - Workdir string - FirstSeq int64 - LatestSeq int64 - RunEpoch int64 - State string - Done bool -} - -type ActiveChatRunSummary struct { - ConversationID string - RequestID string - Workdir string - FirstSeq int64 - LatestSeq int64 - RunEpoch int64 - UpdatedAt int64 -} - -const ( - ChatRunStateQueued = "queued" - ChatRunStateDelivered = "delivered" - ChatRunStateClaimed = "claimed" - ChatRunStateStarting = "starting" - ChatRunStateDesktopQueued = "desktop_queued" - ChatRunStateRunning = "running" - ChatRunStateCompleted = "completed" - ChatRunStateFailed = "failed" - ChatRunStateCancelled = "cancelled" -) - -type chatRun struct { - requestID string - conversationID string - workdir string - runEpoch int64 - events []*ChatBroadcastEvent - nextSeq int64 - state string - done bool - updatedAt time.Time - expiresAt time.Time - subscribers map[int]*chatRunSubscriber - - relayBufferRetention time.Duration -} - -type chatRunSubscriber struct { - ch chan *ChatBroadcastEvent - done chan struct{} - closeOnce sync.Once -} - type Status struct { Online bool `json:"online"` AgentReady bool `json:"agent_ready"` @@ -148,11 +77,10 @@ type Status struct { func NewManager() *Manager { m := &Manager{ - registry: newSessionRegistry(), - syncHub: newSyncHub(), - chatStore: newChatRunStore(), - tunnels: newTunnelStore(), - cmdQueue: newCommandQueue(defaultCommandQueueTimeout), + registry: newSessionRegistry(), + syncHub: newSyncHub(), + tunnels: newTunnelStore(), + cmdQueue: newCommandQueue(defaultCommandQueueTimeout), } m.convStreams = newConversationStreamStore(m.IsOnline) return m diff --git a/crates/agent-gateway/internal/session/manager_chat_runs.go b/crates/agent-gateway/internal/session/manager_chat_runs.go deleted file mode 100644 index 134000ae..00000000 --- a/crates/agent-gateway/internal/session/manager_chat_runs.go +++ /dev/null @@ -1,1333 +0,0 @@ -package session - -import ( - "encoding/json" - "log" - "sort" - "strings" - "sync" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (m *Manager) StartPendingChatCommandRun( - requestID string, - conversationID string, - workdirInput ...string, -) (ChatRunSnapshot, bool, error) { - workdir := "" - if len(workdirInput) > 0 { - workdir = workdirInput[0] - } - snapshot, created := m.createChatRun(requestID, conversationID, workdir) - if !created { - return snapshot, false, nil - } - return snapshot, true, nil -} - -func (m *Manager) StartAcceptedChatCommandRun( - requestID string, - conversationID string, - workdir string, - initialPayloads []map[string]any, -) (ChatRunSnapshot, bool, int64, error) { - m.chatStore.chatCommandMu.Lock() - defer m.chatStore.chatCommandMu.Unlock() - - snapshot, created := m.createChatRun(requestID, conversationID, workdir) - if !created { - return snapshot, false, snapshot.LatestSeq, nil - } - - m.MarkChatRunControl(snapshot.RequestID, conversationID, "accepted", "", "") - acceptedSeq := snapshot.LatestSeq - if acceptedSnapshot, ok := m.ChatRunSnapshot(snapshot.RequestID, conversationID); ok { - snapshot = acceptedSnapshot - acceptedSeq = acceptedSnapshot.LatestSeq - } - if len(initialPayloads) > 0 { - m.MarkChatRunPayloads(snapshot.RequestID, conversationID, initialPayloads) - if nextSnapshot, ok := m.ChatRunSnapshot(snapshot.RequestID, conversationID); ok { - snapshot = nextSnapshot - } - } - return snapshot, true, acceptedSeq, nil -} - -func (m *Manager) createChatRun( - requestID string, - conversationID string, - workdir string, -) (ChatRunSnapshot, bool) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return ChatRunSnapshot{}, false - } - conversationID = strings.TrimSpace(conversationID) - workdir = strings.TrimSpace(workdir) - now := time.Now() - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(now) - - if existing := m.chatStore.chatRuns[requestID]; existing != nil { - return existing.snapshot(), false - } - - m.chatStore.nextChatRunEpoch++ - latestSeq := m.latestConversationSeqLocked(conversationID) - run := &chatRun{ - requestID: requestID, - conversationID: conversationID, - workdir: workdir, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - relayBufferRetention: m.chatStore.relayBufferRetention, - } - m.chatStore.chatRuns[requestID] = run - if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - return run.snapshot(), true -} - -func (m *Manager) latestConversationSeqLocked(conversationID string) int64 { - if conversationID == "" { - return 0 - } - var latestSeq int64 - for _, run := range m.chatStore.chatRuns { - if run == nil || run.conversationID != conversationID { - continue - } - if run.nextSeq > latestSeq { - latestSeq = run.nextSeq - } - } - return latestSeq -} - -func (m *Manager) chatRunCanClaimConversationLocked(conversationID string, requestID string) bool { - if conversationID == "" || requestID == "" { - return false - } - currentRequestID := m.chatStore.chatRunByConversation[conversationID] - if currentRequestID == "" || currentRequestID == requestID { - return true - } - currentRun := m.chatStore.chatRuns[currentRequestID] - return currentRun == nil || currentRun.done -} - -func chatRunControlCanClaimConversation(controlType string, state string) bool { - if normalizeChatRunState(state) == ChatRunStateRunning { - return true - } - return controlType == "started" -} - -func (m *Manager) RemoveChatRun(requestID string) { - requestID = strings.TrimSpace(requestID) - if requestID == "" { - return - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - run := m.chatStore.chatRuns[requestID] - if run == nil { - return - } - m.removeChatRunLocked(requestID, run) -} - -func (m *Manager) RemoveChatRunByConversation(conversationID string) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - requestID := m.chatStore.chatRunByConversation[conversationID] - run := m.chatStore.chatRuns[requestID] - if run == nil { - for candidateRequestID, candidateRun := range m.chatStore.chatRuns { - if candidateRun.conversationID == conversationID { - requestID = candidateRequestID - run = candidateRun - break - } - } - } - if run == nil { - return - } - m.removeChatRunLocked(requestID, run) -} - -func (m *Manager) ActiveChatRunSummaries() []ActiveChatRunSummary { - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - now := time.Now() - m.pruneExpiredChatRunsLocked(now) - - seen := make(map[string]int, len(m.chatStore.chatRuns)) - summaries := make([]ActiveChatRunSummary, 0, len(m.chatStore.chatRuns)) - for _, run := range m.chatStore.chatRuns { - if run == nil || run.done || !activeChatRunStates[run.state] { - continue - } - conversationID := run.conversationID - if conversationID == "" { - continue - } - firstSeq := run.snapshot().FirstSeq - if firstSeq <= 0 { - firstSeq = run.nextSeq + 1 - } - summary := ActiveChatRunSummary{ - ConversationID: conversationID, - RequestID: run.requestID, - Workdir: run.workdir, - FirstSeq: firstSeq, - LatestSeq: run.nextSeq, - RunEpoch: run.runEpoch, - UpdatedAt: run.updatedAt.UnixMilli(), - } - if index, ok := seen[conversationID]; ok { - if summaries[index].Workdir == "" { - summaries[index].Workdir = summary.Workdir - } - currentOwner := m.chatStore.chatRunByConversation[conversationID] - if shouldReplaceActiveChatRunSummary(summary, summaries[index], currentOwner) { - summaries[index] = summary - } - continue - } - seen[conversationID] = len(summaries) - summaries = append(summaries, summary) - } - return summaries -} - -func shouldReplaceActiveChatRunSummary(candidate ActiveChatRunSummary, current ActiveChatRunSummary, currentOwner string) bool { - candidateIsOwner := currentOwner != "" && candidate.RequestID == currentOwner - currentIsOwner := currentOwner != "" && current.RequestID == currentOwner - if candidateIsOwner != currentIsOwner { - return candidateIsOwner - } - return candidate.UpdatedAt > current.UpdatedAt -} - -func (m *Manager) ConversationRunSummary(conversationID string) (ActiveChatRunSummary, bool) { - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return ActiveChatRunSummary{}, false - } - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - requestID := m.chatStore.chatRunByConversation[conversationID] - if requestID == "" { - return ActiveChatRunSummary{}, false - } - run := m.chatStore.chatRuns[requestID] - if run == nil { - return ActiveChatRunSummary{}, false - } - firstSeq := run.snapshot().FirstSeq - if firstSeq <= 0 { - firstSeq = run.nextSeq + 1 - } - return ActiveChatRunSummary{ - ConversationID: conversationID, - RequestID: run.requestID, - Workdir: run.workdir, - FirstSeq: firstSeq, - LatestSeq: run.nextSeq, - RunEpoch: run.runEpoch, - UpdatedAt: run.updatedAt.UnixMilli(), - }, true -} - -func (m *Manager) FailStartingChatRun(requestID string, message string) bool { - return m.failChatRunIf( - requestID, - message, - "Desktop backend did not accept the remote chat request. Please retry.", - func(run *chatRun) bool { - return run != nil && !run.done && normalizeChatRunState(run.state) == ChatRunStateQueued - }, - ) -} - -func (m *Manager) FailUnstartedChatRun(requestID string, message string) bool { - return m.failChatRunIf( - requestID, - message, - "Desktop app accepted the remote chat request but did not start it. Please retry.", - func(run *chatRun) bool { - if run == nil || run.done { - return false - } - state := normalizeChatRunState(run.state) - return state != ChatRunStateQueued && - state != ChatRunStateDesktopQueued && - state != ChatRunStateRunning && - !isTerminalChatRunState(state) - }, - ) -} - -func (m *Manager) failChatRunIf( - requestID string, - message string, - defaultMessage string, - shouldFail func(*chatRun) bool, -) bool { - requestID = strings.TrimSpace(requestID) - message = strings.TrimSpace(message) - if requestID == "" { - return false - } - if message == "" { - message = defaultMessage - } - - data, err := json.Marshal(map[string]string{"message": message}) - if err != nil { - fallback, marshalErr := json.Marshal(map[string]string{"message": defaultMessage}) - if marshalErr != nil { - fallback = []byte(`{"message":"Remote chat request failed. Please retry."}`) - } - data = fallback - } - - now := time.Now() - var broadcast *ChatBroadcastEvent - var runSubscribers []*chatRunSubscriber - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if shouldFail == nil || !shouldFail(run) { - m.chatStore.chatMu.Unlock() - return false - } - - run.nextSeq++ - run.updatedAt = now - run.applyState(ChatRunStateFailed) - run.expiresAt = now.Add(chatRunDoneRetention) - chatEvent := &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_ERROR, - ConversationId: run.conversationID, - Data: string(data), - } - broadcast = &ChatBroadcastEvent{ - RequestID: requestID, - Event: chatEvent, - Seq: run.nextSeq, - Workdir: run.workdir, - ReceivedAt: now, - } - run.appendEvent(broadcast) - runSubscribers = run.collectSubscribers() - m.chatStore.chatMu.Unlock() - - notifySubscribers(runSubscribers, broadcast) - return true -} - -type ChatRunSubscribeResult struct { - EventCh <-chan *ChatBroadcastEvent - Done <-chan struct{} - Cleanup func() - Snapshot ChatRunSnapshot - GapDetected bool - OldestSeq int64 -} - -func (m *Manager) SubscribeChatRun( - requestID string, - conversationID string, - afterSeq int64, -) (*ChatRunSubscribeResult, error) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if afterSeq < 0 { - afterSeq = 0 - } - conversationReplayRequested := requestID == "" && conversationID != "" - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - now := time.Now() - m.pruneExpiredChatRunsLocked(now) - - if conversationReplayRequested && conversationID != "" { - if liveRequestID := m.chatStore.chatRunByConversation[conversationID]; liveRequestID != "" { - requestID = liveRequestID - } - } else if requestID == "" && conversationID != "" { - requestID = m.chatStore.chatRunByConversation[conversationID] - } - run := m.chatStore.chatRuns[requestID] - if run == nil { - done := make(chan struct{}) - close(done) - return &ChatRunSubscribeResult{ - Done: done, - Cleanup: func() {}, - }, ErrChatRunNotFound - } - - var replay []*ChatBroadcastEvent - gapDetected := false - var oldestSeq int64 - - if conversationReplayRequested && conversationID != "" { - replay = m.collectConversationEventsLocked(conversationID, afterSeq) - } else { - replay = collectBufferedEventsAfterSeq(run, afterSeq) - } - - if afterSeq > 0 && len(run.events) > 0 { - oldestSeq = run.events[0].Seq - if afterSeq < oldestSeq { - gapDetected = true - } - } - - bufferSize := len(replay) + 128 - ch := make(chan *ChatBroadcastEvent, bufferSize) - done := make(chan struct{}) - for _, event := range replay { - ch <- event - } - - subID := -1 - var subscriber *chatRunSubscriber - doneClosed := false - if !run.done { - subID = m.chatStore.nextChatRunSubID - m.chatStore.nextChatRunSubID++ - subscriber = &chatRunSubscriber{ - ch: ch, - done: done, - } - run.subscribers[subID] = subscriber - } else if len(replay) == 0 { - close(done) - doneClosed = true - } - - var cleanupOnce sync.Once - cleanup := func() { - cleanupOnce.Do(func() { - m.chatStore.chatMu.Lock() - if subID >= 0 { - if current := m.chatStore.chatRuns[requestID]; current != nil { - delete(current.subscribers, subID) - } - } - m.chatStore.chatMu.Unlock() - if subscriber != nil { - subscriber.close() - } else if !doneClosed { - close(done) - } - }) - } - - return &ChatRunSubscribeResult{ - EventCh: ch, - Done: done, - Cleanup: cleanup, - Snapshot: run.snapshot(), - GapDetected: gapDetected, - OldestSeq: oldestSeq, - }, nil -} - -func collectBufferedEventsAfterSeq(run *chatRun, afterSeq int64) []*ChatBroadcastEvent { - if run == nil { - return nil - } - replay := make([]*ChatBroadcastEvent, 0, len(run.events)) - for _, event := range run.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - return replay -} - -func (m *Manager) collectConversationEventsLocked(conversationID string, afterSeq int64) []*ChatBroadcastEvent { - var replay []*ChatBroadcastEvent - for _, run := range m.chatStore.chatRuns { - if run == nil || run.conversationID != conversationID { - continue - } - for _, event := range run.events { - if event.Seq > afterSeq { - replay = append(replay, cloneChatBroadcastEvent(event)) - } - } - } - // Runs live in a map, so multi-run conversations (e.g. a queued prompt - // auto-sent right after the previous run) would otherwise replay in - // arbitrary run order. - sort.SliceStable(replay, func(i, j int) bool { - return replay[i].Seq < replay[j].Seq - }) - return replay -} - -func (m *Manager) ChatRunSnapshot( - requestID string, - conversationID string, -) (ChatRunSnapshot, bool) { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(time.Now()) - - if requestID == "" && conversationID != "" { - requestID = m.chatStore.chatRunByConversation[conversationID] - } - run := m.chatStore.chatRuns[requestID] - if run == nil { - return ChatRunSnapshot{}, false - } - return run.snapshot(), true -} - -func (m *Manager) RunningChatRunSnapshot(conversationID string) (ChatRunSnapshot, bool) { - if conversationID == "" { - return ChatRunSnapshot{}, false - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - m.pruneExpiredChatRunsLocked(time.Now()) - - if requestID := m.chatStore.chatRunByConversation[conversationID]; requestID != "" { - if run := m.chatStore.chatRuns[requestID]; chatRunIsRunningForConversation(run, conversationID) { - return run.snapshot(), true - } - } - - var best *chatRun - for _, run := range m.chatStore.chatRuns { - if !chatRunIsRunningForConversation(run, conversationID) { - continue - } - if best == nil || run.updatedAt.After(best.updatedAt) { - best = run - } - } - if best == nil { - return ChatRunSnapshot{}, false - } - return best.snapshot(), true -} - -func chatRunIsRunningForConversation(run *chatRun, conversationID string) bool { - return run != nil && - !run.done && - run.conversationID == conversationID && - normalizeChatRunState(run.state) == ChatRunStateRunning -} - -func (m *Manager) MarkChatRunControl( - requestID string, - conversationID string, - controlType string, - errorCode string, - message string, -) { - m.markChatRunControl( - strings.TrimSpace(requestID), - strings.TrimSpace(conversationID), - strings.TrimSpace(controlType), - "", - strings.TrimSpace(errorCode), - strings.TrimSpace(message), - time.Now(), - ) -} - -func (m *Manager) MarkChatRunPayload( - requestID string, - conversationID string, - payload map[string]any, -) int64 { - seqs := m.MarkChatRunPayloads(requestID, conversationID, []map[string]any{payload}) - if len(seqs) == 0 { - return 0 - } - return seqs[0] -} - -func (m *Manager) MarkChatRunPayloads( - requestID string, - conversationID string, - payloads []map[string]any, -) []int64 { - requestID = strings.TrimSpace(requestID) - conversationID = strings.TrimSpace(conversationID) - if requestID == "" || len(payloads) == 0 { - return nil - } - - now := time.Now() - broadcasts := make([]*ChatBroadcastEvent, 0, len(payloads)) - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil || run.done { - m.chatStore.chatMu.Unlock() - if run == nil { - log.Printf("MarkChatRunPayloads: no run for requestID=%s", requestID) - } - return nil - } - m.updateRunConversationLocked(run, requestID, conversationID, false) - for _, payload := range payloads { - broadcast := m.appendChatPayloadLocked(run, payload, now) - if broadcast != nil { - broadcasts = append(broadcasts, broadcast) - } - } - runSubscribers := run.collectSubscribers() - m.chatStore.chatMu.Unlock() - - if len(broadcasts) == 0 { - return nil - } - for _, broadcast := range broadcasts { - notifySubscribers(runSubscribers, broadcast) - } - seqs := make([]int64, 0, len(broadcasts)) - for _, broadcast := range broadcasts { - seqs = append(seqs, broadcast.Seq) - } - return seqs -} - -func (m *Manager) ApplyChatRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapshot) { - if snapshot == nil { - return - } - requestID := strings.TrimSpace(snapshot.GetRunId()) - conversationID := strings.TrimSpace(snapshot.GetConversationId()) - if requestID == "" || conversationID == "" { - return - } - state := normalizeChatRunState(snapshot.GetState()) - if state == "" { - state = ChatRunStateRunning - } - now := chatRuntimeSnapshotTime(snapshot.GetUpdatedAt()) - workdir := strings.TrimSpace(snapshot.GetCwd()) - - payload := map[string]any{ - "type": "runtime_snapshot", - "conversation_id": conversationID, - "run_id": requestID, - "state": state, - "updated_at": now.UnixMilli(), - "revision": snapshot.GetRevision(), - "entries_json": strings.TrimSpace(snapshot.GetEntriesJson()), - "tool_status": strings.TrimSpace(snapshot.GetToolStatus()), - "tool_status_is_compaction": snapshot.GetToolStatusIsCompaction(), - } - if workerID := strings.TrimSpace(snapshot.GetWorkerId()); workerID != "" { - payload["worker_id"] = workerID - } - - var broadcast *ChatBroadcastEvent - var runSubscribers []*chatRunSubscriber - var activityKind string - var activityConversationID string - var activityWorkdir string - terminalState := isTerminalChatRunState(state) - - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - created := false - if run == nil { - m.chatStore.nextChatRunEpoch++ - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - workdir: workdir, - runEpoch: m.chatStore.nextChatRunEpoch, - state: state, - nextSeq: m.latestConversationSeqLocked(conversationID), - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - relayBufferRetention: m.chatStore.relayBufferRetention, - } - m.chatStore.chatRuns[requestID] = run - created = true - } - if run.done && !terminalState { - m.chatStore.chatMu.Unlock() - return - } - previousState := normalizeChatRunState(run.state) - m.updateRunConversationLocked(run, requestID, conversationID, state == ChatRunStateRunning) - if workdir != "" { - run.workdir = workdir - } - run.applyState(state) - run.updatedAt = now - if terminalState { - run.expiresAt = now.Add(chatRunDoneRetention) - } - broadcast = m.appendChatPayloadLocked(run, payload, now) - runSubscribers = run.collectSubscribers() - if state == ChatRunStateRunning && (created || previousState != ChatRunStateRunning) { - activityKind = "running" - } else if terminalState { - activityKind = "idle" - } - activityConversationID = run.conversationID - activityWorkdir = run.workdir - m.chatStore.chatMu.Unlock() - - if broadcast == nil { - return - } - notifySubscribers(runSubscribers, broadcast) - if terminalState { - for _, s := range runSubscribers { - s.close() - } - } - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } -} - -func chatRuntimeSnapshotTime(updatedAt int64) time.Time { - if updatedAt <= 0 { - return time.Now() - } - if updatedAt < 10_000_000_000 { - return time.Unix(updatedAt, 0) - } - return time.UnixMilli(updatedAt) -} - -func (m *Manager) broadcastChatEvent(requestID string, event *gatewayv1.ChatEvent) { - if event == nil { - return - } - - requestID = strings.TrimSpace(requestID) - conversationID := strings.TrimSpace(event.GetConversationId()) - now := time.Now() - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - broadcast := &ChatBroadcastEvent{ - RequestID: requestID, - Event: event, - ReceivedAt: now, - } - var runSubscribers []*chatRunSubscriber - var activityKind string - var activityConversationID string - var activityWorkdir string - run := m.chatStore.chatRuns[requestID] - if run == nil && requestID != "" { - m.chatStore.nextChatRunEpoch++ - latestSeq := m.latestConversationSeqLocked(conversationID) - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: latestSeq, - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - relayBufferRetention: m.chatStore.relayBufferRetention, - } - m.chatStore.chatRuns[requestID] = run - if conversationID != "" && m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } - } - if run == nil || run.done { - m.chatStore.chatMu.Unlock() - return - } - previousState := normalizeChatRunState(run.state) - m.updateRunConversationLocked(run, requestID, conversationID, false) - if normalizeChatRunState(run.state) != ChatRunStateRunning && !isTerminalChatEvent(event) { - run.applyState(ChatRunStateRunning) - } - run.nextSeq++ - run.updatedAt = now - broadcast.Seq = run.nextSeq - broadcast.Workdir = run.workdir - if isTerminalChatEvent(event) { - if event.GetType() == gatewayv1.ChatEvent_DONE { - run.applyState(ChatRunStateCompleted) - } else { - run.applyState(ChatRunStateFailed) - } - run.expiresAt = now.Add(chatRunDoneRetention) - } - nextState := normalizeChatRunState(run.state) - activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) - run.appendEvent(broadcast) - runSubscribers = run.collectSubscribers() - m.chatStore.chatMu.Unlock() - - notifySubscribers(runSubscribers, broadcast) - if isTerminalChatEvent(event) { - for _, s := range runSubscribers { - s.close() - } - } - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } -} - -func (m *Manager) broadcastChatControl(requestID string, control *gatewayv1.ChatControlEvent) { - if control == nil { - return - } - requestID = strings.TrimSpace(requestID) - if requestID == "" { - requestID = strings.TrimSpace(control.GetRequestId()) - } - conversationID := strings.TrimSpace(control.GetConversationId()) - controlType := strings.TrimSpace(control.GetType()) - state := normalizeChatRunState(control.GetState()) - if state == "" { - state = controlTypeToState[controlType] - } - errorCode := strings.TrimSpace(control.GetErrorCode()) - message := strings.TrimSpace(control.GetMessage()) - m.markChatRunControl(requestID, conversationID, controlType, state, errorCode, message, time.Now()) -} - -func (m *Manager) markChatRunControl( - requestID string, - conversationID string, - controlType string, - state string, - errorCode string, - message string, - now time.Time, -) { - if requestID == "" { - return - } - - state = normalizeChatRunState(state) - if controlType == "" { - controlType = stateToControlType[normalizeChatRunState(state)] - if controlType == "" { - controlType = "progress" - } - } - - var activityKind string - var activityConversationID string - var activityWorkdir string - m.chatStore.chatMu.Lock() - m.pruneExpiredChatRunsLocked(now) - run := m.chatStore.chatRuns[requestID] - if run == nil && controlType == "started" && conversationID != "" { - // GUI-local runs (e.g. a queued prompt auto-sent by the desktop app) - // announce themselves with a bare "started" control before any chat - // event or runtime snapshot reaches the gateway. Register the run here - // so the "running" activity broadcast is not lost and remote clients - // can attach immediately. - m.chatStore.nextChatRunEpoch++ - run = &chatRun{ - requestID: requestID, - conversationID: conversationID, - runEpoch: m.chatStore.nextChatRunEpoch, - state: ChatRunStateQueued, - nextSeq: m.latestConversationSeqLocked(conversationID), - updatedAt: now, - subscribers: make(map[int]*chatRunSubscriber), - relayBufferRetention: m.chatStore.relayBufferRetention, - } - m.chatStore.chatRuns[requestID] = run - } - if run == nil || run.done { - m.chatStore.chatMu.Unlock() - if run == nil { - log.Printf("markChatRunControl: no run for requestID=%s controlType=%s", requestID, controlType) - } - return - } - previousState := normalizeChatRunState(run.state) - canClaim := chatRunControlCanClaimConversation(controlType, state) - m.updateRunConversationLocked(run, requestID, conversationID, canClaim) - broadcast := m.appendChatControlLocked(run, controlType, errorCode, message, now) - nextState := normalizeChatRunState(run.state) - activityKind, activityConversationID, activityWorkdir = detectRunActivity(run, previousState, nextState) - runSubscribers := run.collectSubscribers() - m.chatStore.chatMu.Unlock() - - if span := chatControlSpanName(broadcast.Control); span != "" { - logChatRunSpan(span, broadcast) - } - notifySubscribers(runSubscribers, broadcast) - if isTerminalChatRunState(nextState) { - for _, s := range runSubscribers { - s.close() - } - } - if activityKind != "" { - m.broadcastChatRunActivity(activityKind, activityConversationID, activityWorkdir, now) - } -} - -func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { - m.dispatchFromAgent(nil, env) -} - -func (m *Manager) DispatchFromAgentForSession(session *AgentSession, env *gatewayv1.AgentEnvelope) { - m.dispatchFromAgent(session, env) -} - -func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.AgentEnvelope) { - m.registry.mu.RLock() - session := m.registry.session - m.registry.mu.RUnlock() - if session == nil || (expected != nil && session != expected) { - return - } - - if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { - m.UpdateRuntimeStatus(session, runtimeStatus) - return - } - - if runtimeSnapshot := env.GetChatRuntimeSnapshot(); runtimeSnapshot != nil { - m.ApplyChatRuntimeSnapshot(runtimeSnapshot) - return - } - - if chatEvent := env.GetChatEvent(); chatEvent != nil { - m.broadcastChatEvent(env.GetRequestId(), chatEvent) - } - - if chatControl := env.GetChatControl(); chatControl != nil { - m.broadcastChatControl(env.GetRequestId(), chatControl) - } - - if historySync := env.GetHistorySync(); historySync != nil { - m.broadcastHistorySync(historySync) - return - } - - if settingsSync := env.GetSettingsSync(); settingsSync != nil { - m.broadcastSettingsSync(settingsSync) - return - } - - if terminalEvent := env.GetTerminalEvent(); terminalEvent != nil { - m.broadcastTerminalEvent(terminalEvent) - return - } - - if sftpEvent := env.GetSftpEvent(); sftpEvent != nil { - m.broadcastSftpEvent(sftpEvent) - return - } - - if chatQueueEvent := env.GetChatQueueEvent(); chatQueueEvent != nil { - m.broadcastChatQueueEvent(chatQueueEvent) - return - } - - if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { - m.dispatchTunnelFrame(tunnelFrame) - return - } - - if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { - m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) - return - } - - session.dispatch(env) -} - -// chatRun methods - -func (r *chatRun) snapshot() ChatRunSnapshot { - var firstSeq int64 - if len(r.events) > 0 { - firstSeq = r.events[0].Seq - } - return ChatRunSnapshot{ - RequestID: r.requestID, - ConversationID: r.conversationID, - Workdir: r.workdir, - FirstSeq: firstSeq, - LatestSeq: r.nextSeq, - RunEpoch: r.runEpoch, - State: r.state, - Done: r.done, - } -} - -func (r *chatRun) applyState(state string) { - state = normalizeChatRunState(state) - if state == "" { - state = ChatRunStateQueued - } - r.state = state - r.done = isTerminalChatRunState(state) -} - -func (r *chatRun) appendEvent(event *ChatBroadcastEvent) { - if r == nil || event == nil { - return - } - if event.ReceivedAt.IsZero() { - event.ReceivedAt = time.Now() - } - r.events = append(r.events, cloneChatBroadcastEvent(event)) - r.evictExpiredEvents() -} - -func (r *chatRun) evictExpiredEvents() { - retention := r.relayBufferRetention - if retention <= 0 { - retention = defaultRelayBufferRetention - } - cutoff := time.Now().Add(-retention) - i := 0 - for i < len(r.events) && r.events[i].ReceivedAt.Before(cutoff) { - i++ - } - if i > 0 { - copy(r.events, r.events[i:]) - r.events = r.events[:len(r.events)-i] - } -} - -func (r *chatRun) shouldPrune(now time.Time) bool { - if r == nil { - return true - } - if r.done { - return !r.expiresAt.IsZero() && now.After(r.expiresAt) - } - return !r.updatedAt.IsZero() && now.Sub(r.updatedAt) > chatRunStaleRetention -} - -func (s *chatRunSubscriber) close() { - s.closeOnce.Do(func() { - close(s.done) - }) -} - -func (m *Manager) updateRunConversationLocked(run *chatRun, requestID string, conversationID string, canClaim bool) { - if conversationID == "" { - return - } - if run.conversationID != "" && run.conversationID != conversationID { - if m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - } - run.conversationID = conversationID - if canClaim || m.chatRunCanClaimConversationLocked(conversationID, requestID) { - m.chatStore.chatRunByConversation[conversationID] = requestID - } -} - -func (r *chatRun) collectSubscribers() []*chatRunSubscriber { - subs := make([]*chatRunSubscriber, 0, len(r.subscribers)) - for _, s := range r.subscribers { - subs = append(subs, s) - } - return subs -} - -func notifySubscribers(subscribers []*chatRunSubscriber, broadcast *ChatBroadcastEvent) { - for _, s := range subscribers { - select { - case <-s.done: - case s.ch <- cloneChatBroadcastEvent(broadcast): - } - } -} - -func (m *Manager) pruneExpiredChatRunsLocked(now time.Time) { - for requestID, run := range m.chatStore.chatRuns { - if run == nil { - delete(m.chatStore.chatRuns, requestID) - continue - } - if run.shouldPrune(now) { - m.removeChatRunLocked(requestID, run) - } - } -} - -func (m *Manager) removeChatRunLocked(requestID string, run *chatRun) { - if run.conversationID != "" && m.chatStore.chatRunByConversation[run.conversationID] == requestID { - delete(m.chatStore.chatRunByConversation, run.conversationID) - } - delete(m.chatStore.chatRuns, requestID) - for _, subscriber := range run.subscribers { - subscriber.close() - } -} - -func cloneChatBroadcastEvent(event *ChatBroadcastEvent) *ChatBroadcastEvent { - if event == nil { - return nil - } - return &ChatBroadcastEvent{ - RequestID: event.RequestID, - Event: event.Event, - Control: event.Control, - Payload: cloneChatPayloadMap(event.Payload), - Seq: event.Seq, - Workdir: event.Workdir, - ReceivedAt: event.ReceivedAt, - } -} - -func cloneChatPayloadMap(input map[string]any) map[string]any { - if len(input) == 0 { - return nil - } - out := make(map[string]any, len(input)) - for k, v := range input { - out[k] = v - } - return out -} - -// State constants and helpers - -var validChatRunStates = map[string]bool{ - ChatRunStateQueued: true, - ChatRunStateDelivered: true, - ChatRunStateClaimed: true, - ChatRunStateStarting: true, - ChatRunStateDesktopQueued: true, - ChatRunStateRunning: true, - ChatRunStateCompleted: true, - ChatRunStateFailed: true, - ChatRunStateCancelled: true, -} - -func normalizeChatRunState(state string) string { - if validChatRunStates[state] { - return state - } - return "" -} - -var terminalChatRunStates = map[string]bool{ - ChatRunStateCompleted: true, - ChatRunStateFailed: true, - ChatRunStateCancelled: true, -} - -func isTerminalChatRunState(state string) bool { - return terminalChatRunStates[normalizeChatRunState(state)] -} - -var activeChatRunStates = map[string]bool{ - ChatRunStateQueued: true, - ChatRunStateDelivered: true, - ChatRunStateClaimed: true, - ChatRunStateStarting: true, - ChatRunStateRunning: true, -} - -var controlTypeToState = map[string]string{ - "accepted": ChatRunStateQueued, - "delivered": ChatRunStateDelivered, - "claimed": ChatRunStateClaimed, - "starting": ChatRunStateStarting, - "queued_in_gui": ChatRunStateDesktopQueued, - "started": ChatRunStateRunning, - "completed": ChatRunStateCompleted, - "failed": ChatRunStateFailed, - "cancelled": ChatRunStateCancelled, -} - -var stateToControlType = map[string]string{ - ChatRunStateQueued: "accepted", - ChatRunStateDelivered: "delivered", - ChatRunStateClaimed: "claimed", - ChatRunStateStarting: "starting", - ChatRunStateDesktopQueued: "queued_in_gui", - ChatRunStateRunning: "started", - ChatRunStateCompleted: "completed", - ChatRunStateFailed: "failed", - ChatRunStateCancelled: "cancelled", -} - -func isTerminalChatEvent(event *gatewayv1.ChatEvent) bool { - if event == nil { - return false - } - return event.GetType() == gatewayv1.ChatEvent_DONE || event.GetType() == gatewayv1.ChatEvent_ERROR -} - -func detectRunActivity(run *chatRun, previousState, nextState string) (kind, conversationID, workdir string) { - if isTerminalChatRunState(nextState) { - kind = "idle" - } else if previousState != ChatRunStateRunning && nextState == ChatRunStateRunning { - kind = "running" - } - if kind != "" { - conversationID = run.conversationID - workdir = run.workdir - } - return -} - -func (m *Manager) appendChatControlLocked( - run *chatRun, - controlType string, - errorCode string, - message string, - now time.Time, -) *ChatBroadcastEvent { - if run == nil { - return nil - } - state := controlTypeToState[controlType] - if state == "" { - state = normalizeChatRunState(run.state) - } - if state == "" { - state = ChatRunStateQueued - } - run.applyState(state) - run.updatedAt = now - if isTerminalChatRunState(state) { - run.expiresAt = now.Add(chatRunDoneRetention) - } - run.nextSeq++ - seq := run.nextSeq - if controlType == "" { - controlType = stateToControlType[normalizeChatRunState(state)] - if controlType == "" { - controlType = "progress" - } - } - control := &gatewayv1.ChatControlEvent{ - RequestId: run.requestID, - ConversationId: run.conversationID, - RunEpoch: run.runEpoch, - Type: controlType, - State: run.state, - ErrorCode: errorCode, - Message: message, - Seq: seq, - } - broadcast := &ChatBroadcastEvent{ - RequestID: run.requestID, - Control: control, - Seq: seq, - Workdir: run.workdir, - ReceivedAt: now, - } - run.appendEvent(broadcast) - return broadcast -} - -func (m *Manager) appendChatPayloadLocked( - run *chatRun, - payload map[string]any, - now time.Time, -) *ChatBroadcastEvent { - if run == nil || len(payload) == 0 { - return nil - } - run.updatedAt = now - run.nextSeq++ - seq := run.nextSeq - nextPayload := cloneChatPayloadMap(payload) - if nextPayload == nil { - nextPayload = make(map[string]any) - } - nextPayload["request_id"] = run.requestID - nextPayload["conversation_id"] = run.conversationID - nextPayload["run_epoch"] = run.runEpoch - nextPayload["state"] = run.state - nextPayload["seq"] = seq - broadcast := &ChatBroadcastEvent{ - RequestID: run.requestID, - Payload: nextPayload, - Seq: seq, - Workdir: run.workdir, - ReceivedAt: now, - } - run.appendEvent(broadcast) - return broadcast -} - -func chatControlSpanName(control *gatewayv1.ChatControlEvent) string { - if control == nil { - return "" - } - switch control.GetType() { - case "claimed": - return "runtime_claimed" - case "started": - return "runtime_started" - case "completed": - return "run_completed" - case "failed": - return "run_failed" - case "cancelled": - return "run_cancelled" - default: - return "" - } -} - -func logChatRunSpan(span string, event *ChatBroadcastEvent) { - if event == nil { - return - } - runID := event.RequestID - conversationID := "" - if event.Control != nil { - conversationID = event.Control.GetConversationId() - } else if event.Payload != nil { - if value, ok := event.Payload["conversation_id"].(string); ok { - conversationID = value - } - } else if event.Event != nil { - conversationID = event.Event.GetConversationId() - } - log.Printf( - "chat_run_span span=%s run_id=%q conversation_id=%q seq=%d", - span, - runID, - conversationID, - event.Seq, - ) -} diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go new file mode 100644 index 00000000..1ff8c979 --- /dev/null +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -0,0 +1,88 @@ +package session + +import ( + "strings" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func (m *Manager) DispatchFromAgent(env *gatewayv1.AgentEnvelope) { + m.dispatchFromAgent(nil, env) +} + +func (m *Manager) DispatchFromAgentForSession(session *AgentSession, env *gatewayv1.AgentEnvelope) { + m.dispatchFromAgent(session, env) +} + +func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.AgentEnvelope) { + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session == nil || (expected != nil && session != expected) { + return + } + + if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { + m.UpdateRuntimeStatus(session, runtimeStatus) + m.convStreams.onRuntimeStatus(runtimeStatus.GetActiveRunCount(), time.Now()) + return + } + + if runtimeSnapshot := env.GetChatRuntimeSnapshot(); runtimeSnapshot != nil { + m.ingestRuntimeSnapshot(runtimeSnapshot) + return + } + + if chatEvent := env.GetChatEvent(); chatEvent != nil { + m.ingestChatEvent(env.GetRequestId(), chatEvent) + } + + if chatControl := env.GetChatControl(); chatControl != nil { + m.ingestChatControl(env.GetRequestId(), chatControl) + } + + if historySync := env.GetHistorySync(); historySync != nil { + // Agent-sent running/idle activity is dropped: conversation activity + // is derived from run lifecycle transitions in the stream store, which + // always carry run ids. + switch strings.TrimSpace(historySync.GetKind()) { + case "running", "idle": + return + } + m.broadcastHistorySync(historySync) + return + } + + if settingsSync := env.GetSettingsSync(); settingsSync != nil { + m.broadcastSettingsSync(settingsSync) + return + } + + if terminalEvent := env.GetTerminalEvent(); terminalEvent != nil { + m.broadcastTerminalEvent(terminalEvent) + return + } + + if sftpEvent := env.GetSftpEvent(); sftpEvent != nil { + m.broadcastSftpEvent(sftpEvent) + return + } + + if chatQueueEvent := env.GetChatQueueEvent(); chatQueueEvent != nil { + m.broadcastChatQueueEvent(chatQueueEvent) + return + } + + if tunnelFrame := env.GetTunnelFrame(); tunnelFrame != nil { + m.dispatchTunnelFrame(tunnelFrame) + return + } + + if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { + m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) + return + } + + session.dispatch(env) +} diff --git a/crates/agent-gateway/internal/session/manager_history_sync.go b/crates/agent-gateway/internal/session/manager_history_sync.go index 6709a407..9b67bfa3 100644 --- a/crates/agent-gateway/internal/session/manager_history_sync.go +++ b/crates/agent-gateway/internal/session/manager_history_sync.go @@ -1,9 +1,6 @@ package session import ( - "strings" - "time" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -34,8 +31,6 @@ func (m *Manager) broadcastHistorySync(event *gatewayv1.HistorySyncEvent) { return } - m.releaseCompletedChatRunAfterHistoryUpsert(event) - m.syncHub.historyMu.Lock() subscribers := make([]chan *gatewayv1.HistorySyncEvent, 0, len(m.syncHub.historySubscribers)) for _, ch := range m.syncHub.historySubscribers { @@ -44,113 +39,9 @@ func (m *Manager) broadcastHistorySync(event *gatewayv1.HistorySyncEvent) { m.syncHub.historyMu.Unlock() for _, ch := range subscribers { - publishHistorySyncToSubscriber(ch, event) - } -} - -func publishHistorySyncToSubscriber(ch chan *gatewayv1.HistorySyncEvent, event *gatewayv1.HistorySyncEvent) { - if ch == nil || event == nil { - return - } - select { - case ch <- event: - return - default: - } - if !isCriticalHistorySyncEvent(event) { - return - } - retained := drainCriticalHistorySyncEvents(ch) - if maxRetained := cap(ch) - 1; maxRetained > 0 && len(retained) > maxRetained { - retained = retained[len(retained)-maxRetained:] - } - retained = append(retained, event) - for _, retainedEvent := range retained { select { - case ch <- retainedEvent: + case ch <- event: default: - return } } } - -func drainCriticalHistorySyncEvents(ch chan *gatewayv1.HistorySyncEvent) []*gatewayv1.HistorySyncEvent { - retained := make([]*gatewayv1.HistorySyncEvent, 0) - for { - select { - case pending := <-ch: - if isCriticalHistorySyncEvent(pending) { - retained = append(retained, pending) - } - default: - return retained - } - } -} - -func isCriticalHistorySyncEvent(event *gatewayv1.HistorySyncEvent) bool { - if event == nil { - return false - } - switch strings.TrimSpace(event.GetKind()) { - case "running", "idle": - return true - default: - return false - } -} - -func (m *Manager) broadcastChatRunActivity(kind string, conversationID string, workdir string, updatedAt time.Time) { - kind = strings.TrimSpace(kind) - conversationID = strings.TrimSpace(conversationID) - if conversationID == "" { - return - } - if kind != "running" && kind != "idle" { - return - } - if updatedAt.IsZero() { - updatedAt = time.Now() - } - - event := &gatewayv1.HistorySyncEvent{ - Kind: kind, - ConversationId: conversationID, - } - if workdir = strings.TrimSpace(workdir); workdir != "" { - event.Conversation = &gatewayv1.ConversationSummary{ - Id: conversationID, - Cwd: workdir, - UpdatedAt: updatedAt.UnixMilli(), - } - } - m.broadcastHistorySync(event) -} - -func historySyncConversationID(event *gatewayv1.HistorySyncEvent) string { - conversationID := strings.TrimSpace(event.GetConversationId()) - if conversationID == "" && event.GetConversation() != nil { - conversationID = strings.TrimSpace(event.GetConversation().GetId()) - } - return conversationID -} - -func (m *Manager) releaseCompletedChatRunAfterHistoryUpsert(event *gatewayv1.HistorySyncEvent) { - if strings.TrimSpace(event.GetKind()) != "upsert" { - return - } - - conversationID := historySyncConversationID(event) - if conversationID == "" { - return - } - - m.chatStore.chatMu.Lock() - defer m.chatStore.chatMu.Unlock() - requestID := m.chatStore.chatRunByConversation[conversationID] - run := m.chatStore.chatRuns[requestID] - if run == nil || !run.done { - return - } - m.removeChatRunLocked(requestID, run) -} diff --git a/crates/agent-gateway/internal/session/manager_state.go b/crates/agent-gateway/internal/session/manager_state.go index b28d0aa5..928c7655 100644 --- a/crates/agent-gateway/internal/session/manager_state.go +++ b/crates/agent-gateway/internal/session/manager_state.go @@ -69,21 +69,3 @@ func newSyncHub() *syncHub { } } -type chatRunStore struct { - chatCommandMu sync.Mutex - chatMu sync.Mutex - nextChatRunSubID int - nextChatRunEpoch int64 - chatRuns map[string]*chatRun - chatRunByConversation map[string]string - - relayBufferRetention time.Duration -} - -func newChatRunStore() *chatRunStore { - return &chatRunStore{ - chatRuns: make(map[string]*chatRun), - chatRunByConversation: make(map[string]string), - relayBufferRetention: defaultRelayBufferRetention, - } -} diff --git a/crates/agent-gateway/internal/session/manager_test.go b/crates/agent-gateway/internal/session/manager_test.go index 1124f2be..75765290 100644 --- a/crates/agent-gateway/internal/session/manager_test.go +++ b/crates/agent-gateway/internal/session/manager_test.go @@ -2,7 +2,6 @@ package session import ( "testing" - "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -134,73 +133,30 @@ func TestTerminalSessionSnapshotPreservesSshMetadataAndSorts(t *testing.T) { } } -func TestChatRunShouldPruneRetainsRunningUntilStale(t *testing.T) { - now := time.Now() - running := &chatRun{ - state: ChatRunStateRunning, - updatedAt: now.Add(-15 * time.Minute), - } - if running.shouldPrune(now) { - t.Fatal("running chat should survive well before stale retention") - } - - stale := &chatRun{ - state: ChatRunStateQueued, - updatedAt: now.Add(-(chatRunStaleRetention + time.Second)), - } - if !stale.shouldPrune(now) { - t.Fatal("non-done chat should prune after stale retention") - } - - done := &chatRun{ - done: true, - expiresAt: now.Add(-time.Second), - } - if !done.shouldPrune(now) { - t.Fatal("done chat should prune after expiresAt") - } -} - -func TestPruneExpiredChatRunsDropsNilEntries(t *testing.T) { +func TestActiveConversationActivitiesTracksRunLifecycle(t *testing.T) { manager := NewManager() - manager.chatStore.chatMu.Lock() - manager.chatStore.chatRuns["nil-run"] = nil - manager.pruneExpiredChatRunsLocked(time.Now()) - _, exists := manager.chatStore.chatRuns["nil-run"] - manager.chatStore.chatMu.Unlock() - if exists { - t.Fatal("nil chat run should be deleted during pruning") - } -} - -func TestConversationRunSummaryReturnsCompletedRun(t *testing.T) { - manager := NewManager() - - snapshot, created, _, err := manager.StartAcceptedChatCommandRun("run-1", "conv-1", "/workspace", nil) - if err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun failed: err=%v created=%v", err, created) - } - _ = snapshot - - manager.MarkChatRunControl("run-1", "conv-1", "started", "", "") + manager.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", nil) + manager.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "started", + State: "running", + }) - summary, ok := manager.ConversationRunSummary("conv-1") - if !ok || summary.RequestID != "run-1" { - t.Fatalf("expected running run summary, got ok=%v summary=%+v", ok, summary) + activities := manager.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-1" || activities[0].State != RunActivityRunning { + t.Fatalf("activities = %#v, want running run-1", activities) } - manager.MarkChatRunControl("run-1", "conv-1", "completed", "", "") - - summary, ok = manager.ConversationRunSummary("conv-1") - if !ok || summary.RequestID != "run-1" { - t.Fatalf("expected completed run summary via ConversationRunSummary, got ok=%v summary=%+v", ok, summary) - } + manager.ingestChatControl("run-1", &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conv-1", + Type: "completed", + State: "completed", + }) - actives := manager.ActiveChatRunSummaries() - for _, s := range actives { - if s.ConversationID == "conv-1" { - t.Fatal("completed run should not appear in ActiveChatRunSummaries") - } + if activities := manager.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("completed run should not appear in activities, got %#v", activities) } } diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 761d7744..1d978dbb 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -18,37 +18,6 @@ func newTestSessionManager() *session.Manager { return sm } -func startRunningChatCommandRun( - t *testing.T, - sm *session.Manager, - requestID string, - conversationID string, -) session.ChatRunSnapshot { - t.Helper() - snapshot, created, err := sm.StartPendingChatCommandRun(requestID, conversationID, "") - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created { - t.Fatalf("StartPendingChatCommandRun created = false for %q", requestID) - } - dispatchChatControl(sm, requestID, conversationID, "started", session.ChatRunStateRunning) - if next, ok := sm.ChatRunSnapshot(requestID, conversationID); ok { - return next - } - return snapshot -} - -func activeChatRunConversationIDs(sm *session.Manager) []string { - summaries := sm.ActiveChatRunSummaries() - ids := make([]string, 0, len(summaries)) - for _, summary := range summaries { - if summary.ConversationID != "" { - ids = append(ids, summary.ConversationID) - } - } - return ids -} func dispatchChatControl( sm *session.Manager, @@ -209,171 +178,6 @@ func TestChatRuntimeReadyRequiresFreshRuntimeHeartbeat(t *testing.T) { } } -func TestChatRunSeqContinuesWithinConversation(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1"); !ok || snapshot.LatestSeq != 3 { - t.Fatalf("first snapshot = %#v ok=%v, want latest seq 3", snapshot, ok) - } - - next, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-2") - if err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - if next.LatestSeq != 3 { - t.Fatalf("second run initial snapshot = %#v, want latest seq 3", next) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - if snapshot, ok := sm.ChatRunSnapshot("request-2", "conversation-1"); !ok || snapshot.LatestSeq != 4 { - t.Fatalf("second snapshot = %#v ok=%v, want latest seq 4", snapshot, ok) - } - - sub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer sub.Cleanup() - if sub.Snapshot.RequestID != "request-2" || sub.Snapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want latest run request-2 seq 4", sub.Snapshot) - } - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-sub.EventCh: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - want := []string{ - "request-1:1:accepted", - "request-1:2:user_message", - "request-1:3:completed", - "request-2:4:accepted", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("conversation replay = %#v, want %#v", got, want) - } - } -} - -func TestSubscribeChatRunConversationReplayAttachesLatestLiveRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, created, err := sm.StartPendingChatCommandRun("request-1", "conversation-1", "client-1"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-1 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-1", "conversation-1", "accepted", "", "") - sm.MarkChatRunPayload("request-1", "conversation-1", map[string]any{ - "type": "user_message", - "message": "first", - }) - sm.MarkChatRunControl("request-1", "conversation-1", "completed", "", "") - if _, created, err := sm.StartPendingChatCommandRun("request-2", "conversation-1", "client-2"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-2 created=%v err=%v", created, err) - } - sm.MarkChatRunControl("request-2", "conversation-1", "accepted", "", "") - - replaySub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun conversation replay: %v", err) - } - defer replaySub.Cleanup() - assertDoneOpen(t, replaySub.Done) - if replaySub.Snapshot.RequestID != "request-2" || replaySub.Snapshot.LatestSeq != 4 { - t.Fatalf("conversation replay snapshot = %#v, want live request-2 seq 4", replaySub.Snapshot) - } - - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-replaySub.EventCh: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for conversation replay, got %#v", got) - } - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-2", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"second"}`, - }, - }, - }) - select { - case event := <-replaySub.EventCh: - if event.RequestID != "request-2" || event.Seq != 5 || event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("live event after replay = %#v, want request-2 token seq 5", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for live event after conversation replay, got %#v", got) - } -} - -func TestClearSessionIfHeartbeatStalePreservesOpenChatRuns(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sess := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(sess) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - - time.Sleep(time.Millisecond) - if !sm.ClearSessionIfHeartbeatStale(sess, time.Nanosecond) { - t.Fatalf("current stale session was not cleared") - } - assertDoneOpen(t, sub.Done) - select { - case event := <-sub.EventCh: - t.Fatalf("unexpected chat event after heartbeat timeout: %#v", event) - default: - } - got := activeChatRunConversationIDs(sm) - want := []string{"conversation-1"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after heartbeat timeout = %#v, want %#v", got, want) - } -} func TestDispatchFromStaleSessionIsIgnored(t *testing.T) { t.Parallel() @@ -482,1126 +286,267 @@ func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { } } -func TestRemoveChatRunByConversationReleasesBufferedRun(t *testing.T) { + +func TestChatQueueEventsReplayLatestSnapshotToNewSubscribers(t *testing.T) { t.Parallel() sm := newTestSessionManager() - startRunningChatCommandRun(t, sm, "request-1", "conversation-1") + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "queue-event-1", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: " conversation-1 ", + SnapshotJson: `{"conversationId":"conversation-1","revision":2,"items":[{"id":"queue-1"}]}`, + Revision: 2, + }, + }, + }) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "queue-event-stale", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: "conversation-1", + SnapshotJson: `{"conversationId":"conversation-1","revision":1,"items":[]}`, + Revision: 1, + }, + }, + }) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "queue-event-zero", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: "conversation-1", + SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, + Revision: 0, + }, + }, + }) - sub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun before remove: %v", err) - } - if sub.Snapshot.RequestID != "request-1" { - t.Fatalf("snapshot request id = %q, want request-1", sub.Snapshot.RequestID) + cached, ok := sm.ChatQueueSnapshot("conversation-1") + if !ok || cached.GetRevision() != 2 || !strings.Contains(cached.GetSnapshotJson(), "queue-1") { + t.Fatalf("cached queue snapshot = %#v ok=%v, want revision 2 with queue-1", cached, ok) } - sm.RemoveChatRunByConversation("conversation-1") - assertDoneClosed(t, sub.Done) - sub.Cleanup() + events, cleanup := sm.SubscribeChatQueueEvents() + defer cleanup() select { - case event := <-sub.EventCh: - t.Fatalf("unexpected replay event after remove: %#v", event) - default: - } - - missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingSub.Cleanup() - assertDoneClosed(t, missingSub.Done) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun after remove = %v, want ErrChatRunNotFound", err) + case event := <-events: + if event.GetConversationId() != "conversation-1" || + event.GetRevision() != 2 || + !strings.Contains(event.GetSnapshotJson(), "queue-1") { + t.Fatalf("replayed queue snapshot = %#v, want latest revision 2", event) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for replayed queue snapshot") } } -func TestStartPendingChatCommandRunReusesExistingRun(t *testing.T) { +func TestChatQueueSnapshotAllowsNewSessionToResetRevision(t *testing.T) { t.Parallel() sm := newTestSessionManager() - first, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun first: %v", err) - } - if !created { - t.Fatalf("first run created = false, want true") - } - if first.RequestID != "request-1" { - t.Fatalf("first request id = %q, want request-1", first.RequestID) - } - duplicate, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun duplicate: %v", err) - } - if created { - t.Fatalf("duplicate run created = true, want false") - } - if duplicate.RequestID != "request-1" { - t.Fatalf("duplicate request id = %q, want original request-1", duplicate.RequestID) - } + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "queue-event-1", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: "conversation-1", + SnapshotJson: `{"conversationId":"conversation-1","revision":5,"items":[{"id":"queue-1"}]}`, + Revision: 5, + }, + }, + }) - sm.RemoveChatRun("request-1") - restarted, created, err := sm.StartPendingChatCommandRun( - "request-2", - "conversation-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun after remove: %v", err) - } - if !created { - t.Fatalf("restarted run created = false, want true") - } - if restarted.RequestID != "request-2" { - t.Fatalf("restarted request id = %q, want request-2", restarted.RequestID) + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "queue-event-reset", + Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ + ChatQueueEvent: &gatewayv1.ChatQueueEvent{ + ConversationId: "conversation-1", + SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, + Revision: 0, + }, + }, + }) + + cached, ok := sm.ChatQueueSnapshot("conversation-1") + if !ok || cached.GetRevision() != 0 || strings.Contains(cached.GetSnapshotJson(), "queue-1") { + t.Fatalf("cached queue snapshot after new session = %#v ok=%v, want empty revision 0", cached, ok) } } -func TestPendingChatRunIsAdvertisedBeforeStartedEvent(t *testing.T) { +func dispatchChatToken(sm *session.Manager, requestID string, conversationID string, text string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: requestID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: fmt.Sprintf(`{"text":%q}`, text), + }, + }, + }) +} + +func dispatchChatDone(sm *session.Manager, requestID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: requestID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: "{}", + }, + }, + }) +} + +func TestConversationStreamSeqContinuesAcrossDispatchedRuns(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - snapshot, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - "/workspace", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || snapshot.RequestID != "request-1" { - t.Fatalf("pending run = %#v created=%v", snapshot, created) - } - if got := activeChatRunConversationIDs(sm); fmt.Sprint(got) != fmt.Sprint([]string{"conversation-1"}) { - t.Fatalf("pending active chat runs = %#v, want conversation-1", got) - } - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") + dispatchChatToken(sm, "run-1", "conversation-1", "first") + dispatchChatDone(sm, "run-1", "conversation-1") + dispatchChatControl(sm, "run-2", "conversation-1", "started", "running") + dispatchChatToken(sm, "run-2", "conversation-1", "second") + + sub := sm.SubscribeConversationStream("conversation-1", 0, "") defer sub.Cleanup() - dispatchChatControl(sm, "request-1", "conversation-1", "delivered", session.ChatRunStateDelivered) - if got := activeChatRunConversationIDs(sm); fmt.Sprint(got) != fmt.Sprint([]string{"conversation-1"}) { - t.Fatalf("delivered active chat runs = %#v, want conversation-1", got) + var lastSeq int64 + runFinished := 0 + for _, event := range sub.Events { + if event.Seq <= lastSeq { + t.Fatalf("seq regressed: %d after %d", event.Seq, lastSeq) + } + lastSeq = event.Seq + if event.Type == "run_finished" { + runFinished++ + } } - if sm.FailStartingChatRun("request-1", "desktop did not accept") { - t.Fatalf("accepted pending run should not fail the accept watchdog") + if runFinished != 1 { + t.Fatalf("run_finished events = %d, want 1", runFinished) } + if sub.Activity == nil || sub.Activity.RunID != "run-2" { + t.Fatalf("activity = %#v, want run-2", sub.Activity) + } +} + +func TestDispatchedHistoryRunningIdleAreDropped(t *testing.T) { + t.Parallel() - dispatchChatControl(sm, "request-1", "conversation-1", "started", session.ChatRunStateRunning) + sm := newTestSessionManager() + sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) + + historyEvents, cleanup := sm.SubscribeHistorySync() + defer cleanup() - got := activeChatRunConversationIDs(sm) - want := []string{"conversation-1"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after started = %#v, want %#v", got, want) + for _, kind := range []string{"running", "idle"} { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "history-sync", + Payload: &gatewayv1.AgentEnvelope_HistorySync{ + HistorySync: &gatewayv1.HistorySyncEvent{ + Kind: kind, + ConversationId: "conversation-1", + }, + }, + }) } + select { - case event := <-sub.EventCh: - if event.Control == nil || event.Control.GetType() != "delivered" { - t.Fatalf("first control event = %#v, want delivered", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for delivered control event") + case event := <-historyEvents: + t.Fatalf("agent running/idle history event should be dropped, got %#v", event) + case <-time.After(50 * time.Millisecond): + } + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("history running must not create activity, got %#v", activities) } + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "history-sync", + Payload: &gatewayv1.AgentEnvelope_HistorySync{ + HistorySync: &gatewayv1.HistorySyncEvent{ + Kind: "upsert", + ConversationId: "conversation-1", + Conversation: &gatewayv1.ConversationSummary{Id: "conversation-1"}, + }, + }, + }) select { - case event := <-sub.EventCh: - if event.Control == nil || event.Control.GetType() != "started" { - t.Fatalf("second control event = %#v, want started", event) + case event := <-historyEvents: + if event.GetKind() != "upsert" { + t.Fatalf("history event kind = %q, want upsert", event.GetKind()) } case <-time.After(time.Second): - t.Fatalf("timed out waiting for started control event") + t.Fatalf("upsert history event was not forwarded") } } -func TestFailStartingChatRunBroadcastsErrorAndClearsActiveSummary(t *testing.T) { +func TestAgentDisconnectPreservesActiveConversationActivity(t *testing.T) { t.Parallel() sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) - if !sm.FailStartingChatRun("request-1", "desktop did not accept") { - t.Fatalf("FailStartingChatRun returned false") - } + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") + sm.ClearSession(sess) - select { - case event := <-sub.EventCh: - if event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event type = %v, want ERROR", event.Event.GetType()) - } - if !strings.Contains(event.Event.GetData(), "desktop did not accept") { - t.Fatalf("event data = %q", event.Event.GetData()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for starting run failure event") - } - if got := activeChatRunConversationIDs(sm); len(got) != 0 { - t.Fatalf("active chat runs after failed start = %#v, want empty", got) - } - if status := sm.Status(); !status.Online { - t.Fatalf("status online = false, agent session should remain active after individual chat run failure") + activities := sm.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-1" { + t.Fatalf("activities after disconnect = %#v, want run-1 preserved", activities) } } -func TestFailUnstartedChatRunBroadcastsErrorUnlessStarted(t *testing.T) { +func TestTerminalSnapshotFinishesRunAndStaleRunningIsIgnored(t *testing.T) { t.Parallel() sm := newTestSessionManager() sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun request-1: %v", err) - } - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - if sm.FailUnstartedChatRun("request-1", "desktop app did not start") { - t.Fatalf("unaccepted pending run should not fail the render-start watchdog") - } - dispatchChatControl(sm, "request-1", "conversation-1", "delivered", session.ChatRunStateDelivered) - if !sm.FailUnstartedChatRun("request-1", "desktop app did not start") { - t.Fatalf("FailUnstartedChatRun returned false for accepted pending run") - } - select { - case event := <-sub.EventCh: - if event.Control == nil || event.Control.GetType() != "delivered" { - t.Fatalf("event = %#v, want delivered control", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for delivered control event") - } - select { - case event := <-sub.EventCh: - if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("event = %#v, want ERROR", event) - } - if !strings.Contains(event.Event.GetData(), "desktop app did not start") { - t.Fatalf("event data = %q", event.Event.GetData()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for unstarted run failure event") - } - - if _, _, err := sm.StartPendingChatCommandRun( - "request-2", - "conversation-2", - "client-submit-2", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun request-2: %v", err) - } - dispatchChatControl(sm, "request-2", "conversation-2", "started", session.ChatRunStateRunning) - if sm.FailUnstartedChatRun("request-2", "desktop app did not start") { - t.Fatalf("started run should not fail the render-start watchdog") - } -} - -func TestTerminalChatRunStateIsImmutable(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - if _, _, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ); err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_ERROR, - ConversationId: "conversation-1", - Data: `{"message":"startup failed"}`, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - Type: "completed", - State: session.ChatRunStateCompleted, - RequestId: "request-1", - ConversationId: "conversation-1", - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"late token"}`, - }, - }, - }) - - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - if sub.Snapshot.State != session.ChatRunStateFailed { - t.Fatalf("terminal state = %q, want %q", sub.Snapshot.State, session.ChatRunStateFailed) - } - - select { - case event := <-sub.EventCh: - if event.Event == nil || event.Event.GetType() != gatewayv1.ChatEvent_ERROR { - t.Fatalf("replayed event = %#v, want ERROR", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for replayed error event") - } - select { - case event := <-sub.EventCh: - t.Fatalf("terminal completion control should be ignored after failure: %#v", event) - default: - } -} - -func TestDesktopBroadcastChatEventCreatesAttachableRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "desktop-run-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conversation-1", - Data: `{"text":"hello"}`, - }, - }, - }) - - sub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - if sub.Snapshot.RequestID != "desktop-run-1" { - t.Fatalf("snapshot request id = %q, want desktop-run-1", sub.Snapshot.RequestID) - } - - select { - case event := <-sub.EventCh: - if event.Seq != 1 { - t.Fatalf("event seq = %d, want 1", event.Seq) - } - if event.Event.GetType() != gatewayv1.ChatEvent_TOKEN { - t.Fatalf("event type = %v, want TOKEN", event.Event.GetType()) - } - if event.Event.GetConversationId() != "conversation-1" { - t.Fatalf("conversation id = %q, want conversation-1", event.Event.GetConversationId()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for replayed desktop chat event") - } -} - -func TestDesktopStartedControlCreatesAttachableRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - // GUI-local runs (e.g. queue auto-send) announce themselves with a bare - // "started" control before any chat event or runtime snapshot arrives. - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "desktop-local-run-1", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - RequestId: "desktop-local-run-1", - ConversationId: "conversation-local", - Type: "started", - }, - }, - }) - - snapshot, ok := sm.RunningChatRunSnapshot("conversation-local") - if !ok { - t.Fatalf("RunningChatRunSnapshot: run for bare started control not registered") - } - if snapshot.RequestID != "desktop-local-run-1" { - t.Fatalf("snapshot request id = %q, want desktop-local-run-1", snapshot.RequestID) - } - - summary, ok := sm.ConversationRunSummary("conversation-local") - if !ok || summary.RequestID != "desktop-local-run-1" { - t.Fatalf("ConversationRunSummary = %#v ok=%v, want desktop-local-run-1", summary, ok) - } - - // A stale "completed" control for an unknown run must not resurrect a run. + dispatchChatControl(sm, "run-1", "conversation-1", "started", "running") sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "desktop-unknown-run", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - RequestId: "desktop-unknown-run", - ConversationId: "conversation-unknown", - Type: "completed", - }, - }, - }) - if _, ok := sm.ChatRunSnapshot("desktop-unknown-run", ""); ok { - t.Fatalf("completed control for unknown run must not create a run") - } -} - -func TestChatRuntimeSnapshotCreatesAttachableConversationRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "runtime-snapshot-1", + RequestId: "run-1", Payload: &gatewayv1.AgentEnvelope_ChatRuntimeSnapshot{ ChatRuntimeSnapshot: &gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - ClientRequestId: "client-1", - WorkerId: "gui-live", - State: session.ChatRunStateRunning, - Cwd: "/workspace", - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"partial","round":1}]`, - ToolStatus: "Thinking...", - ToolStatusIsCompaction: false, - }, - }, - }) - - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "run-1" || - summaries[0].Workdir != "/workspace" { - t.Fatalf("active summaries = %#v, want snapshot-backed run-1", summaries) - } - - sub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - if sub.Snapshot.RequestID != "run-1" || sub.Snapshot.State != session.ChatRunStateRunning || sub.Snapshot.Done { - t.Fatalf("snapshot = %#v, want running run-1", sub.Snapshot) - } - - select { - case event := <-sub.EventCh: - if event.RequestID != "run-1" || event.Seq != 1 { - t.Fatalf("runtime snapshot event = %#v, want run-1 seq 1", event) - } - eventType, _ := event.Payload["type"].(string) - if eventType != "runtime_snapshot" { - t.Fatalf("payload type = %q, want runtime_snapshot", eventType) - } - entriesJSON, _ := event.Payload["entries_json"].(string) - if !strings.Contains(entriesJSON, "partial") { - t.Fatalf("entries_json = %q, want partial assistant text", entriesJSON) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for runtime snapshot replay") - } -} - -func TestChatRuntimeSnapshotTerminalClosesSubscribers(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateRunning, - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]}]`, - }) - - sub, err := sm.SubscribeChatRun("run-1", "conversation-1", 1) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateCompleted, - Revision: 2, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"done","round":1}]`, - }) - - select { - case event := <-sub.EventCh: - eventType, _ := event.Payload["type"].(string) - state, _ := event.Payload["state"].(string) - if eventType != "runtime_snapshot" || state != session.ChatRunStateCompleted { - t.Fatalf("terminal snapshot event = %#v, want completed runtime_snapshot", event) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for terminal runtime snapshot") - } - assertDoneClosed(t, sub.Done) - if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { - t.Fatalf("active summaries after terminal snapshot = %#v, want empty", summaries) - } -} - -func TestChatRuntimeSnapshotIgnoresStaleRunningAfterTerminal(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateRunning, - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]}]`, - }) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateCompleted, - Revision: 2, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"done","round":1}]`, - }) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateRunning, - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"stale","round":1}]`, - }) - - if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { - t.Fatalf("active summaries after stale running snapshot = %#v, want empty", summaries) - } - snapshot, ok := sm.ChatRunSnapshot("run-1", "conversation-1") - if !ok || snapshot.State != session.ChatRunStateCompleted || !snapshot.Done || snapshot.LatestSeq != 2 { - t.Fatalf("snapshot after stale running = %#v ok=%v, want completed seq 2", snapshot, ok) - } -} - -func TestChatRuntimeSnapshotTerminalCanFollowDoneEvent(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateRunning, - Revision: 1, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]}]`, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "run-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "run-1", - State: session.ChatRunStateCompleted, - Revision: 2, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]},{"id":"a1","kind":"assistant","text":"final","round":1}]`, - }) - - sub, err := sm.SubscribeChatRun("run-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - if sub.Snapshot.State != session.ChatRunStateCompleted || !sub.Snapshot.Done || sub.Snapshot.LatestSeq != 3 { - t.Fatalf("snapshot = %#v, want completed seq 3", sub.Snapshot) - } - - gotRuntimeSnapshots := 0 - gotFinalSnapshot := false - for gotRuntimeSnapshots < 2 { - select { - case event := <-sub.EventCh: - if event.Payload == nil { - continue - } - eventType, _ := event.Payload["type"].(string) - if eventType != "runtime_snapshot" { - continue - } - gotRuntimeSnapshots += 1 - entriesJSON, _ := event.Payload["entries_json"].(string) - if strings.Contains(entriesJSON, "final") { - gotFinalSnapshot = true - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for final runtime snapshot") - } - } - if !gotFinalSnapshot { - t.Fatalf("final runtime snapshot was not replayed") - } -} - -func TestHistoryRunningDoesNotCreateAttachableConversationRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, - }, - }) - - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 0 { - t.Fatalf("active summaries = %#v, want empty", summaries) - } - - missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingSub.Cleanup() - assertDoneClosed(t, missingSub.Done) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun = %v, want ErrChatRunNotFound", err) - } -} - -func TestHistoryRunningDoesNotPromoteDesktopQueuedCommandRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "/workspace", []map[string]any{{ - "type": "user_message", - "message": "queued prompt", - }}); err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun queued created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-queued", "conversation-1", "queued_in_gui", session.ChatRunStateDesktopQueued) - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "running", - ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - Cwd: "/workspace", - }, - }, - }, - }) - - queuedSnapshot, ok := sm.ChatRunSnapshot("request-queued", "conversation-1") - if !ok || queuedSnapshot.State != session.ChatRunStateDesktopQueued || queuedSnapshot.Workdir != "/workspace" { - t.Fatalf("queued snapshot = %#v, ok=%v; want desktop queued request with workdir", queuedSnapshot, ok) - } - - if summaries := sm.ActiveChatRunSummaries(); len(summaries) != 0 { - t.Fatalf("active summaries = %#v, want queued GUI request hidden from live runs", summaries) - } - - historyEvents, cleanupHistoryEvents := sm.SubscribeHistorySync() - defer cleanupHistoryEvents() - - dispatchChatControl(sm, "request-queued", "conversation-1", "started", session.ChatRunStateRunning) - - select { - case event := <-historyEvents: - if event.GetKind() != "running" || event.GetConversationId() != "conversation-1" { - t.Fatalf("history event after queued start = %#v, want running conversation-1", event) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for running history event after queued request started") - } - - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "request-queued" || - summaries[0].FirstSeq != 1 || - summaries[0].LatestSeq != 4 { - t.Fatalf("active summaries after started = %#v, want request-queued replay cursor", summaries) - } - - sub, err := sm.SubscribeChatRun("request-queued", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - if sub.Snapshot.RequestID != "request-queued" || sub.Snapshot.State != session.ChatRunStateRunning { - t.Fatalf("snapshot = %#v, want running request-queued", sub.Snapshot) - } - - got := make([]string, 0, 4) - for len(got) < 4 { - select { - case event := <-sub.EventCh: - eventType := "" - if event.Control != nil { - eventType = event.Control.GetType() - } else if event.Payload != nil { - eventType, _ = event.Payload["type"].(string) - } - got = append(got, fmt.Sprintf("%s:%d:%s", event.RequestID, event.Seq, eventType)) - case <-time.After(time.Second): - t.Fatalf("timed out waiting for promoted queued replay, got %#v", got) - } - } - want := []string{ - "request-queued:1:accepted", - "request-queued:2:user_message", - "request-queued:3:queued_in_gui", - "request-queued:4:started", - } - for index := range want { - if got[index] != want[index] { - t.Fatalf("promoted queued replay = %#v, want %#v", got, want) - } - } -} - -func TestQueuedRunStartedHistoryEventSurvivesFullSubscriberQueue(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - historyEvents, cleanupHistoryEvents := sm.SubscribeHistorySync() - defer cleanupHistoryEvents() - - for index := 0; index < 140; index += 1 { - conversationID := fmt.Sprintf("filler-%03d", index) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: fmt.Sprintf("history-filler-%03d", index), - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "upsert", - ConversationId: conversationID, - Conversation: &gatewayv1.ConversationSummary{ - Id: conversationID, - }, - }, - }, - }) - } - - if _, created, _, err := sm.StartAcceptedChatCommandRun("request-queued", "conversation-1", "/workspace", []map[string]any{{ - "type": "user_message", - "message": "queued prompt", - }}); err != nil || !created { - t.Fatalf("StartAcceptedChatCommandRun queued created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-queued", "conversation-1", "queued_in_gui", session.ChatRunStateDesktopQueued) - dispatchChatControl(sm, "request-queued", "conversation-1", "started", session.ChatRunStateRunning) - - select { - case event := <-historyEvents: - if event.GetKind() != "running" || event.GetConversationId() != "conversation-1" { - t.Fatalf("first history event after subscriber queue pressure = %#v, want running conversation-1", event) - } - case <-time.After(time.Second): - t.Fatal("timed out waiting for running history event after subscriber queue pressure") - } -} - -func TestCriticalHistoryEventDoesNotDrainEarlierCriticalEvent(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - - historyEvents, cleanupHistoryEvents := sm.SubscribeHistorySync() - defer cleanupHistoryEvents() - - if _, created, err := sm.StartPendingChatCommandRun("request-a", "conversation-a", "client-a"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-a created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-a", "conversation-a", "started", session.ChatRunStateRunning) - - for index := 0; index < 140; index += 1 { - conversationID := fmt.Sprintf("filler-%03d", index) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: fmt.Sprintf("history-filler-%03d", index), - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "upsert", - ConversationId: conversationID, - Conversation: &gatewayv1.ConversationSummary{ - Id: conversationID, - }, - }, - }, - }) - } - - if _, created, err := sm.StartPendingChatCommandRun("request-b", "conversation-b", "client-b"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun request-b created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-b", "conversation-b", "started", session.ChatRunStateRunning) - - seen := make([]string, 0, 2) - deadline := time.After(time.Second) - for len(seen) < 2 { - select { - case event := <-historyEvents: - if event.GetKind() == "running" { - seen = append(seen, event.GetConversationId()) - } - case <-deadline: - t.Fatalf("timed out waiting for running events, saw %#v", seen) - } - } - want := []string{"conversation-a", "conversation-b"} - if fmt.Sprint(seen) != fmt.Sprint(want) { - t.Fatalf("running history events = %#v, want %#v", seen, want) - } -} - -func TestStartedRunKeepsConversationOwnerWhenPreviousRunEmitsLateEvent(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - startRunningChatCommandRun(t, sm, "request-old", "conversation-1") - if _, created, err := sm.StartPendingChatCommandRun("request-new", "conversation-1", "client-new"); err != nil || !created { - t.Fatalf("StartPendingChatCommandRun new created=%v err=%v", created, err) - } - dispatchChatControl(sm, "request-new", "conversation-1", "started", session.ChatRunStateRunning) - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-old", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, + RunId: "run-1", ConversationId: "conversation-1", - Data: `{"text":"late old token"}`, + State: "completed", }, }, }) - - summaries := sm.ActiveChatRunSummaries() - if len(summaries) != 1 || - summaries[0].ConversationID != "conversation-1" || - summaries[0].RequestID != "request-new" { - t.Fatalf("active summaries = %#v, want request-new", summaries) - } - - sub, err := sm.SubscribeChatRun("", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("terminal snapshot should clear activity, got %#v", activities) } - defer sub.Cleanup() - assertDoneOpen(t, sub.Done) - if sub.Snapshot.RequestID != "request-new" { - t.Fatalf("conversation snapshot request id = %q, want request-new", sub.Snapshot.RequestID) - } -} - -func TestCompletedHistoryUpsertDoesNotPreemptTerminalChatEvent(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - started := startRunningChatCommandRun(t, sm, "request-1", "conversation-1") - - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", started.LatestSeq) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } - defer sub.Cleanup() + // A stale "running" snapshot after the terminal must not resurrect the run. sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-1", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-1", - Data: `{}`, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "history-sync-1", - Payload: &gatewayv1.AgentEnvelope_HistorySync{ - HistorySync: &gatewayv1.HistorySyncEvent{ - Kind: "upsert", + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatRuntimeSnapshot{ + ChatRuntimeSnapshot: &gatewayv1.ChatRuntimeSnapshot{ + RunId: "run-1", ConversationId: "conversation-1", - Conversation: &gatewayv1.ConversationSummary{ - Id: "conversation-1", - }, + State: "running", }, }, }) - - select { - case event := <-sub.EventCh: - if event.Event.GetType() != gatewayv1.ChatEvent_DONE { - t.Fatalf("event type = %v, want DONE", event.Event.GetType()) - } - case <-time.After(time.Second): - t.Fatalf("timed out waiting for terminal chat event") - } - - missingSub, err := sm.SubscribeChatRun("", "conversation-1", 0) - defer missingSub.Cleanup() - assertDoneClosed(t, missingSub.Done) - if !errors.Is(err, session.ErrChatRunNotFound) { - t.Fatalf("SubscribeChatRun after release = %v, want ErrChatRunNotFound", err) - } -} - -func TestActiveChatRunSummariesReturnOpenRuns(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - startRunningChatCommandRun(t, sm, "request-b", "conversation-b") - startRunningChatCommandRun(t, sm, "request-a", "conversation-a") - startRunningChatCommandRun(t, sm, "request-empty", "") - startRunningChatCommandRun(t, sm, "request-a-duplicate", "conversation-a") - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "request-b", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: "conversation-b", - Data: `{}`, - }, - }, - }) - - got := activeChatRunConversationIDs(sm) - want := []string{"conversation-a"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat run conversation ids = %#v, want %#v", got, want) - } -} - -func TestClearSessionPreservesOpenChatRuns(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sess := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(sess) - first, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - "client-submit-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun: %v", err) - } - if !created || first.RequestID != "request-1" { - t.Fatalf("first run = %#v created=%v", first, created) + if activities := sm.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("stale running snapshot resurrected the run: %#v", activities) } - sub, err := sm.SubscribeChatRun("request-1", "conversation-1", 0) - if err != nil { - t.Fatalf("SubscribeChatRun: %v", err) - } + sub := sm.SubscribeConversationStream("conversation-1", 0, "") defer sub.Cleanup() - - sm.ClearSession(sess) - assertDoneClosed(t, sess.Done()) - assertDoneOpen(t, sub.Done) - - select { - case event := <-sub.EventCh: - t.Fatalf("unexpected chat event after session clear: %#v", event) - default: - } - - got := activeChatRunConversationIDs(sm) - want := []string{"conversation-1"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after disconnect = %#v, want %#v", got, want) - } - - restarted, created, err := sm.StartPendingChatCommandRun( - "request-1", - "conversation-1", - ) - if err != nil { - t.Fatalf("StartPendingChatCommandRun retry: %v", err) - } - if created || restarted.RequestID != "request-1" { - t.Fatalf("retry run = %#v created=%v, want preserved request-1", restarted, created) - } - - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.ApplyChatRuntimeSnapshot(&gatewayv1.ChatRuntimeSnapshot{ - ConversationId: "conversation-1", - RunId: "request-1", - State: session.ChatRunStateRunning, - EntriesJson: `[{"id":"u1","kind":"user","text":"hello","attachments":[]}]`, - Revision: 1, - }) - if snapshot, ok := sm.ChatRunSnapshot("request-1", "conversation-1"); !ok || snapshot.State != session.ChatRunStateRunning || snapshot.Done { - t.Fatalf("snapshot after reconnect = %#v ok=%v, want running request-1", snapshot, ok) - } -} - -func TestStaleClearSessionDoesNotFailReplacementChatRun(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - first := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(first) - second := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(second) - - startRunningChatCommandRun(t, sm, "request-current", "conversation-current") - sm.ClearSession(first) - - got := activeChatRunConversationIDs(sm) - want := []string{"conversation-current"} - if fmt.Sprint(got) != fmt.Sprint(want) { - t.Fatalf("active chat runs after stale clear = %#v, want %#v", got, want) - } - assertDoneOpen(t, second.Done()) -} - -func TestChatQueueEventsReplayLatestSnapshotToNewSubscribers(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "queue-event-1", - Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ - ChatQueueEvent: &gatewayv1.ChatQueueEvent{ - ConversationId: " conversation-1 ", - SnapshotJson: `{"conversationId":"conversation-1","revision":2,"items":[{"id":"queue-1"}]}`, - Revision: 2, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "queue-event-stale", - Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ - ChatQueueEvent: &gatewayv1.ChatQueueEvent{ - ConversationId: "conversation-1", - SnapshotJson: `{"conversationId":"conversation-1","revision":1,"items":[]}`, - Revision: 1, - }, - }, - }) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "queue-event-zero", - Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ - ChatQueueEvent: &gatewayv1.ChatQueueEvent{ - ConversationId: "conversation-1", - SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, - Revision: 0, - }, - }, - }) - - cached, ok := sm.ChatQueueSnapshot("conversation-1") - if !ok || cached.GetRevision() != 2 || !strings.Contains(cached.GetSnapshotJson(), "queue-1") { - t.Fatalf("cached queue snapshot = %#v ok=%v, want revision 2 with queue-1", cached, ok) - } - - events, cleanup := sm.SubscribeChatQueueEvents() - defer cleanup() - select { - case event := <-events: - if event.GetConversationId() != "conversation-1" || - event.GetRevision() != 2 || - !strings.Contains(event.GetSnapshotJson(), "queue-1") { - t.Fatalf("replayed queue snapshot = %#v, want latest revision 2", event) + finished := 0 + for _, event := range sub.Events { + if event.Type == "run_finished" { + finished++ } - case <-time.After(time.Second): - t.Fatal("timed out waiting for replayed queue snapshot") } -} - -func TestChatQueueSnapshotAllowsNewSessionToResetRevision(t *testing.T) { - t.Parallel() - - sm := newTestSessionManager() - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "queue-event-1", - Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ - ChatQueueEvent: &gatewayv1.ChatQueueEvent{ - ConversationId: "conversation-1", - SnapshotJson: `{"conversationId":"conversation-1","revision":5,"items":[{"id":"queue-1"}]}`, - Revision: 5, - }, - }, - }) - - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "queue-event-reset", - Payload: &gatewayv1.AgentEnvelope_ChatQueueEvent{ - ChatQueueEvent: &gatewayv1.ChatQueueEvent{ - ConversationId: "conversation-1", - SnapshotJson: `{"conversationId":"conversation-1","revision":0,"items":[]}`, - Revision: 0, - }, - }, - }) - - cached, ok := sm.ChatQueueSnapshot("conversation-1") - if !ok || cached.GetRevision() != 0 || strings.Contains(cached.GetSnapshotJson(), "queue-1") { - t.Fatalf("cached queue snapshot after new session = %#v ok=%v, want empty revision 0", cached, ok) + if finished != 1 { + t.Fatalf("run_finished events = %d, want exactly 1", finished) } } diff --git a/crates/agent-gateway/test/websocket/chat_test.go b/crates/agent-gateway/test/websocket/chat_test.go new file mode 100644 index 00000000..5be48aa2 --- /dev/null +++ b/crates/agent-gateway/test/websocket/chat_test.go @@ -0,0 +1,347 @@ +package websocket_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/server" + "github.com/liveagent/agent-gateway/internal/session" +) + +func newChatWebSocketTest(t *testing.T) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { + t.Helper() + + sm := session.NewManager() + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := server.NewWebSocketServer(&config.Config{ + Token: "ws-token", + RequestTimeout: time.Second, + }, sm) + conn, cleanup := dialGatewayWebSocket(t, handler) + authWebSocket(t, conn, "ws-token") + return sm, agentSession, conn, cleanup +} + +func decodePayload(t *testing.T, env wsEnvelope) map[string]any { + t.Helper() + payload := map[string]any{} + if len(env.Payload) > 0 { + if err := json.Unmarshal(env.Payload, &payload); err != nil { + t.Fatalf("decode payload for %s: %v", env.Type, err) + } + } + return payload +} + +// receiveEventOfType skips unrelated frames (pings, other event types) until +// an envelope of the wanted type arrives. +func receiveEventOfType(t *testing.T, conn *websocket.Conn, eventType string) map[string]any { + t.Helper() + for attempt := 0; attempt < 16; attempt++ { + env := receiveEnvelope(t, conn) + if env.Type == eventType { + return decodePayload(t, env) + } + } + t.Fatalf("timed out waiting for %s event", eventType) + return nil +} + +func dispatchStarted(sm *session.Manager, runID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "started", + State: "running", + }, + }, + }) +} + +func dispatchToken(sm *session.Manager, runID string, conversationID string, text string) { + data, _ := json.Marshal(map[string]any{"text": text}) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOKEN, + ConversationId: conversationID, + Data: string(data), + }, + }, + }) +} + +func dispatchDone(sm *session.Manager, runID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_DONE, + ConversationId: conversationID, + Data: "{}", + }, + }, + }) +} + +// The subscription is conversation-scoped and persists across run boundaries: +// a queued prompt auto-send (new run started by the desktop app) streams into +// the same subscription with no re-subscribe handshake. +func TestChatSubscribePersistsAcrossRunHandoff(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) + if resp["conversation_id"] != "conversation-1" || resp["stream_epoch"] == "" { + t.Fatalf("subscribe response = %#v", resp) + } + if resp["activity"] != nil { + t.Fatalf("idle conversation must report nil activity, got %#v", resp["activity"]) + } + + dispatchStarted(sm, "run-1", "conversation-1") + started := receiveEventOfType(t, conn, "chat.event") + if started["type"] != "run_started" || started["run_id"] != "run-1" { + t.Fatalf("first push = %#v, want run_started run-1", started) + } + + dispatchToken(sm, "run-1", "conversation-1", "hello") + token := receiveEventOfType(t, conn, "chat.event") + if token["type"] != "token" || token["run_id"] != "run-1" || token["text"] != "hello" { + t.Fatalf("token push = %#v", token) + } + + dispatchDone(sm, "run-1", "conversation-1") + finished := receiveEventOfType(t, conn, "chat.event") + if finished["type"] != "run_finished" || finished["status"] != "completed" { + t.Fatalf("finish push = %#v", finished) + } + + // Queue auto-send: a new run flows into the same subscription. + dispatchStarted(sm, "run-2", "conversation-1") + second := receiveEventOfType(t, conn, "chat.event") + if second["type"] != "run_started" || second["run_id"] != "run-2" { + t.Fatalf("handoff push = %#v, want run_started run-2", second) + } + dispatchToken(sm, "run-2", "conversation-1", "again") + tail := receiveEventOfType(t, conn, "chat.event") + if tail["run_id"] != "run-2" || tail["text"] != "again" { + t.Fatalf("handoff token = %#v", tail) + } +} + +func TestChatActivityBroadcastCarriesRunIDs(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + dispatchStarted(sm, "run-1", "conversation-1") + running := receiveEventOfType(t, conn, "chat.activity") + if running["running"] != true || running["run_id"] != "run-1" || running["conversation_id"] != "conversation-1" { + t.Fatalf("running activity = %#v", running) + } + + dispatchDone(sm, "run-1", "conversation-1") + idle := receiveEventOfType(t, conn, "chat.activity") + if idle["running"] != false || idle["conversation_id"] != "conversation-1" { + t.Fatalf("idle activity = %#v", idle) + } +} + +func TestChatCommandSubmitSeedsUserMessageAndDeliversEnvelope(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + receiveEnvelopeWithID(t, conn, "sub-1") + + sendEnvelope(t, conn, "cmd-1", "chat.command", map[string]any{ + "type": "chat.submit", + "payload": map[string]any{ + "message": "hello agent", + "conversation_id": "conversation-1", + "client_request_id": "client-1", + }, + }) + + resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "cmd-1")) + runID, _ := resp["run_id"].(string) + if runID == "" || resp["conversation_id"] != "conversation-1" { + t.Fatalf("command response = %#v", resp) + } + if seq, ok := resp["accepted_seq"].(float64); !ok || seq <= 0 { + t.Fatalf("accepted_seq = %#v, want > 0", resp["accepted_seq"]) + } + + seeded := receiveEventOfType(t, conn, "chat.event") + if seeded["type"] != "user_message" || + seeded["message"] != "hello agent" || + seeded["client_request_id"] != "client-1" || + seeded["run_id"] != runID { + t.Fatalf("seeded user_message = %#v", seeded) + } + + outbound := readOutboundEnvelope(t, agentSession) + command := outbound.GetChatCommand() + if command == nil || command.GetType() != "chat.submit" { + t.Fatalf("outbound payload = %#v, want chat.submit command", outbound.GetPayload()) + } + if command.GetRequest().GetMessage() != "hello agent" || + command.GetRequest().GetClientRequestId() != "client-1" { + t.Fatalf("chat command request = %#v", command.GetRequest()) + } + + // The agent's user_message echo is swallowed: the next stream event after + // run start must not duplicate the seeded message. + dispatchStarted(sm, runID, "conversation-1") + startedPush := receiveEventOfType(t, conn, "chat.event") + if startedPush["type"] != "run_started" { + t.Fatalf("post-start push = %#v", startedPush) + } + echoData, _ := json.Marshal(map[string]any{"message": "hello agent"}) + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatEvent{ + ChatEvent: &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conversation-1", + Data: string(echoData), + }, + }, + }) + dispatchToken(sm, runID, "conversation-1", "reply") + next := receiveEventOfType(t, conn, "chat.event") + if next["type"] != "token" || next["text"] != "reply" { + t.Fatalf("expected token after swallowed echo, got %#v", next) + } +} + +func TestChatCancelKeepsRunAliveUntilAgentConfirms(t *testing.T) { + t.Parallel() + + sm, agentSession, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + receiveEnvelopeWithID(t, conn, "sub-1") + + dispatchStarted(sm, "run-1", "conversation-1") + receiveEventOfType(t, conn, "chat.event") + + sendEnvelope(t, conn, "cancel-1", "chat.command", map[string]any{ + "type": "chat.cancel", + "payload": map[string]any{"conversation_id": "conversation-1"}, + }) + + // The gateway blocks the response on agent delivery; ack it first. + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetChatCommand().GetType() != "chat.cancel" { + t.Fatalf("outbound cancel = %#v", outbound.GetPayload()) + } + + // The run is NOT terminalized by the gateway: activity flips to + // cancelling and the agent's terminal signal wins. The response and the + // activity push race on the outbox, so collect both order-independently. + var cancelling map[string]any + var resp map[string]any + for attempt := 0; attempt < 16 && (cancelling == nil || resp == nil); attempt++ { + env := receiveEnvelope(t, conn) + switch { + case env.Type == "chat.activity": + payload := decodePayload(t, env) + if payload["state"] == "cancelling" { + cancelling = payload + } + case env.ID == "cancel-1": + resp = decodePayload(t, env) + } + } + if cancelling == nil || cancelling["running"] != true { + t.Fatalf("cancelling activity = %#v", cancelling) + } + if resp == nil || resp["ok"] != true || resp["run_id"] != "run-1" { + t.Fatalf("cancel response = %#v", resp) + } + + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: "run-1", + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: "run-1", + ConversationId: "conversation-1", + Type: "cancelled", + State: "cancelled", + }, + }, + }) + finished := receiveEventOfType(t, conn, "chat.event") + if finished["type"] != "run_finished" || finished["status"] != "cancelled" { + t.Fatalf("cancel finish = %#v", finished) + } +} + +func TestChatSubscribeResumesWithAfterSeq(t *testing.T) { + t.Parallel() + + sm, _, conn, cleanup := newChatWebSocketTest(t) + defer cleanup() + + dispatchStarted(sm, "run-1", "conversation-1") + dispatchToken(sm, "run-1", "conversation-1", "one") + dispatchToken(sm, "run-1", "conversation-1", "two") + + sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + }) + first := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) + events, _ := first["events"].([]any) + if len(events) != 3 { + t.Fatalf("full replay = %d events, want 3", len(events)) + } + epoch, _ := first["stream_epoch"].(string) + latest, _ := first["latest_seq"].(float64) + + sendEnvelope(t, conn, "sub-2", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + "after_seq": latest - 1, + "stream_epoch": epoch, + }) + resumed := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-2")) + resumedEvents, _ := resumed["events"].([]any) + if resumed["reset"] != false || len(resumedEvents) != 1 { + t.Fatalf("resume = reset:%v events:%d, want reset:false events:1", resumed["reset"], len(resumedEvents)) + } + + sendEnvelope(t, conn, "sub-3", "chat.subscribe", map[string]any{ + "conversation_id": "conversation-1", + "after_seq": latest, + "stream_epoch": "stale-epoch", + }) + mismatched := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-3")) + if mismatched["reset"] != true { + t.Fatalf("epoch mismatch must reset, got %#v", mismatched) + } +} diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index 7247f991..9ba6f4ec 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -42,7 +42,6 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 |---|---|---| | `GET /ws` | token | WebUI 主 WebSocket 协议。 | | `GET /ws/terminal` | token | WebUI 终端专用 WebSocket;首帧 JSON auth,后续使用 `version + kind + headerLength + JSON header + bytes` 的二进制 frame。 | -| `POST /api/chat/commands` | token + CSRF + Origin | WebUI 提交 `chat.submit`、`chat.edit_resend`、`chat.cancel`。 | | `GET /api/chat/events` | token + Origin | WebUI 使用 fetch SSE 订阅/恢复 Chat 事件。 | | `GET /api/status` | token | Gateway 当前 Agent 在线状态。 | | `POST /api/files/import` | token | WebUI 上传可读文件,Gateway 转发给桌面端导入 workspace uploads。 | @@ -103,7 +102,7 @@ Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、 | 领域 | 设计 | |---|---| | 认证 | HTTP API 与 WebSocket 通过 token;gRPC 通过 interceptor 校验 token。 | -| Chat command 防护 | `POST /api/chat/commands` 要求 token、Origin 校验、`X-LiveAgent-CSRF`、2 MiB payload 上限和固定窗口 rate limit。 | +| Chat command 防护 | Chat 命令仅经认证后的 WebSocket `chat.command` 提交(连接级 token + Origin 校验,2 MiB payload 上限)。 | | Chat SSE 防护 | `GET /api/chat/events` 要求 token、Origin 校验、固定窗口 rate limit;replay 单次最多返回 `maxBufferedChatRunEvents` 条。 | | Provider API key | 普通 settings sync 不应携带真实 key;WebUI 只接收 presence/redacted 字段。 | | 文件访问 | WebUI 上传只把 bytes 交给桌面端导入,Gateway 不直接落地为任意本地路径。 | diff --git a/docs/architecture/protocols.md b/docs/architecture/protocols.md index a82148f5..0270517d 100644 --- a/docs/architecture/protocols.md +++ b/docs/architecture/protocols.md @@ -9,7 +9,7 @@ | gRPC stream | `AgentGateway.AgentTerminalConnect` | Desktop <-> Gateway | 终端专用字节流,承载 `TerminalStreamFrame`,避免 terminal IO 与 chat/settings/history 队头阻塞。 | | WebSocket | `GET /ws` | WebUI <-> Gateway | WebUI 非 Chat 请求/响应、状态广播、history/settings 等同步。 | | WebSocket | `GET /ws/terminal` | WebUI <-> Gateway | WebUI 终端专用二进制流;JSON 控制帧只用于 auth/error,热路径为 bytes frame。 | -| HTTP Chat Command | `POST /api/chat/commands` | WebUI -> Gateway -> Desktop | Chat 提交、编辑重发、取消;命令先被 Gateway accepted,再异步下发桌面端。 | +| WS Chat Command | WebSocket `chat.command` | WebUI -> Gateway -> Desktop | Chat 提交、编辑重发、取消;命令先被 Gateway accepted,再异步下发桌面端。 | | HTTP Chat Events | `GET /api/chat/events` | Gateway -> WebUI | fetch-based SSE;按 `conversation_id` 与 `after_seq` 恢复事件流。 | | HTTP API | `/api/status` | WebUI -> Gateway | 查询 Agent 在线状态。 | | HTTP upload | `/api/files/import` | WebUI -> Gateway -> Desktop | 上传可读文件并导入桌面 workspace。 | @@ -28,10 +28,10 @@ | 阶段 | WebUI -> Gateway | Gateway -> Desktop | Desktop -> Gateway -> WebUI | |---|---|---|---| -| 提交 | `POST /api/chat/commands`,`type=chat.submit` | `ChatCommandRequest{type=chat.submit}` | 先返回 `run.accepted`,再通过 SSE 推送用户消息、runtime 与 token 事件。 | -| 编辑重发 | `POST /api/chat/commands`,`type=chat.edit_resend` | `ChatCommandRequest{type=chat.edit_resend, base_message_ref}` | Gateway 先发布 `conversation.rebased` 与新用户消息事件,桌面端随后原子截断并运行新 turn。 | +| 提交 | WS `chat.command`,`type=chat.submit` | `ChatCommandRequest{type=chat.submit}` | 响应携带 `run_id`/`accepted_seq`;用户消息与 token 事件经会话订阅 `chat.event` 推送。 | +| 编辑重发 | WS `chat.command`,`type=chat.edit_resend` | `ChatCommandRequest{type=chat.edit_resend, base_message_ref}` | Gateway 先发布 `rebased` 与新用户消息事件,桌面端随后原子截断并运行新 turn。 | | 恢复 | `GET /api/chat/events?conversation_id=&after_seq=` | 无 | WebUI 先用 history snapshot/projection hydrate,再由 Gateway 从 SQLite `chat_events` 按 conversation seq 跨 run 补发缺失事件;内存缓存同样按 conversation 汇总最近 run 事件并负责实时 fan-out。 | -| 取消 | `POST /api/chat/commands`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | 后续事件流发布 `run.cancelled` 或桌面端终态事件。 | +| 取消 | WS `chat.command`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | Gateway 置 `cancelling` 状态,桌面端真实终态优先,超时由 watchdog 兜底 `run_finished(cancelled)`。 | | 完成 | 无 | 无 | `ChatEvent.type=DONE` 映射为 `run.completed` 终态。 | 桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。旧 WebSocket Chat 路由已下线。 diff --git a/docs/architecture/webui.md b/docs/architecture/webui.md index 46bb156a..16e03e01 100644 --- a/docs/architecture/webui.md +++ b/docs/architecture/webui.md @@ -26,7 +26,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | socket 创建 | `getGatewayWebSocketClient(token)` 建立 `/ws` 连接。 | | 状态订阅 | 订阅 Gateway status,展示 Desktop Agent online/offline。 | | 请求响应 | 所有 request 带 id,Gateway 用同 id 返回 payload 或 error。 | -| Chat 流 | 提交/编辑/取消走 `POST /api/chat/commands`,流式输出走 `GET /api/chat/events` fetch SSE。 | +| Chat 流 | 提交/编辑/取消走 WebSocket `chat.command`;流式输出走按会话持久订阅 `chat.subscribe`(`chat.event` 推送,seq 续传)。 | | 断线恢复 | WebSocket client 处理普通同步重连;Chat SSE 在 history snapshot hydrate 后按同 conversation 单调递增的 `after_seq` 或 `Last-Event-ID` 跨 run 补齐缺失事件;观察正在运行的远程会话时优先使用 `history.list.running_conversations[].first_seq - 1` 作为当前 run 的订阅起点。 | ## WebUI 本地状态 From 455057dc7448939ed7fe2722a2e5f1b46af9dbfd Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 11:56:03 +0800 Subject: [PATCH 17/64] feat(webui): add conversation stream transport and transcript stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New web/src/lib/chat/stream modules implementing the client half of the conversation-scoped protocol: - ConversationStreamClient: persistent per-conversation registrations inside the socket layer with automatic seq-cursor resume on every reconnect, pre-sync event buffering, gap-triggered resync, and overflow recovery - transcriptStore: GUI-style committed/settled/live split — replies settle in the tail with unchanged ids and fold into committed at the next run_started; optimistic user entries adopted by client_request_id; id-preserving history merge; rebased truncation; snapshot rebuild - activityStore: single source for running dots, fed by chat.activity and history.list hydration - ChatCommandPipeline: optimistic echo + command_update outcomes (bound/queued_in_gui/failed) with a 60s pending watchdog - gatewaySocket: chat generators, per-run subscriptions, __gatewayRunId metadata and subscription_end handling removed; chatCommand promise, stream client wiring into auth/disconnect, chat.activity / chat.command_update fan-out; chat streams now count toward shouldMaintainConnection - pushChatEvent gains entryIdPrefix so entries of different runs never collide on id Co-Authored-By: Claude Fable 5 --- .../test/webui/chat-command-pipeline.test.mjs | 147 +++++ .../test/webui/chat-stream-recovery.test.mjs | 89 --- .../webui/conversation-stream-client.test.mjs | 193 +++++++ .../web/src/lib/chat/stream/activityStore.ts | 144 +++++ .../lib/chat/stream/chatCommandPipeline.ts | 192 +++++++ .../chat/stream/conversationStreamClient.ts | 201 +++++++ .../web/src/lib/chat/stream/streamTypes.ts | 245 ++++++++ .../src/lib/chat/stream/transcriptStore.ts | 535 ++++++++++++++++++ crates/agent-gateway/web/src/lib/chatUi.ts | 33 +- .../web/src/lib/gatewaySocket.ts | 272 ++++----- .../web/test/activity-store.test.mjs | 85 +++ .../web/test/liveConversationCommit.test.mjs | 59 -- .../test/liveConversationStreamStore.test.mjs | 201 ------- .../web/test/transcript-store.test.mjs | 280 +++++++++ 14 files changed, 2144 insertions(+), 532 deletions(-) create mode 100644 crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs delete mode 100644 crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs create mode 100644 crates/agent-gateway/test/webui/conversation-stream-client.test.mjs create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts create mode 100644 crates/agent-gateway/web/test/activity-store.test.mjs delete mode 100644 crates/agent-gateway/web/test/liveConversationCommit.test.mjs delete mode 100644 crates/agent-gateway/web/test/liveConversationStreamStore.test.mjs create mode 100644 crates/agent-gateway/web/test/transcript-store.test.mjs diff --git a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs new file mode 100644 index 00000000..485d07e2 --- /dev/null +++ b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs @@ -0,0 +1,147 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { ChatCommandPipeline } = loader.loadModule("src/lib/chat/stream/chatCommandPipeline.ts"); +const { createTranscriptStore } = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); + +function createHarness() { + const stores = new Map(); + const outcomes = { bound: [], queued: [], failed: [] }; + const pipeline = new ChatCommandPipeline({ + getTranscriptStore(conversationId) { + let store = stores.get(conversationId); + if (!store) { + store = createTranscriptStore(); + stores.set(conversationId, store); + } + return store; + }, + onBound(update, pending) { + outcomes.bound.push({ update, pending }); + }, + onQueuedInGui(update, pending) { + outcomes.queued.push({ update, pending }); + }, + onFailed(pending, errorCode, message) { + outcomes.failed.push({ pending, errorCode, message }); + }, + }); + return { pipeline, stores, outcomes }; +} + +function tailTexts(store) { + store.flush(); + return store.getSnapshot().tail.map((entry) => entry.text ?? ""); +} + +test("submit inserts the optimistic bubble and resolves the accepted run", async () => { + const { pipeline, stores } = createHarness(); + const outcome = await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }), + }); + + assert.equal(outcome.kind, "accepted"); + assert.equal(outcome.accepted.runId, "run-1"); + assert.deepEqual(tailTexts(stores.get("conv-1")), ["hello"]); + assert.equal(pipeline.hasPending("conv-1"), true); + + // The stream's run signal settles the pending spinner. + pipeline.handleRunSignal("conv-1", "run-1"); + assert.equal(pipeline.hasPending("conv-1"), false); +}); + +test("submit failure removes the bubble and surfaces an error entry", async () => { + const { pipeline, stores, outcomes } = createHarness(); + const outcome = await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => { + throw new Error("agent offline"); + }, + }); + + assert.equal(outcome.kind, "failed"); + assert.equal(pipeline.hasPending("conv-1"), false); + const texts = tailTexts(stores.get("conv-1")); + assert.equal(texts.some((text) => text === "hello"), false, "optimistic bubble removed"); + assert.equal(texts.some((text) => /agent offline/.test(text)), true); + assert.equal(outcomes.failed.length, 1); +}); + +test("bound update re-keys a draft conversation", async () => { + const { pipeline, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "draft-1", + clientRequestId: "client-1", + message: "first message", + submit: async () => ({ runId: "run-1", conversationId: "", acceptedSeq: 0 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-real", + phase: "bound", + errorCode: null, + message: null, + }); + + assert.equal(outcomes.bound.length, 1); + assert.equal(outcomes.bound[0].pending.conversationId, "conv-real"); + assert.equal(pipeline.hasPending("draft-1"), false); + assert.equal(pipeline.hasPending("conv-real"), true); +}); + +test("queued_in_gui clears pending and removes the optimistic bubble", async () => { + const { pipeline, stores, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "park me", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 1 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "queued_in_gui", + errorCode: null, + message: null, + }); + + assert.equal(pipeline.hasPending("conv-1"), false); + assert.equal(outcomes.queued.length, 1); + assert.deepEqual(tailTexts(stores.get("conv-1")), [], "bubble removed; queue panel owns it"); +}); + +test("failed update surfaces the gateway error", async () => { + const { pipeline, stores, outcomes } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "doomed", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 1 }), + }); + + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "failed", + errorCode: "startup_timeout", + message: "did not start", + }); + + assert.equal(pipeline.hasPending("conv-1"), false); + assert.equal(outcomes.failed.length, 1); + assert.equal(outcomes.failed[0].errorCode, "startup_timeout"); + const texts = tailTexts(stores.get("conv-1")); + assert.equal(texts.some((text) => /did not start/.test(text)), true); +}); diff --git a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs b/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs deleted file mode 100644 index 6a81c845..00000000 --- a/crates/agent-gateway/test/webui/chat-stream-recovery.test.mjs +++ /dev/null @@ -1,89 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; - -test("chat stream recovery detects released attach streams", () => { - const loader = createWebModuleLoader(); - const { - isChatStreamNotAvailableEvent, - isChatStreamNotAvailableMessage, - isRecoverableChatStreamTransportMessage, - isRecoverableChatStreamTransportStatus, - resolveChatStreamUnavailableRecoveryAction, - shouldHydrateRestoredConversationSnapshot, - } = loader.loadModule("src/lib/chatStreamRecovery.ts"); - - assert.equal(isChatStreamNotAvailableMessage("chat stream not available"), true); - assert.equal( - isChatStreamNotAvailableMessage(new Error("Error: chat stream not available")), - true, - ); - assert.equal(isChatStreamNotAvailableMessage("chat request failed"), false); - assert.equal(isRecoverableChatStreamTransportStatus(502), true); - assert.equal(isRecoverableChatStreamTransportStatus(404), false); - assert.equal( - isRecoverableChatStreamTransportMessage( - "502 Bad Gatewaynginx", - ), - true, - ); - assert.equal(isRecoverableChatStreamTransportMessage("model rejected the request"), false); - - assert.equal( - isChatStreamNotAvailableEvent({ - type: "error", - message: "chat stream not available", - conversation_id: "conversation-1", - }), - true, - ); - assert.equal( - isChatStreamNotAvailableEvent({ - type: "done", - conversation_id: "conversation-1", - }), - false, - ); - assert.equal( - resolveChatStreamUnavailableRecoveryAction("conversation-1"), - "refresh-history-snapshot", - ); - assert.equal( - resolveChatStreamUnavailableRecoveryAction("__local_draft__:conversation-1"), - "reload-history", - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - liveEntries: [{ id: "live-assistant", kind: "assistant", text: "partial", round: 1 }], - historyEntries: [ - { id: "history-user", kind: "user", text: "hello", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "partial and final", round: 1 }, - ], - }), - true, - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - historyEntries: [{ id: "history-user", kind: "user", text: "hello", attachments: [] }], - }), - false, - ); - - assert.equal( - shouldHydrateRestoredConversationSnapshot({ - currentEntries: [{ id: "local-user", kind: "user", text: "hello", attachments: [] }], - liveEntries: [ - { id: "live-assistant", kind: "assistant", text: "partial text that is newer", round: 1 }, - ], - historyEntries: [ - { id: "history-user", kind: "user", text: "hello", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "partial", round: 1 }, - ], - }), - false, - ); -}); diff --git a/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs new file mode 100644 index 00000000..5c289b3f --- /dev/null +++ b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader(); +const { ConversationStreamClient } = loader.loadModule( + "src/lib/chat/stream/conversationStreamClient.ts", +); + +function createTransport() { + const calls = []; + let responder = () => ({}); + return { + calls, + setResponder(fn) { + responder = fn; + }, + request(type, payload) { + calls.push({ type, payload }); + return Promise.resolve(responder(type, payload)); + }, + }; +} + +function subscribeResponse(overrides = {}) { + return { + conversation_id: "conv-1", + stream_epoch: "epoch-1", + latest_seq: 0, + reset: false, + activity: null, + snapshot: null, + events: [], + ...overrides, + }; +} + +function collectHandlers() { + const seen = { syncs: [], events: [] }; + return { + seen, + handlers: { + onSync(result) { + seen.syncs.push(result); + }, + onEvent(event) { + seen.events.push(event); + }, + }, + }; +} + +async function flushMicrotasks() { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +test("subscribes with resume cursor and re-subscribes after reconnect", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 4 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + assert.equal(transport.calls.length, 1); + assert.equal(transport.calls[0].type, "chat.subscribe"); + assert.equal(transport.calls[0].payload.after_seq, 0); + assert.equal(seen.syncs.length, 1); + + // Live events advance the cursor. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 5, text: "a" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 6, text: "b" }); + assert.equal(seen.events.length, 2); + + // Disconnect + reconnect: the registration survives and resumes from seq 6 + // with the stream epoch. + client.handleDisconnected(); + transport.setResponder(() => subscribeResponse({ latest_seq: 8, events: [ + { type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 7, text: "c" }, + { type: "token", conversation_id: "conv-1", run_id: "run-1", seq: 8, text: "d" }, + ] })); + client.handleConnected(); + await flushMicrotasks(); + + assert.equal(transport.calls.length, 2); + assert.equal(transport.calls[1].payload.after_seq, 6); + assert.equal(transport.calls[1].payload.stream_epoch, "epoch-1"); + assert.equal(seen.syncs.length, 2); + assert.equal(seen.syncs[1].events.length, 2); +}); + +test("duplicate and stale seqs are dropped; gaps trigger a resync", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 2 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "dup" }); + assert.equal(seen.events.length, 0, "stale seq dropped"); + + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 3, text: "ok" }); + assert.equal(seen.events.length, 1); + + transport.setResponder(() => subscribeResponse({ latest_seq: 9, events: [] })); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 9, text: "gap" }); + await flushMicrotasks(); + assert.equal(seen.events.length, 1, "gapped event not delivered directly"); + assert.equal( + transport.calls.filter((call) => call.type === "chat.subscribe").length, + 2, + "gap triggered a resync", + ); +}); + +test("events racing ahead of the subscribe response are buffered, then drained", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + transport.setResponder(() => gate.then(() => subscribeResponse({ latest_seq: 1 }))); + + client.subscribe("conv-1", handlers); + client.handleConnected(); + + // Pushes arrive while the subscribe response is still in flight. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "early" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 3, text: "birds" }); + assert.equal(seen.events.length, 0); + + release(); + await flushMicrotasks(); + assert.equal(seen.syncs.length, 1); + assert.deepEqual( + seen.events.map((event) => event.text), + ["early", "birds"], + ); +}); + +test("seq-less events (snapshot pushes) pass through without cursor changes", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 5 })); + client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleChatEvent({ type: "snapshot", conversation_id: "conv-1", run_id: "run-1", entries_json: "[]" }); + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 6, text: "next" }); + assert.deepEqual( + seen.events.map((event) => event.type), + ["snapshot", "token"], + ); +}); + +test("subscription_reset resumes from the cursor; cleanup unsubscribes", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { handlers } = collectHandlers(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 3 })); + const cleanup = client.subscribe("conv-1", handlers); + client.handleConnected(); + await flushMicrotasks(); + + client.handleSubscriptionReset({ conversation_id: "conv-1" }); + await flushMicrotasks(); + const subscribes = transport.calls.filter((call) => call.type === "chat.subscribe"); + assert.equal(subscribes.length, 2); + assert.equal(subscribes[1].payload.after_seq, 3); + + assert.equal(client.size, 1); + cleanup(); + await flushMicrotasks(); + assert.equal(client.size, 0); + assert.equal( + transport.calls.filter((call) => call.type === "chat.unsubscribe").length, + 1, + ); +}); diff --git a/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts new file mode 100644 index 00000000..6ba420d1 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts @@ -0,0 +1,144 @@ +import type { ConversationActivityEvent, RunActivityState } from "./streamTypes"; + +// The single source of truth for "which conversations have an active run": +// sidebar dots, busy indicators, everything. Fed by the always-on +// chat.activity broadcast plus history.list hydration — both carry run ids +// composed by the gateway inside the run-lifecycle transition, so there is no +// enrichment race and no local/remote union to reconcile. + +export type ConversationActivity = { + runId: string; + state: RunActivityState; + workdir: string | null; + updatedAt: number; +}; + +export type ActivitySnapshot = { + activities: ReadonlyMap; + revision: number; +}; + +export type ActivityStore = { + getSnapshot(): ActivitySnapshot; + subscribe(listener: () => void): () => void; + isRunning(conversationId: string): boolean; + get(conversationId: string): ConversationActivity | null; + applyActivityEvent(event: ConversationActivityEvent): void; + // history.list `running_conversations` hydration: authoritative snapshot of + // every active run at response time. + hydrate( + items: Array<{ + conversationId: string; + runId: string; + state?: string; + workdir?: string | null; + updatedAt?: number; + }>, + ): void; + clear(): void; +}; + +export function createActivityStore(): ActivityStore { + let activities = new Map(); + let snapshot: ActivitySnapshot = { activities, revision: 0 }; + const listeners = new Set<() => void>(); + + const emit = () => { + snapshot = { activities, revision: snapshot.revision + 1 }; + for (const listener of listeners) { + listener(); + } + }; + + const normalizeState = (state: string | undefined | null): RunActivityState => { + return state === "queued" || state === "cancelling" ? state : "running"; + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + isRunning: (conversationId) => activities.has(conversationId), + get: (conversationId) => activities.get(conversationId) ?? null, + + applyActivityEvent: (event) => { + const current = activities.get(event.conversationId); + // Activity events are state signals ordered per conversation by the + // gateway; a stale timestamp can only appear after a reconnect race — + // ignore anything older than what we already show. + if (current && event.updatedAt > 0 && event.updatedAt < current.updatedAt) { + return; + } + if (!event.running || !event.runId) { + if (!activities.has(event.conversationId)) { + return; + } + activities = new Map(activities); + activities.delete(event.conversationId); + emit(); + return; + } + const next: ConversationActivity = { + runId: event.runId, + state: event.state ?? "running", + workdir: event.workdir, + updatedAt: event.updatedAt, + }; + if ( + current && + current.runId === next.runId && + current.state === next.state && + current.workdir === next.workdir + ) { + return; + } + activities = new Map(activities); + activities.set(event.conversationId, next); + emit(); + }, + + hydrate: (items) => { + const next = new Map(); + for (const item of items) { + const conversationId = item.conversationId.trim(); + const runId = item.runId.trim(); + if (!conversationId || !runId) { + continue; + } + next.set(conversationId, { + runId, + state: normalizeState(item.state), + workdir: item.workdir?.trim() || null, + updatedAt: item.updatedAt ?? 0, + }); + } + let changed = next.size !== activities.size; + if (!changed) { + for (const [conversationId, activity] of next) { + const current = activities.get(conversationId); + if (!current || current.runId !== activity.runId || current.state !== activity.state) { + changed = true; + break; + } + } + } + if (!changed) { + return; + } + activities = next; + emit(); + }, + + clear: () => { + if (activities.size === 0) { + return; + } + activities = new Map(); + emit(); + }, + }; +} diff --git a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts new file mode 100644 index 00000000..33f1108d --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts @@ -0,0 +1,192 @@ +import type { ChatCommandAccepted, ChatCommandUpdate } from "./streamTypes"; +import type { TranscriptStore } from "./transcriptStore"; + +// Command lifecycle for chat submissions from this client. The pipeline owns +// the optimistic user echo and reacts to pre-stream outcomes +// (chat.command_update); everything after run start flows through the +// conversation stream and needs no pipeline involvement. + +export type ChatCommandRequest = { + conversationId: string; + clientRequestId: string; + message: string; + attachments?: Extract< + Parameters[0]["attachments"], + unknown + >; + submit: () => Promise; +}; + +export type PendingChatCommand = { + runId: string | null; + clientRequestId: string; + conversationId: string; + submittedAt: number; +}; + +export type ChatCommandOutcome = + | { kind: "accepted"; accepted: ChatCommandAccepted } + | { kind: "queued_in_gui"; update: ChatCommandUpdate } + | { kind: "bound"; update: ChatCommandUpdate } + | { kind: "failed"; errorCode: string | null; message: string }; + +export type ChatCommandPipelineHooks = { + getTranscriptStore(conversationId: string): TranscriptStore; + // A draft conversation got its real id: re-key stores/subscriptions. + onBound?(update: ChatCommandUpdate, pending: PendingChatCommand): void; + onQueuedInGui?(update: ChatCommandUpdate, pending: PendingChatCommand): void; + onFailed?(pending: PendingChatCommand, errorCode: string | null, message: string): void; + onPendingChanged?(): void; +}; + +const PENDING_COMMAND_TIMEOUT_MS = 60_000; + +export class ChatCommandPipeline { + // conversationId → pending command (one in-flight submission per + // conversation drives the pre-first-token spinner). + private pending = new Map(); + private byRunId = new Map(); + private timeouts = new Map>(); + + constructor(private readonly hooks: ChatCommandPipelineHooks) {} + + hasPending(conversationId: string): boolean { + return this.pending.has(conversationId); + } + + async submit(request: ChatCommandRequest): Promise { + const store = this.hooks.getTranscriptStore(request.conversationId); + store.addOptimisticUserEntry({ + clientRequestId: request.clientRequestId, + text: request.message, + attachments: request.attachments as never, + }); + + const pending: PendingChatCommand = { + runId: null, + clientRequestId: request.clientRequestId, + conversationId: request.conversationId, + submittedAt: Date.now(), + }; + this.setPending(request.conversationId, pending); + + try { + const accepted = await request.submit(); + pending.runId = accepted.runId; + this.byRunId.set(accepted.runId, pending); + return { kind: "accepted", accepted }; + } catch (error) { + const message = error instanceof Error ? error.message : "chat command failed"; + this.fail(pending, null, message); + return { kind: "failed", errorCode: null, message }; + } + } + + // chat.command_update push from the gateway (issuing connection only). + handleCommandUpdate(update: ChatCommandUpdate): void { + const pending = this.byRunId.get(update.runId); + if (!pending) { + return; + } + switch (update.phase) { + case "bound": { + if (update.conversationId && update.conversationId !== pending.conversationId) { + // Draft conversation materialized: the app re-keys stores and + // subscriptions, then the pending command follows the real id. + const previousConversationId = pending.conversationId; + pending.conversationId = update.conversationId; + this.movePending(previousConversationId, update.conversationId, pending); + this.hooks.onBound?.(update, pending); + } + return; + } + case "queued_in_gui": { + // The prompt is parked in the desktop queue: it is not pending + // anymore, the queue panel shows it. The seeded entries are removed + // by the stream's run_queued event; the optimistic entry may still + // exist if the seed never reached the stream (draft conversations). + this.hooks + .getTranscriptStore(pending.conversationId) + .removeOptimisticUserEntry(pending.clientRequestId); + this.clearPending(pending); + this.hooks.onQueuedInGui?.(update, pending); + return; + } + case "failed": { + this.fail(pending, update.errorCode, update.message ?? "chat command failed"); + return; + } + } + } + + // Stream signals that settle the pending command: the run started (tokens + // will flow), finished (failed fast), or was queued. + handleRunSignal(conversationId: string, runId: string): void { + const pending = this.pending.get(conversationId); + if (pending && (pending.runId === runId || pending.runId === null)) { + this.clearPending(pending); + } + } + + private fail(pending: PendingChatCommand, errorCode: string | null, message: string): void { + const store = this.hooks.getTranscriptStore(pending.conversationId); + store.removeOptimisticUserEntry(pending.clientRequestId); + store.appendLocalError(message); + this.clearPending(pending); + this.hooks.onFailed?.(pending, errorCode, message); + } + + private setPending(conversationId: string, pending: PendingChatCommand): void { + const existingTimeout = this.timeouts.get(conversationId); + if (existingTimeout !== undefined) { + clearTimeout(existingTimeout); + } + this.pending.set(conversationId, pending); + this.timeouts.set( + conversationId, + setTimeout(() => { + const current = this.pending.get(pending.conversationId); + if (current === pending) { + this.fail( + pending, + "startup_timeout", + "The desktop app did not start this request in time. Please retry.", + ); + } + }, PENDING_COMMAND_TIMEOUT_MS), + ); + this.hooks.onPendingChanged?.(); + } + + private movePending( + from: string, + to: string, + pending: PendingChatCommand, + ): void { + if (this.pending.get(from) === pending) { + this.pending.delete(from); + } + const timeout = this.timeouts.get(from); + if (timeout !== undefined) { + this.timeouts.delete(from); + this.timeouts.set(to, timeout); + } + this.pending.set(to, pending); + this.hooks.onPendingChanged?.(); + } + + private clearPending(pending: PendingChatCommand): void { + if (this.pending.get(pending.conversationId) === pending) { + this.pending.delete(pending.conversationId); + const timeout = this.timeouts.get(pending.conversationId); + if (timeout !== undefined) { + clearTimeout(timeout); + this.timeouts.delete(pending.conversationId); + } + } + if (pending.runId) { + this.byRunId.delete(pending.runId); + } + this.hooks.onPendingChanged?.(); + } +} diff --git a/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts new file mode 100644 index 00000000..02493cea --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts @@ -0,0 +1,201 @@ +import type { + ConversationStreamEvent, + ConversationStreamHandlers, + ConversationSubscribeResult, +} from "./streamTypes"; +import { + normalizeSubscribeResult, + readEventConversationId, + readEventSeq, +} from "./streamTypes"; + +// Transport-owned subscription manager: one persistent registration per +// conversation. Resume is built in — on every (re)connect the manager +// re-issues chat.subscribe with the last seen seq and stream epoch, so a +// dropped socket never silently ends a stream. Registrations survive +// disconnects and are removed only by their cleanup function. + +type StreamTransport = { + request(type: string, payload: unknown): Promise; +}; + +type Registration = { + conversationId: string; + handlers: ConversationStreamHandlers; + lastSeq: number; + streamEpoch: string; + synced: boolean; + syncing: boolean; + resyncQueued: boolean; + disposed: boolean; + // Live events that raced ahead of the chat.subscribe response; drained + // after onSync (seq dedup drops the replay overlap). + pendingEvents: ConversationStreamEvent[]; +}; + +const MAX_PENDING_EVENTS = 512; + +export class ConversationStreamClient { + private registrations = new Map(); + private connected = false; + + constructor(private readonly transport: StreamTransport) {} + + get size(): number { + return this.registrations.size; + } + + subscribe(conversationId: string, handlers: ConversationStreamHandlers): () => void { + const normalized = conversationId.trim(); + if (!normalized) { + return () => {}; + } + const previous = this.registrations.get(normalized); + if (previous) { + previous.disposed = true; + } + const registration: Registration = { + conversationId: normalized, + handlers, + lastSeq: 0, + streamEpoch: "", + synced: false, + syncing: false, + resyncQueued: false, + disposed: false, + pendingEvents: [], + }; + this.registrations.set(normalized, registration); + if (this.connected) { + void this.sync(registration); + } + return () => { + registration.disposed = true; + if (this.registrations.get(normalized) === registration) { + this.registrations.delete(normalized); + if (this.connected) { + void this.transport + .request("chat.unsubscribe", { conversation_id: normalized }) + .catch(() => undefined); + } + } + }; + } + + // The socket authenticated (first connect or reconnect): (re)issue + // chat.subscribe for every registration with its resume cursor. + handleConnected(): void { + this.connected = true; + for (const registration of this.registrations.values()) { + registration.synced = false; + void this.sync(registration); + } + } + + handleDisconnected(): void { + this.connected = false; + for (const registration of this.registrations.values()) { + registration.synced = false; + } + } + + // Server pushed chat.event: route by conversation id, advance the cursor. + handleChatEvent(payload: unknown): void { + if (!payload || typeof payload !== "object") { + return; + } + const event = payload as ConversationStreamEvent; + const conversationId = readEventConversationId(event); + if (!conversationId) { + return; + } + const registration = this.registrations.get(conversationId); + if (!registration || registration.disposed) { + return; + } + if (!registration.synced) { + // Live events published after the server registered the subscriber can + // arrive before the subscribe response; buffer and drain after onSync. + if (registration.pendingEvents.length < MAX_PENDING_EVENTS) { + registration.pendingEvents.push(event); + } + return; + } + this.deliver(registration, event); + } + + private deliver(registration: Registration, event: ConversationStreamEvent): void { + const seq = readEventSeq(event); + if (seq > 0) { + if (seq <= registration.lastSeq) { + return; + } + if (registration.lastSeq > 0 && seq > registration.lastSeq + 1) { + // Missed events on the wire — resync from the cursor. + void this.sync(registration); + return; + } + registration.lastSeq = seq; + } + registration.handlers.onEvent(event); + } + + // Server told us our subscriber overflowed: resume from the cursor. + handleSubscriptionReset(payload: unknown): void { + if (!payload || typeof payload !== "object") { + return; + } + const conversationId = + typeof (payload as { conversation_id?: unknown }).conversation_id === "string" + ? ((payload as { conversation_id: string }).conversation_id ?? "").trim() + : ""; + const registration = conversationId ? this.registrations.get(conversationId) : undefined; + if (registration && !registration.disposed) { + void this.sync(registration); + } + } + + private async sync(registration: Registration): Promise { + if (registration.syncing) { + registration.resyncQueued = true; + return; + } + registration.syncing = true; + try { + const raw = await this.transport.request("chat.subscribe", { + conversation_id: registration.conversationId, + after_seq: registration.lastSeq, + stream_epoch: registration.streamEpoch || undefined, + }); + if (registration.disposed) { + return; + } + const result: ConversationSubscribeResult = normalizeSubscribeResult( + registration.conversationId, + raw, + ); + registration.streamEpoch = result.streamEpoch; + registration.lastSeq = result.latestSeq; + registration.synced = true; + registration.handlers.onSync(result); + const pending = registration.pendingEvents; + registration.pendingEvents = []; + for (const event of pending) { + if (registration.disposed || !registration.synced) { + break; + } + this.deliver(registration, event); + } + } catch { + // The transport reconnect loop will call handleConnected again; a + // failed subscribe leaves the registration unsynced until then. + registration.synced = false; + } finally { + registration.syncing = false; + if (registration.resyncQueued && !registration.disposed) { + registration.resyncQueued = false; + void this.sync(registration); + } + } + } +} diff --git a/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts b/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts new file mode 100644 index 00000000..fa251eb0 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts @@ -0,0 +1,245 @@ +import type { ChatEvent } from "@/lib/gatewayTypes"; + +// Wire types for the conversation-scoped chat stream protocol. +// +// One persistent subscription per conversation carries every run: run +// boundaries are events inside the stream (run_started / run_finished), all +// events carry first-class conversation_id / run_id / seq, and reconnects +// resume from the last seen seq. + +export type RunFinishedStatus = "completed" | "failed" | "cancelled"; + +export type RunLifecycleEvent = + | { + type: "run_started"; + conversation_id: string; + run_id: string; + seq: number; + client_request_id?: string; + workdir?: string; + } + | { + type: "run_finished"; + conversation_id: string; + run_id: string; + seq: number; + status: RunFinishedStatus; + error_code?: string; + message?: string; + reason?: string; + title?: string; + client_request_id?: string; + } + | { + type: "run_queued"; + conversation_id: string; + run_id: string; + seq: number; + client_request_id?: string; + } + | { + type: "snapshot"; + conversation_id: string; + run_id: string; + revision?: number; + entries_json?: string; + tool_status?: string | null; + tool_status_is_compaction?: boolean; + }; + +export type ConversationStreamEvent = (ChatEvent | RunLifecycleEvent) & { + conversation_id?: string; + run_id?: string; + seq?: number; +}; + +export type RunActivityState = "queued" | "running" | "cancelling"; + +export type StreamRunActivity = { + runId: string; + state: RunActivityState; + startedSeq: number; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + clientRequestId?: string; + updatedAt: number; +}; + +export type StreamRunSnapshot = { + runId: string; + revision: number; + entriesJson: string; + toolStatus: string | null; + toolStatusIsCompaction: boolean; +}; + +// Parsed chat.subscribe response. +export type ConversationSubscribeResult = { + conversationId: string; + streamEpoch: string; + latestSeq: number; + reset: boolean; + activity: StreamRunActivity | null; + snapshot: StreamRunSnapshot | null; + events: ConversationStreamEvent[]; +}; + +// chat.activity broadcast: the single source for sidebar dots / busy state of +// non-visible conversations. +export type ConversationActivityEvent = { + conversationId: string; + runId: string | null; + running: boolean; + state: RunActivityState | null; + workdir: string | null; + updatedAt: number; +}; + +// chat.command_update push: pre-stream outcomes of a submitted command, +// delivered only to the issuing connection. +export type ChatCommandUpdate = { + runId: string; + clientRequestId: string; + conversationId: string | null; + phase: "bound" | "queued_in_gui" | "failed"; + errorCode: string | null; + message: string | null; +}; + +export type ChatCommandAccepted = { + runId: string; + conversationId: string; + acceptedSeq: number; +}; + +export type ConversationStreamHandlers = { + // A chat.subscribe round-trip completed (initial subscribe, reconnect + // resume, or reset recovery). When reset is true the local tail must be + // rebuilt from snapshot + events. + onSync(result: ConversationSubscribeResult): void; + // A live pushed event. + onEvent(event: ConversationStreamEvent): void; +}; + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +export function normalizeRunActivity(raw: unknown): StreamRunActivity | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + if (!runId) { + return null; + } + const state = readString(value.state).trim(); + return { + runId, + state: state === "queued" || state === "cancelling" ? state : "running", + startedSeq: readNumber(value.started_seq), + toolStatus: readString(value.tool_status).trim() || null, + toolStatusIsCompaction: value.tool_status_is_compaction === true, + clientRequestId: readString(value.client_request_id).trim() || undefined, + updatedAt: readNumber(value.updated_at), + }; +} + +export function normalizeRunSnapshot(raw: unknown): StreamRunSnapshot | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + if (!runId) { + return null; + } + return { + runId, + revision: readNumber(value.revision), + entriesJson: readString(value.entries_json), + toolStatus: readString(value.tool_status).trim() || null, + toolStatusIsCompaction: value.tool_status_is_compaction === true, + }; +} + +export function normalizeSubscribeResult( + conversationId: string, + raw: unknown, +): ConversationSubscribeResult { + const value = (raw && typeof raw === "object" ? raw : {}) as Record; + const events = Array.isArray(value.events) + ? (value.events.filter( + (event) => event && typeof event === "object", + ) as ConversationStreamEvent[]) + : []; + return { + conversationId: readString(value.conversation_id).trim() || conversationId, + streamEpoch: readString(value.stream_epoch).trim(), + latestSeq: readNumber(value.latest_seq), + reset: value.reset === true, + activity: normalizeRunActivity(value.activity), + snapshot: normalizeRunSnapshot(value.snapshot), + events, + }; +} + +export function normalizeActivityEvent(raw: unknown): ConversationActivityEvent | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const conversationId = readString(value.conversation_id).trim(); + if (!conversationId) { + return null; + } + const state = readString(value.state).trim(); + return { + conversationId, + runId: readString(value.run_id).trim() || null, + running: value.running === true, + state: state === "queued" || state === "running" || state === "cancelling" ? state : null, + workdir: readString(value.workdir).trim() || null, + updatedAt: readNumber(value.updated_at), + }; +} + +export function normalizeCommandUpdate(raw: unknown): ChatCommandUpdate | null { + if (!raw || typeof raw !== "object") { + return null; + } + const value = raw as Record; + const runId = readString(value.run_id).trim(); + const phase = readString(value.phase).trim(); + if (!runId || (phase !== "bound" && phase !== "queued_in_gui" && phase !== "failed")) { + return null; + } + return { + runId, + clientRequestId: readString(value.client_request_id).trim(), + conversationId: readString(value.conversation_id).trim() || null, + phase, + errorCode: readString(value.error_code).trim() || null, + message: readString(value.message).trim() || null, + }; +} + +export function readEventSeq(event: ConversationStreamEvent): number { + const seq = (event as { seq?: unknown }).seq; + return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? Math.floor(seq) : 0; +} + +export function readEventRunId(event: ConversationStreamEvent): string { + const runId = (event as { run_id?: unknown }).run_id; + return typeof runId === "string" ? runId.trim() : ""; +} + +export function readEventConversationId(event: ConversationStreamEvent): string { + const conversationId = (event as { conversation_id?: unknown }).conversation_id; + return typeof conversationId === "string" ? conversationId.trim() : ""; +} diff --git a/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts new file mode 100644 index 00000000..a047f098 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts @@ -0,0 +1,535 @@ +import type { ChatEntry, PushChatEventOptions } from "@/lib/chatUi"; +import { chatEntryDedupKey, pushChatEvent } from "@/lib/chatUi"; +import type { ChatEvent } from "@/lib/gatewayTypes"; +import type { + ConversationStreamEvent, + ConversationSubscribeResult, + StreamRunActivity, +} from "./streamTypes"; +import { readEventRunId, readEventSeq } from "./streamTypes"; + +// One transcript store per conversation, modeled after the GUI's proven +// split between committed history and an append-only live tail: +// +// committed — history-backed entries (stable ids, virtualized region) +// settled — entries of finished runs, not yet folded into committed; +// they stay in the (non-virtualized) tail region so the end +// of a reply moves zero DOM nodes +// live — entries of the current in-flight segment +// +// Entry ids never change. Completion is supersession, not refetch: at the +// next run_started the settled tail folds into committed in one commit, +// together with the incoming user bubble. + +export type TranscriptSnapshot = { + committed: ChatEntry[]; + // settled + live — everything rendered in the tail region. + tail: ChatEntry[]; + activeRun: StreamRunActivity | null; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + // Bumped whenever the settled tail folds into committed (the one moment a + // scroll-preserving flushSync commit is warranted). + foldRevision: number; + revision: number; +}; + +export type TranscriptStore = { + getSnapshot(): TranscriptSnapshot; + subscribe(listener: () => void): () => void; + // Stream plumbing. + applySync(result: ConversationSubscribeResult): void; + applyEvent(event: ConversationStreamEvent): void; + // Optimistic local echo for a command this client is submitting. The + // matching seeded user_message adopts the entry by client_request_id, + // keeping this id — the user bubble never remounts. + addOptimisticUserEntry(params: { + clientRequestId: string; + text: string; + attachments?: UserAttachments; + }): void; + removeOptimisticUserEntry(clientRequestId: string): void; + // Failure surfaced outside the stream (command never bound). + appendLocalError(message: string): void; + // History snapshot (initial load / quiet upsert merge). Preserves existing + // ids by dedup key and leaves tail entries in place — committed only takes + // history entries the tail is not already showing. + applyHistorySnapshot(entries: ChatEntry[]): void; + // Fold the settled tail into committed outside of run_started (used when + // the conversation is switched away; keeps the next mount clean). + foldSettledTail(): void; + reset(): void; + flush(): void; +}; + +type UserAttachments = Extract["attachments"]; + +const EMPTY_SNAPSHOT: TranscriptSnapshot = { + committed: [], + tail: [], + activeRun: null, + toolStatus: null, + toolStatusIsCompaction: false, + foldRevision: 0, + revision: 0, +}; + +function runIdPrefix(runId: string): string { + return runId ? `${runId}/` : ""; +} + +function optimisticEntryId(clientRequestId: string): string { + return `optimistic-user-${clientRequestId}`; +} + +export function createTranscriptStore(): TranscriptStore { + let committed: ChatEntry[] = []; + let settled: ChatEntry[] = []; + let live: ChatEntry[] = []; + let activeRun: StreamRunActivity | null = null; + let toolStatus: string | null = null; + let toolStatusIsCompaction = false; + let foldRevision = 0; + + let snapshot = EMPTY_SNAPSHOT; + let dirty = false; + let rafId: number | null = null; + const listeners = new Set<() => void>(); + // clientRequestId → optimistic entry id, until adopted or removed. + const pendingOptimistic = new Map(); + // runId → ids of entries that belong to it but carry foreign ids (adopted + // optimistic entries), so run_queued compensation can remove them too. + const adoptedEntryIds = new Map(); + + const buildSnapshot = (): TranscriptSnapshot => ({ + committed, + tail: settled.length === 0 ? live : live.length === 0 ? settled : [...settled, ...live], + activeRun, + toolStatus, + toolStatusIsCompaction, + foldRevision, + revision: snapshot.revision + 1, + }); + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + const commit = () => { + rafId = null; + if (!dirty) { + return; + } + dirty = false; + snapshot = buildSnapshot(); + emit(); + }; + const schedule = (flush?: boolean) => { + dirty = true; + if (flush) { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + return; + } + if (rafId === null && typeof requestAnimationFrame === "function") { + rafId = requestAnimationFrame(commit); + } else if (typeof requestAnimationFrame !== "function") { + commit(); + } + }; + + const foldSettled = (flush: boolean) => { + if (settled.length === 0) { + return; + } + committed = [...committed, ...settled]; + settled = []; + foldRevision += 1; + schedule(flush); + }; + + const setToolStatus = (status: string | null, isCompaction: boolean, flush?: boolean) => { + const next = status && status.trim() ? status.trim() : null; + const nextCompaction = Boolean(next) && isCompaction; + if (toolStatus === next && toolStatusIsCompaction === nextCompaction) { + return; + } + toolStatus = next; + toolStatusIsCompaction = nextCompaction; + schedule(flush); + }; + + const adoptOptimisticEntry = (event: ConversationStreamEvent, runId: string): boolean => { + const clientRequestId = + typeof (event as { client_request_id?: unknown }).client_request_id === "string" + ? ((event as { client_request_id: string }).client_request_id ?? "").trim() + : ""; + if (!clientRequestId) { + return false; + } + const entryId = pendingOptimistic.get(clientRequestId); + if (!entryId) { + return false; + } + pendingOptimistic.delete(clientRequestId); + // The optimistic entry already shows this exact message; keep it (and its + // id) and record the ownership for run_queued compensation. + if (runId) { + const owned = adoptedEntryIds.get(runId) ?? []; + owned.push(entryId); + adoptedEntryIds.set(runId, owned); + } + return live.some((entry) => entry.id === entryId); + }; + + const removeRunEntries = (runId: string) => { + const prefix = runIdPrefix(runId); + const adopted = new Set(adoptedEntryIds.get(runId) ?? []); + adoptedEntryIds.delete(runId); + const matches = (entry: ChatEntry) => + (prefix !== "" && entry.id.startsWith(prefix)) || adopted.has(entry.id); + const nextLive = live.filter((entry) => !matches(entry)); + const nextSettled = settled.filter((entry) => !matches(entry)); + if (nextLive.length !== live.length || nextSettled.length !== settled.length) { + live = nextLive; + settled = nextSettled; + schedule(true); + } + }; + + const applyRunFinished = (event: ConversationStreamEvent) => { + const payload = event as { + status?: string; + message?: string; + reason?: string; + }; + if (payload.status === "failed" && payload.message && payload.reason !== "superseded") { + live = pushChatEvent( + live, + { type: "error", message: payload.message } as ChatEvent, + { entryIdPrefix: runIdPrefix(readEventRunId(event)) }, + ); + } + settled = live.length === 0 ? settled : [...settled, ...live]; + live = []; + activeRun = null; + setToolStatus(null, false); + schedule(true); + }; + + const applyDelta = (event: ConversationStreamEvent, runId: string) => { + if (event.type === "user_message" && adoptOptimisticEntry(event, runId)) { + schedule(true); + return; + } + const options: PushChatEventOptions = { entryIdPrefix: runIdPrefix(runId) }; + const next = pushChatEvent(live, event as ChatEvent, options); + if (next !== live) { + live = next; + schedule(event.type === "user_message"); + } + }; + + const rebuildLiveFromSnapshot = (entriesJson: string, runId: string) => { + const entries = parseSnapshotEntries(entriesJson); + if (entries.length === 0 && live.length === 0) { + return; + } + // Snapshot entries carry their own (runtime-assigned) ids; prefix them so + // they cannot collide with entries of other runs. + const prefix = runIdPrefix(runId); + live = entries.map((entry) => + prefix && !entry.id.startsWith(prefix) ? { ...entry, id: `${prefix}${entry.id}` } : entry, + ); + schedule(true); + }; + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + applySync: (result) => { + if (result.reset) { + // Seq continuity broke (gateway restart / buffer gap). Committed + // history is still valid; rebuild the tail from scratch. + settled = []; + live = []; + activeRun = null; + toolStatus = null; + toolStatusIsCompaction = false; + if (result.snapshot) { + rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + } + } else if (result.snapshot && live.length === 0) { + // Late join mid-run where the buffer cannot cover the run start. + rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + } + activeRun = result.activity; + if (result.activity) { + setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); + } else if (result.reset) { + setToolStatus(null, false); + } + for (const event of result.events) { + applyOne(event); + } + schedule(true); + }, + + applyEvent: (event) => { + applyOne(event); + }, + + addOptimisticUserEntry: ({ clientRequestId, text, attachments }) => { + const id = optimisticEntryId(clientRequestId); + if (live.some((entry) => entry.id === id)) { + return; + } + pendingOptimistic.set(clientRequestId, id); + live = [ + ...live, + { + id, + kind: "user", + text, + attachments: (attachments ?? []) as UserAttachments, + }, + ]; + schedule(true); + }, + + removeOptimisticUserEntry: (clientRequestId) => { + const id = pendingOptimistic.get(clientRequestId) ?? optimisticEntryId(clientRequestId); + pendingOptimistic.delete(clientRequestId); + const next = live.filter((entry) => entry.id !== id); + if (next.length !== live.length) { + live = next; + schedule(true); + } + }, + + appendLocalError: (message) => { + live = pushChatEvent(live, { type: "error", message } as ChatEvent, { + entryIdPrefix: "local/", + }); + schedule(true); + }, + + applyHistorySnapshot: (entries) => { + // Preserve ids of entries we already render (matched by dedup key — + // tool entries match by tool-call identity, immune to live-path + // trimming) and keep tail entries where they are: committed only takes + // what the tail is not showing. + const existingByKey = new Map(); + for (const entry of committed) { + existingByKey.set(chatEntryDedupKey(entry), entry); + } + const tailKeys = new Set(); + for (const entry of settled) { + tailKeys.add(chatEntryDedupKey(entry)); + } + for (const entry of live) { + tailKeys.add(chatEntryDedupKey(entry)); + } + + const nextCommitted: ChatEntry[] = []; + let changed = entries.length !== committed.length; + for (const entry of entries) { + const key = chatEntryDedupKey(entry); + if (tailKeys.has(key)) { + // Already rendered in the tail region; folds in at run_started. + changed = true; + continue; + } + const existing = existingByKey.get(key); + if (existing) { + // Same logical entry: keep the rendered id, upgrade the payload + // (history carries full, untrimmed tool content). + const upgraded = entry.id === existing.id ? entry : { ...entry, id: existing.id }; + nextCommitted.push(upgraded); + if (existing !== upgraded) { + changed = true; + } + } else { + nextCommitted.push(entry); + changed = true; + } + } + if (!changed && nextCommitted.length === committed.length) { + return; + } + committed = nextCommitted; + schedule(true); + }, + + foldSettledTail: () => { + foldSettled(true); + }, + + reset: () => { + committed = []; + settled = []; + live = []; + activeRun = null; + toolStatus = null; + toolStatusIsCompaction = false; + pendingOptimistic.clear(); + adoptedEntryIds.clear(); + schedule(true); + }, + + flush: () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + }, + }; + + // edit_resend: truncate the transcript at the edited user message. The new + // user_message (adopting the optimistic entry) follows in the stream. + function applyRebased(event: ConversationStreamEvent) { + const ref = (event as { base_message_ref?: unknown }).base_message_ref; + if (!ref || typeof ref !== "object") { + return; + } + const refValue = ref as Record; + const messageId = + typeof refValue.message_id === "string" ? refValue.message_id.trim() : ""; + const contentHash = + typeof refValue.content_hash === "string" ? refValue.content_hash.trim() : ""; + if (!messageId && !contentHash) { + return; + } + foldSettled(true); + const index = committed.findIndex( + (entry) => + entry.kind === "user" && + entry.messageRef != null && + ((messageId !== "" && entry.messageRef.messageId === messageId) || + (contentHash !== "" && entry.messageRef.contentHash === contentHash)), + ); + if (index < 0) { + return; + } + committed = committed.slice(0, index); + schedule(true); + } + + function applyOne(event: ConversationStreamEvent) { + const runId = readEventRunId(event); + switch (event.type) { + case "run_started": { + // Fold the previous reply into history in the same commit that will + // render the new run — the one intentional layout change. + foldSettled(true); + activeRun = { + runId, + state: "running", + startedSeq: readEventSeq(event), + toolStatus: null, + toolStatusIsCompaction: false, + clientRequestId: + typeof (event as { client_request_id?: unknown }).client_request_id === "string" + ? (event as { client_request_id: string }).client_request_id + : undefined, + updatedAt: Date.now(), + }; + setToolStatus(null, false, true); + return; + } + case "run_finished": { + applyRunFinished(event); + return; + } + case "run_queued": { + // The prompt went into the desktop queue: drop its provisional + // entries; the queue UI shows it instead. + removeRunEntries(runId); + if (activeRun?.runId === runId) { + activeRun = null; + schedule(true); + } + return; + } + case "rebased": { + applyRebased(event); + return; + } + case "snapshot": { + const payload = event as { entries_json?: string }; + rebuildLiveFromSnapshot(payload.entries_json ?? "", runId); + const status = (event as { tool_status?: string | null }).tool_status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { tool_status_is_compaction?: boolean }).tool_status_is_compaction === true, + true, + ); + return; + } + case "tool_status": { + const status = (event as { status?: string | null }).status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { isCompaction?: boolean }).isCompaction === true, + ); + if (activeRun && activeRun.runId === runId) { + activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction }; + } + return; + } + default: { + applyDelta(event, runId); + } + } + } +} + +function isSnapshotChatEntry(value: unknown): value is ChatEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const v = value as Record; + if (typeof v.id !== "string" || typeof v.kind !== "string") { + return false; + } + switch (v.kind) { + case "user": + return typeof v.text === "string" && Array.isArray(v.attachments); + case "assistant": + case "thinking": + case "error": + return typeof v.text === "string"; + case "tool_call": + return v.toolCall != null && typeof v.toolCall === "object"; + case "tool_result": + return v.toolResult != null && typeof v.toolResult === "object"; + case "hosted_search": + return v.hostedSearch != null && typeof v.hostedSearch === "object"; + default: + return false; + } +} + +export function parseSnapshotEntries(json: string | undefined): ChatEntry[] { + const raw = typeof json === "string" ? json.trim() : ""; + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; + } catch { + return []; + } +} diff --git a/crates/agent-gateway/web/src/lib/chatUi.ts b/crates/agent-gateway/web/src/lib/chatUi.ts index ffb022f8..8cf2545c 100644 --- a/crates/agent-gateway/web/src/lib/chatUi.ts +++ b/crates/agent-gateway/web/src/lib/chatUi.ts @@ -1017,8 +1017,8 @@ function buildLiveAssistantEntryId(round?: number, occurrence = 0) { return occurrence <= 0 ? baseId : `${baseId}-${occurrence}`; } -function isLiveAssistantEntryIdForRound(id: string, round?: number) { - const baseId = `live-assistant-${round ?? 0}`; +function isLiveAssistantEntryIdForRound(id: string, round?: number, idPrefix = "") { + const baseId = `${idPrefix}live-assistant-${round ?? 0}`; return id === baseId || id.startsWith(`${baseId}-`); } @@ -1286,7 +1286,18 @@ function enrichTailHostedSearchEntriesWithText(entries: ChatEntry[]): ChatEntry[ return next ?? entries; } -export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry[] { +export type PushChatEventOptions = { + // Namespaces generated entry ids (e.g. "run-abc/") so entries from + // different runs of one conversation can never collide on id. + entryIdPrefix?: string; +}; + +export function pushChatEvent( + entries: ChatEntry[], + event: ChatEvent, + options?: PushChatEventOptions, +): ChatEntry[] { + const idPrefix = options?.entryIdPrefix ?? ""; if (event.type === "user_message") { const text = readString(event.message); const attachments = normalizeLiveUploadedFiles(event.uploaded_files); @@ -1299,7 +1310,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry return [ ...entries, { - id: randomId("live-user"), + id: idPrefix + randomId("live-user"), kind: "user", text, attachments, @@ -1364,12 +1375,12 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry entries, (entry) => entry.kind === "assistant" && - isLiveAssistantEntryIdForRound(entry.id, round), + isLiveAssistantEntryIdForRound(entry.id, round, idPrefix), ); const next: ChatEntry[] = [ ...entries, { - id: buildLiveAssistantEntryId(round, occurrence), + id: idPrefix + buildLiveAssistantEntryId(round, occurrence), kind: "assistant", text, round, @@ -1396,7 +1407,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry return [ ...entries, { - id: buildLiveThinkingEntryId(round, occurrence), + id: idPrefix + buildLiveThinkingEntryId(round, occurrence), kind: "thinking", round, text, @@ -1417,7 +1428,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry return mergedToolCall.entries; } - const baseId = buildLiveToolCallBaseId({ + const baseId = idPrefix + buildLiveToolCallBaseId({ round, id: eventToolCall.id, name: eventToolCall.name, @@ -1472,7 +1483,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry return nextEntries; } - const baseId = buildLiveToolResultBaseId({ + const baseId = idPrefix + buildLiveToolResultBaseId({ round, toolCallId: resultToolCall.id ?? event.id, toolName: resultToolCall.name ?? event.name, @@ -1563,7 +1574,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry } } - const baseId = buildLiveHostedSearchBaseId({ + const baseId = idPrefix + buildLiveHostedSearchBaseId({ round, id: hostedSearch.id, queries: hostedSearch.queries, @@ -1593,7 +1604,7 @@ export function pushChatEvent(entries: ChatEntry[], event: ChatEvent): ChatEntry return [ ...entries, { - id: `live-error-${round ?? 0}-${errorId}`, + id: `${idPrefix}live-error-${round ?? 0}-${errorId}`, kind: "assistant", round, text, diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index c25f6115..4f40eca8 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -38,7 +38,6 @@ import type { AgentStatus, ChatQueueResponse, ChatQueueSnapshot, - ChatEvent, ConversationSummary, GatewayHistoryEvent, CronManagePayload, @@ -54,6 +53,17 @@ import type { HistoryWorkdirsResponse, MemoryManagePayload, } from "./gatewayTypes"; +import { ConversationStreamClient } from "@/lib/chat/stream/conversationStreamClient"; +import type { + ChatCommandAccepted, + ChatCommandUpdate, + ConversationActivityEvent, + ConversationStreamHandlers, +} from "@/lib/chat/stream/streamTypes"; +import { + normalizeActivityEvent, + normalizeCommandUpdate, +} from "@/lib/chat/stream/streamTypes"; type GatewaySocketEnvelope = { id?: string; @@ -72,6 +82,8 @@ type GatewaySettingsUpdateResponse = { type TerminalListener = (event: TerminalEvent) => void; type SftpTransferListener = (event: SftpTransferEvent) => void; type ChatQueueListener = (snapshot: ChatQueueSnapshot) => void; +type ChatActivityListener = (event: ConversationActivityEvent) => void; +type ChatCommandUpdateListener = (update: ChatCommandUpdate) => void; type PendingRequest = { resolve: (value: any) => void; @@ -79,12 +91,6 @@ type PendingRequest = { timeoutId: number; }; -type ChatEventStreamOptions = { - runId?: string; - afterSeq?: number; - signal?: AbortSignal; -}; - type GatewayChatSystemSettings = { executionMode?: string; workdir?: string; @@ -568,7 +574,7 @@ function buildHistoryMessageRefPayload(ref: HistoryMessageRef) { }; } -type ChatCommandResponse = { +type RawChatCommandResponse = { run_id?: string; conversation_id?: string; accepted_seq?: number; @@ -649,25 +655,6 @@ function normalizeChatQueueEvent( ); } -// Consumers (sendChat's preserveRemoteRunOnMismatch cleanup guards) read the -// originating run id from non-enumerable event metadata so it never leaks into -// persisted transcript entries. The gateway includes `run_id` on every -// forwarded chat event; without this attachment a webui-initiated run's -// cleanup cannot tell its own run apart from a newer run (e.g. a GUI queue -// auto-send) and wipes the new run's remote-running state. -function attachChatEventRunMetadata(event: ChatEvent) { - const runId = (event as ChatEvent & { run_id?: unknown }).run_id; - if (typeof runId !== "string" || !runId.trim()) { - return event; - } - Object.defineProperty(event, "__gatewayRunId", { - value: runId.trim(), - enumerable: false, - configurable: true, - }); - return event; -} - const RECONNECT_INITIAL_DELAY_MS = 500; const RECONNECT_MAX_DELAY_MS = 10_000; const RECONNECT_NOTICE_DELAY_MS = 15_000; @@ -1137,7 +1124,11 @@ export class GatewayWebSocketClient { private terminalListeners = new Set(); private sftpTransferListeners = new Set(); private chatQueueListeners = new Set(); - private chatSubscriptions = new Map void; done: () => void }>(); + private chatActivityListeners = new Set(); + private chatCommandUpdateListeners = new Set(); + readonly conversationStreams = new ConversationStreamClient({ + request: (type, payload) => this.request(type, payload), + }); private terminalSessionSnapshot = new Map(); private statusPollTimer: number | null = null; private lastStatus: AgentStatus | null = null; @@ -1335,60 +1326,62 @@ export class GatewayWebSocketClient { ); } - async *commandChat(input: GatewayChatCommandInput): AsyncGenerator { - const signal = input.signal; - if (signal?.aborted) { - return; - } + // Submit a chat command. Streaming does not hang off the command: the + // conversation subscription (persistent, run-agnostic) carries the reply. + async chatCommand(input: GatewayChatCommandInput): Promise { if (this.token.trim() === "") { throw new Error("Gateway token is required"); } - - // Submit command via WebSocket - const commandResponse = await this.request("chat.command", buildChatCommandPayload(input)); - const runId = String(commandResponse.run_id ?? "").trim(); + const response = await this.request( + "chat.command", + buildChatCommandPayload(input), + ); + const runId = String(response.run_id ?? "").trim(); if (!runId) { throw new Error("Gateway chat command returned no run_id"); } - let conversationId = - String(commandResponse.conversation_id ?? "").trim() || input.conversationId?.trim() || ""; - - // Set up abort handler - const handleAbort = () => { - void this.request("chat.cancel", { - conversation_id: conversationId, - run_id: runId, - }).catch(() => undefined); + return { + runId, + conversationId: + String(response.conversation_id ?? "").trim() || input.conversationId?.trim() || "", + acceptedSeq: + typeof response.accepted_seq === "number" && Number.isFinite(response.accepted_seq) + ? response.accepted_seq + : 0, }; - if (signal) { - if (signal.aborted) { - handleAbort(); - return; - } - signal.addEventListener("abort", handleAbort, { once: true }); - } - - try { - yield* this.subscribeChatRunEvents(runId, conversationId, 0, signal); - } finally { - signal?.removeEventListener("abort", handleAbort); - } } - async *streamChatEvents( + // Persistent per-conversation stream subscription with built-in resume. + subscribeConversationStream( conversationId: string, - options?: ChatEventStreamOptions, - ): AsyncGenerator { - const normalizedConversationId = conversationId.trim(); - if (!normalizedConversationId) { - throw new Error("conversation_id is required"); - } - const signal = options?.signal; - if (signal?.aborted) { - return; - } - const normalizedRunId = options?.runId?.trim() ?? ""; - yield* this.subscribeChatRunEvents(normalizedRunId, normalizedConversationId, options?.afterSeq ?? 0, signal); + handlers: ConversationStreamHandlers, + ): () => void { + const cleanup = this.conversationStreams.subscribe(conversationId, handlers); + // Establish the connection (and thereby the subscription) eagerly. + void this.ensureConnected() + .then(() => { + if (this.authenticated) { + this.conversationStreams.handleConnected(); + } + }) + .catch(() => { + this.scheduleReconnect(); + }); + return cleanup; + } + + subscribeChatActivity(listener: ChatActivityListener): () => void { + this.chatActivityListeners.add(listener); + return () => { + this.chatActivityListeners.delete(listener); + }; + } + + subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void { + this.chatCommandUpdateListeners.add(listener); + return () => { + this.chatCommandUpdateListeners.delete(listener); + }; } async cancelChat(conversationId: string, runId?: string): Promise { @@ -1402,79 +1395,6 @@ export class GatewayWebSocketClient { }); } - private async *subscribeChatRunEvents( - runId: string, - conversationId: string, - afterSeq: number, - signal?: AbortSignal, - ): AsyncGenerator { - const subResponse = await this.request<{ - subscription_id: string; - snapshot: { run_id: string; conversation_id: string; state: string; latest_seq: number }; - gap?: boolean; - }>("chat.subscribe", { - run_id: runId || undefined, - conversation_id: conversationId || undefined, - after_seq: afterSeq, - }); - - const subscriptionId = subResponse.subscription_id; - if (!subscriptionId) { - throw new Error("No subscription_id from chat.subscribe"); - } - - // Create event queue - const eventQueue: ChatEvent[] = []; - let resolve: (() => void) | null = null; - let done = false; - - const onEvent = (event: ChatEvent) => { - eventQueue.push(event); - if (resolve) { - resolve(); - resolve = null; - } - }; - const onDone = () => { - done = true; - if (resolve) { - resolve(); - resolve = null; - } - }; - - this.chatSubscriptions.set(subscriptionId, { callback: onEvent, done: onDone }); - - try { - while (!done && !signal?.aborted) { - if (eventQueue.length === 0) { - await new Promise((r) => { - resolve = r; - }); - continue; - } - const event = eventQueue.shift()!; - if (event.conversation_id?.trim()) { - conversationId = event.conversation_id.trim(); - } - yield event; - const eventType = typeof event.type === "string" ? event.type.trim() : ""; - if ( - eventType === "done" || - eventType === "completed" || - eventType === "error" || - eventType === "failed" || - eventType === "cancelled" - ) { - return; - } - } - } finally { - this.chatSubscriptions.delete(subscriptionId); - void this.request("chat.unsubscribe", { subscription_id: subscriptionId }).catch(() => {}); - } - } - async cronManage(payload: CronManagePayload): Promise { return this.request("cron.manage", payload); } @@ -2190,7 +2110,9 @@ export class GatewayWebSocketClient { this.historyListeners.size > 0 || this.settingsListeners.size > 0 || this.terminalListeners.size > 0 || - this.sftpTransferListeners.size > 0) + this.sftpTransferListeners.size > 0 || + this.chatActivityListeners.size > 0 || + this.conversationStreams.size > 0) ); } @@ -2432,6 +2354,7 @@ export class GatewayWebSocketClient { this.authenticated = true; this.clearReconnectNoticeTimer(); this.reconnectAttempt = 0; + this.conversationStreams.handleConnected(); if (!settled) { settled = true; resolve(); @@ -2498,12 +2421,7 @@ export class GatewayWebSocketClient { if (envelope.type === "history.event") { const event = envelope.payload as GatewayHistoryEvent; - if ( - event?.kind === "upsert" || - event?.kind === "delete" || - event?.kind === "running" || - event?.kind === "idle" - ) { + if (event?.kind === "upsert" || event?.kind === "delete") { this.emitHistory(event); } return; @@ -2542,21 +2460,31 @@ export class GatewayWebSocketClient { } if (envelope.type === "chat.event") { - const payload = envelope.payload as any; - const subId = typeof payload?.subscription_id === "string" ? payload.subscription_id : ""; - const sub = subId ? this.chatSubscriptions.get(subId) : undefined; - if (sub) { - sub.callback(attachChatEventRunMetadata(payload as ChatEvent)); + this.conversationStreams.handleChatEvent(envelope.payload); + return; + } + + if (envelope.type === "chat.subscription_reset") { + this.conversationStreams.handleSubscriptionReset(envelope.payload); + return; + } + + if (envelope.type === "chat.activity") { + const event = normalizeActivityEvent(envelope.payload); + if (event) { + for (const listener of this.chatActivityListeners) { + listener(event); + } } return; } - if (envelope.type === "chat.subscription_end") { - const payload = envelope.payload as any; - const subId = typeof payload?.subscription_id === "string" ? payload.subscription_id : ""; - const sub = subId ? this.chatSubscriptions.get(subId) : undefined; - if (sub) { - sub.done(); + if (envelope.type === "chat.command_update") { + const update = normalizeCommandUpdate(envelope.payload); + if (update) { + for (const listener of this.chatCommandUpdateListeners) { + listener(update); + } } return; } @@ -2603,11 +2531,9 @@ export class GatewayWebSocketClient { entry.reject(error); } - const chatSubs = [...this.chatSubscriptions.values()]; - this.chatSubscriptions.clear(); - for (const sub of chatSubs) { - sub.done(); - } + // Conversation stream registrations survive the disconnect; they resume + // with their seq cursors when the socket re-authenticates. + this.conversationStreams.handleDisconnected(); if (!this.disposed && this.statusListeners.size > 0) { if (isRecoverableGatewayTransportError(error) && this.shouldMaintainConnection()) { @@ -2693,11 +2619,13 @@ export type GatewayWebSocketClientLike = { subscribeTerminal(listener: TerminalListener): () => void; subscribeSftpTransfers(listener: SftpTransferListener): () => void; subscribeChatQueue(listener: ChatQueueListener): () => void; - commandChat(input: GatewayChatCommandInput): AsyncGenerator; - streamChatEvents( + subscribeChatActivity(listener: ChatActivityListener): () => void; + subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void; + subscribeConversationStream( conversationId: string, - options?: ChatEventStreamOptions, - ): AsyncGenerator; + handlers: ConversationStreamHandlers, + ): () => void; + chatCommand(input: GatewayChatCommandInput): Promise; cancelChat(conversationId: string, runId?: string): Promise; chatQueueGet(conversationId: string): Promise; chatQueueGetItem(conversationId: string, itemId: string): Promise; diff --git a/crates/agent-gateway/web/test/activity-store.test.mjs b/crates/agent-gateway/web/test/activity-store.test.mjs new file mode 100644 index 00000000..9aedc95d --- /dev/null +++ b/crates/agent-gateway/web/test/activity-store.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); + +const { createActivityStore } = loader.loadModule("src/lib/chat/stream/activityStore.ts"); + +test("activity events drive the running map with run identity", () => { + const store = createActivityStore(); + let notifications = 0; + store.subscribe(() => { + notifications += 1; + }); + + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-1", + running: true, + state: "running", + workdir: "/workspace", + updatedAt: 10, + }); + assert.equal(store.isRunning("conv-1"), true); + assert.equal(store.get("conv-1")?.runId, "run-1"); + assert.equal(notifications, 1); + + // Duplicate state is ignored. + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-1", + running: true, + state: "running", + workdir: "/workspace", + updatedAt: 11, + }); + assert.equal(notifications, 1); + + // A stale event (older than what we show) is ignored. + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-0", + running: true, + state: "running", + workdir: "/workspace", + updatedAt: 5, + }); + assert.equal(store.get("conv-1")?.runId, "run-1"); + + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-1", + running: false, + state: null, + workdir: null, + updatedAt: 20, + }); + assert.equal(store.isRunning("conv-1"), false); + assert.equal(notifications, 2); +}); + +test("hydration replaces the map from history.list", () => { + const store = createActivityStore(); + store.applyActivityEvent({ + conversationId: "conv-stale", + runId: "run-stale", + running: true, + state: "running", + workdir: null, + updatedAt: 1, + }); + + store.hydrate([ + { conversationId: "conv-1", runId: "run-1", state: "running", workdir: "/w", updatedAt: 2 }, + { conversationId: "conv-2", runId: "run-2", state: "cancelling", updatedAt: 3 }, + ]); + + assert.equal(store.isRunning("conv-stale"), false, "hydration is authoritative"); + assert.equal(store.get("conv-1")?.runId, "run-1"); + assert.equal(store.get("conv-2")?.state, "cancelling"); +}); diff --git a/crates/agent-gateway/web/test/liveConversationCommit.test.mjs b/crates/agent-gateway/web/test/liveConversationCommit.test.mjs deleted file mode 100644 index 408fcefd..00000000 --- a/crates/agent-gateway/web/test/liveConversationCommit.test.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; - -const loader = createWebModuleLoader({ - rootDir: fileURLToPath(new URL("../", import.meta.url)), -}); -const liveConversationCommit = loader.loadModule("src/lib/liveConversationCommit.ts"); - -test("appendCommittedLiveEntries keeps later assistant turns even when live ids repeat", () => { - const firstTurn = liveConversationCommit.appendCommittedLiveEntries([], [ - { - id: "live-assistant-1", - kind: "assistant", - text: "第一轮回复", - round: 1, - }, - ]); - - const secondTurn = liveConversationCommit.appendCommittedLiveEntries(firstTurn, [ - { - id: "live-assistant-1", - kind: "assistant", - text: "第二轮回复", - round: 1, - }, - ]); - - assert.equal(secondTurn.length, 2); - assert.deepEqual( - secondTurn.map((entry) => entry.kind === "assistant" ? entry.text : ""), - ["第一轮回复", "第二轮回复"], - ); - assert.notEqual(secondTurn[0].id, secondTurn[1].id); -}); - -test("appendCommittedLiveEntries is idempotent for the same trailing live replay", () => { - const liveEntries = [ - { - id: "live-assistant-1", - kind: "assistant", - text: "同一轮最终回复", - round: 1, - meta: { - provider: "openai", - model: "gpt-5", - }, - }, - ]; - - const committed = liveConversationCommit.appendCommittedLiveEntries([], liveEntries); - const replayed = liveConversationCommit.appendCommittedLiveEntries(committed, liveEntries); - - assert.equal(replayed.length, 1); - assert.equal(replayed[0].kind, "assistant"); - assert.equal(replayed[0].text, "同一轮最终回复"); -}); diff --git a/crates/agent-gateway/web/test/liveConversationStreamStore.test.mjs b/crates/agent-gateway/web/test/liveConversationStreamStore.test.mjs deleted file mode 100644 index e31d9a55..00000000 --- a/crates/agent-gateway/web/test/liveConversationStreamStore.test.mjs +++ /dev/null @@ -1,201 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { fileURLToPath } from "node:url"; - -import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; - -const loader = createWebModuleLoader({ - rootDir: fileURLToPath(new URL("../", import.meta.url)), -}); - -test("live stream store commits through timer fallback when animation frames are paused", async () => { - const { createLiveConversationStreamStore } = loader.loadModule( - "src/lib/liveConversationStreamStore.ts", - ); - const originalWindow = globalThis.window; - const originalDocument = globalThis.document; - const cancelledFrames = []; - let requestedFrame = false; - let notifications = 0; - - try { - delete globalThis.document; - globalThis.window = { - requestAnimationFrame() { - requestedFrame = true; - return 11; - }, - cancelAnimationFrame(frameId) { - cancelledFrames.push(frameId); - }, - setTimeout, - clearTimeout, - }; - - const store = createLiveConversationStreamStore(); - store.subscribe(() => { - notifications += 1; - }); - store.appendEvent({ - type: "token", - text: "hello", - conversation_id: "conversation-1", - round: 1, - }); - - assert.equal(store.getSnapshot().entries.length, 0); - await new Promise((resolve) => setTimeout(resolve, 310)); - - const snapshot = store.getSnapshot(); - assert.equal(requestedFrame, true); - assert.deepEqual(cancelledFrames, [11]); - assert.equal(notifications, 1); - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0].kind, "assistant"); - assert.equal(snapshot.entries[0].text, "hello"); - } finally { - if (originalWindow === undefined) { - delete globalThis.window; - } else { - globalThis.window = originalWindow; - } - if (originalDocument === undefined) { - delete globalThis.document; - } else { - globalThis.document = originalDocument; - } - } -}); - -test("runtime snapshot hydrates live entries and tool status", () => { - const { createLiveConversationStreamStore } = loader.loadModule( - "src/lib/liveConversationStreamStore.ts", - ); - const store = createLiveConversationStreamStore(); - - store.applySnapshot( - { - type: "runtime_snapshot", - seq: 3, - revision: 1, - entries_json: JSON.stringify([ - { id: "u1", kind: "user", text: "hello", attachments: [] }, - { id: "a1", kind: "assistant", text: "partial", round: 1 }, - ]), - tool_status: "Thinking...", - tool_status_is_compaction: true, - }, - { flush: true }, - ); - - const snapshot = store.getSnapshot(); - assert.equal(snapshot.entries.length, 2); - assert.equal(snapshot.entries[0].kind, "user"); - assert.equal(snapshot.entries[1].kind, "assistant"); - assert.equal(snapshot.entries[1].text, "partial"); - assert.equal(snapshot.toolStatus, "Thinking..."); - assert.equal(snapshot.toolStatusIsCompaction, true); -}); - -test("runtime snapshot ignores checkpoint history entries", () => { - const { createLiveConversationStreamStore } = loader.loadModule( - "src/lib/liveConversationStreamStore.ts", - ); - const store = createLiveConversationStreamStore(); - - store.applySnapshot( - { - type: "runtime_snapshot", - seq: 4, - revision: 1, - entries_json: JSON.stringify([ - { - id: "summary-1", - kind: "checkpoint", - content: "old summary", - summaryId: "summary-1", - coveredMessageCount: 10, - coversThroughMessageId: "m10", - generatedBy: { providerId: "p", model: "m" }, - }, - { id: "a1", kind: "assistant", text: "partial", round: 1 }, - ]), - }, - { flush: true }, - ); - - const snapshot = store.getSnapshot(); - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0].kind, "assistant"); - assert.equal(snapshot.entries[0].text, "partial"); -}); - -test("runtime snapshot ignores older revisions", () => { - const { createLiveConversationStreamStore } = loader.loadModule( - "src/lib/liveConversationStreamStore.ts", - ); - const store = createLiveConversationStreamStore(); - - store.applySnapshot( - { - type: "runtime_snapshot", - seq: 5, - revision: 2, - entries_json: JSON.stringify([{ id: "a1", kind: "assistant", text: "new", round: 1 }]), - }, - { flush: true }, - ); - store.applySnapshot( - { - type: "runtime_snapshot", - seq: 6, - revision: 1, - entries_json: JSON.stringify([{ id: "a1", kind: "assistant", text: "old", round: 1 }]), - }, - { flush: true }, - ); - - const snapshot = store.getSnapshot(); - assert.equal(snapshot.entries.length, 1); - assert.equal(snapshot.entries[0].text, "new"); -}); - -test("runtime snapshot fences old deltas and accepts newer stream events", () => { - const { createLiveConversationStreamStore } = loader.loadModule( - "src/lib/liveConversationStreamStore.ts", - ); - const store = createLiveConversationStreamStore(); - - store.applySnapshot( - { - type: "runtime_snapshot", - seq: 10, - revision: 1, - entries_json: JSON.stringify([{ id: "a1", kind: "assistant", text: "partial", round: 1 }]), - }, - { flush: true }, - ); - store.appendEvent( - { - type: "token", - seq: 8, - text: " stale", - conversation_id: "conversation-1", - round: 1, - }, - { flush: true }, - ); - assert.equal(store.getSnapshot().entries[0].text, "partial"); - - store.appendEvent( - { - type: "token", - seq: 11, - text: " fresh", - conversation_id: "conversation-1", - round: 1, - }, - { flush: true }, - ); - assert.equal(store.getSnapshot().entries[0].text, "partial fresh"); -}); diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs new file mode 100644 index 00000000..6cee586d --- /dev/null +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); + +const { createTranscriptStore } = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); + +function runStarted(runId, seq, extra = {}) { + return { type: "run_started", conversation_id: "conv-1", run_id: runId, seq, ...extra }; +} + +function runFinished(runId, seq, status = "completed", extra = {}) { + return { type: "run_finished", conversation_id: "conv-1", run_id: runId, seq, status, ...extra }; +} + +function token(runId, seq, text) { + return { type: "token", conversation_id: "conv-1", run_id: runId, seq, text }; +} + +function userMessage(runId, seq, message, extra = {}) { + return { type: "user_message", conversation_id: "conv-1", run_id: runId, seq, message, ...extra }; +} + +test("run lifecycle: reply settles in the tail and folds at the next run_started", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "hello")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "answer ")); + store.applyEvent(token("run-1", 4, "text")); + store.flush(); + + let snapshot = store.getSnapshot(); + assert.equal(snapshot.committed.length, 0); + assert.equal(snapshot.tail.length, 2); + assert.equal(snapshot.activeRun?.runId, "run-1"); + + const assistantId = snapshot.tail[1].id; + assert.equal(snapshot.tail[1].text, "answer text"); + + // Reply end: entries stay in the tail (zero DOM movement), busy clears. + store.applyEvent(runFinished("run-1", 5)); + store.flush(); + snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun, null); + assert.equal(snapshot.committed.length, 0); + assert.equal(snapshot.tail.length, 2); + assert.equal(snapshot.tail[1].id, assistantId, "settled entry keeps its id"); + const foldRevisionBefore = snapshot.foldRevision; + + // Queue auto-send handoff: the next run folds the settled tail into + // committed and streams into a fresh live segment. + store.applyEvent(userMessage("run-2", 6, "queued prompt")); + store.applyEvent(runStarted("run-2", 7)); + store.applyEvent(token("run-2", 8, "second")); + store.flush(); + snapshot = store.getSnapshot(); + assert.equal(snapshot.committed.length, 2, "previous reply folded into committed"); + assert.equal(snapshot.committed[1].id, assistantId, "fold preserves ids"); + assert.ok(snapshot.foldRevision > foldRevisionBefore); + assert.equal(snapshot.activeRun?.runId, "run-2"); + assert.deepEqual( + snapshot.tail.map((entry) => entry.kind), + ["user", "assistant"], + ); + assert.equal(snapshot.tail[0].text, "queued prompt"); + assert.equal(snapshot.tail[1].text, "second"); +}); + +test("cross-run entries never collide on id", () => { + const store = createTranscriptStore(); + for (const runId of ["run-1", "run-2"]) { + store.applyEvent(runStarted(runId, runId === "run-1" ? 1 : 4)); + store.applyEvent(token(runId, runId === "run-1" ? 2 : 5, "same text")); + store.applyEvent(runFinished(runId, runId === "run-1" ? 3 : 6)); + } + store.applyEvent(runStarted("run-3", 7)); + store.flush(); + + const snapshot = store.getSnapshot(); + const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); +}); + +test("optimistic user entry is adopted by client_request_id keeping its id", () => { + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "hi there" }); + store.flush(); + const optimisticId = store.getSnapshot().tail[0].id; + + store.applyEvent(userMessage("run-1", 1, "hi there", { client_request_id: "client-1" })); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.tail.length, 1, "seeded echo must not duplicate the bubble"); + assert.equal(snapshot.tail[0].id, optimisticId, "user bubble keeps its identity"); +}); + +test("failed run appends an error entry and clears busy", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent(runFinished("run-1", 2, "failed", { message: "model exploded" })); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun, null); + const tailText = snapshot.tail.map((entry) => entry.text ?? "").join("\n"); + assert.match(tailText, /model exploded/); +}); + +test("run_queued removes the run's provisional entries", () => { + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "park me" }); + store.applyEvent(userMessage("run-1", 1, "park me", { client_request_id: "client-1" })); + store.flush(); + assert.equal(store.getSnapshot().tail.length, 1); + + store.applyEvent({ + type: "run_queued", + conversation_id: "conv-1", + run_id: "run-1", + seq: 2, + client_request_id: "client-1", + }); + store.flush(); + assert.equal(store.getSnapshot().tail.length, 0, "queued prompt leaves the transcript"); +}); + +test("history snapshot merge preserves rendered ids and upgrades content", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "question")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "reply")); + store.applyEvent(runFinished("run-1", 4)); + store.applyEvent(userMessage("run-2", 5, "next")); + store.applyEvent(runStarted("run-2", 6)); + store.flush(); + const settledAssistantId = store.getSnapshot().committed.find( + (entry) => entry.kind === "assistant", + )?.id; + assert.ok(settledAssistantId, "first reply folded into committed"); + + // History arrives with its own ids for the same logical entries plus the + // tail entries; tail entries must stay in the tail region. + store.applyHistorySnapshot([ + { id: "hist-1", kind: "user", text: "question", attachments: [] }, + { id: "hist-2", kind: "assistant", text: "reply", round: 0 }, + { id: "hist-3", kind: "user", text: "next", attachments: [] }, + ]); + store.flush(); + const snapshot = store.getSnapshot(); + const committedAssistant = snapshot.committed.find((entry) => entry.kind === "assistant"); + assert.equal( + committedAssistant?.id, + settledAssistantId, + "history merge keeps the rendered id", + ); + assert.equal( + snapshot.committed.some((entry) => entry.text === "next" && entry.kind === "user"), + false, + "entries still rendered in the tail stay out of committed", + ); + assert.equal(snapshot.tail.some((entry) => entry.text === "next"), true); +}); + +test("rebased truncates committed at the edited user message", () => { + const store = createTranscriptStore(); + store.applyHistorySnapshot([ + { + id: "hist-1", + kind: "user", + text: "first", + attachments: [], + messageRef: { + segmentIndex: 0, + messageIndex: 0, + segmentId: "segment-1", + messageId: "message-1", + role: "user", + contentHash: "hash-1", + }, + }, + { id: "hist-2", kind: "assistant", text: "answer", round: 0 }, + { + id: "hist-3", + kind: "user", + text: "second", + attachments: [], + messageRef: { + segmentIndex: 0, + messageIndex: 2, + segmentId: "segment-1", + messageId: "message-3", + role: "user", + contentHash: "hash-3", + }, + }, + ]); + store.applyEvent({ + type: "rebased", + conversation_id: "conv-1", + run_id: "run-9", + seq: 1, + base_message_ref: { + segment_index: 0, + message_index: 2, + segment_id: "segment-1", + message_id: "message-3", + role: "user", + content_hash: "hash-3", + }, + }); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + snapshot.committed.map((entry) => entry.id), + ["hist-1", "hist-2"], + "committed truncated at the edited message", + ); +}); + +test("reset sync rebuilds the tail from a runtime snapshot", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent(token("run-1", 2, "will be lost")); + store.flush(); + + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-2", + latestSeq: 3, + reset: true, + activity: { + runId: "run-2", + state: "running", + startedSeq: 1, + toolStatus: "Vibing", + toolStatusIsCompaction: false, + updatedAt: 1, + }, + snapshot: { + runId: "run-2", + revision: 5, + entriesJson: JSON.stringify([ + { id: "snap-1", kind: "assistant", text: "rebuilt from snapshot", round: 0 }, + ]), + toolStatus: "Vibing", + toolStatusIsCompaction: false, + }, + events: [{ type: "token", conversation_id: "conv-1", run_id: "run-2", seq: 3, text: "!" }], + }); + store.flush(); + + const snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun?.runId, "run-2"); + assert.equal(snapshot.toolStatus, "Vibing"); + const text = snapshot.tail.map((entry) => entry.text ?? "").join(""); + assert.match(text, /rebuilt from snapshot/); + assert.doesNotMatch(text, /will be lost/); +}); + +test("tool status mirrors into the snapshot and clears on run end", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-1", + seq: 2, + status: "Vibing", + }); + store.flush(); + assert.equal(store.getSnapshot().toolStatus, "Vibing"); + + store.applyEvent(runFinished("run-1", 3)); + store.flush(); + assert.equal(store.getSnapshot().toolStatus, null); +}); From bd3badc11428a8c55f07a46578396e43934607db Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 13:05:14 +0800 Subject: [PATCH 18/64] refactor(webui): rebuild chat orchestration on conversation streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GatewayApp.tsx drops from 6656 to 4533 lines: transcript rendering sources from per-conversation transcript stores (committed + tail), busy and sidebar dots derive from the activity store and command pipeline, and the displayed conversation holds one persistent stream subscription regardless of running state — GUI queue auto-sends simply flow in. sendChat shrinks to a pipeline submission with command_update-driven draft binding; cancel no longer marks terminal locally; edit_resend truncation rides the rebased stream event; the queued_in_gui delayed reloads and all fifteen recovery layers are gone (page-restore rehydration, completed markers, hydration blocks, draft adoption windows, per-run abort registries). Rendering: GatewayTranscript consumes disjoint committed/tail entries (tail dedup helpers deleted); run_started performs the single scroll-compensated flushSync fold; Markdown render mode is fixed per entry birth (live-born entries stay in streaming mode forever, shikiTheme passes in both modes) so the reply-end streaming→static reflow cannot occur. Tests: socket suite rewritten for the persistent subscribe/resume contract; history-chat-ui covers the id-preserving quiet merge; queue helper module trimmed to its one live export. Co-Authored-By: Claude Fable 5 --- .../test/webui/chat-turn-queue.test.mjs | 143 +- .../test/webui/gateway-socket-client.test.mjs | 2339 ++--------- .../test/webui/history-chat-ui.test.mjs | 778 +--- .../agent-gateway/web/src/app/GatewayApp.tsx | 3427 ++++------------- .../web/src/app/chatEventUtils.ts | 110 +- crates/agent-gateway/web/src/app/constants.ts | 7 - .../agent-gateway/web/src/app/historyUtils.ts | 25 - crates/agent-gateway/web/src/app/types.ts | 32 +- .../web/src/components/GatewayTranscript.tsx | 99 +- .../web/src/components/Markdown.tsx | 11 +- .../lib/chat/stream/useConversationChat.ts | 151 + .../agent-gateway/web/src/lib/gatewayTypes.ts | 36 +- .../agent-gateway/web/src/lib/historySync.ts | 81 +- .../web/src/pages/StatusDashboardPage.tsx | 21 - .../web/src/pages/chat/AssistantBubble.tsx | 9 + .../web/src/pages/chat/queue/chatTurnQueue.ts | 183 - 16 files changed, 1268 insertions(+), 6184 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts diff --git a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs index 167f2112..b1498ab3 100644 --- a/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs +++ b/crates/agent-gateway/test/webui/chat-turn-queue.test.mjs @@ -2,130 +2,49 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; +// The chat turn queue itself lives in the desktop GUI (the gateway relays +// snapshots); the web module keeps only the composer-side content check. const loader = createWebModuleLoader(); -const queue = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); +const { queuedChatTurnHasContent } = loader.loadModule("src/pages/chat/queue/chatTurnQueue.ts"); -function draft(text, segments = [{ type: "text", text }]) { +function draft(overrides = {}) { return { - segments, - text, - textWithoutLargePastes: text, + isEmpty: false, + text: "hello", + textWithoutLargePastes: "hello", largePastes: [], - skillMentions: [], - commitMentions: [], - gitFileMentions: [], - isEmpty: text.trim() === "", + segments: [{ type: "text", text: "hello" }], + ...overrides, }; } -function turn(id, conversationId, text) { - return queue.createQueuedChatTurn({ - id, - conversationId, - draft: draft(text), - uploadedFiles: [], - workdir: "/workspace", - runtimeControls: { - thinkingEnabled: false, - reasoning: "off", - nativeWebSearchEnabled: false, - }, - createdAt: 1, - }); -} - -test("gateway web queued chat turns append, promote, remove, and take the next turn", () => { - const first = turn("a1", "conversation-a", "first"); - const second = turn("a2", "conversation-a", "second"); - - const appended = queue.appendQueuedChatTurn(queue.appendQueuedChatTurn([], first), second); - assert.deepEqual( - appended.map((item) => item.id), - ["a1", "a2"], - ); - - const promoted = queue.promoteQueuedChatTurn(appended, "a2"); - assert.deepEqual( - promoted.map((item) => item.id), - ["a2", "a1"], - ); - - const taken = queue.takeNextQueuedChatTurn(promoted, "conversation-a"); - assert.equal(taken.item.id, "a2"); - assert.deepEqual( - taken.queue.map((item) => item.id), - ["a1"], - ); - - assert.deepEqual(queue.removeQueuedChatTurn(taken.queue, "a1"), []); +test("queuedChatTurnHasContent accepts drafts with text", () => { + assert.equal(queuedChatTurnHasContent(draft(), []), true); }); -test("gateway web queued chat turn movement stays scoped to the same conversation", () => { - const mixed = [ - turn("a1", "conversation-a", "a one"), - turn("b1", "conversation-b", "b one"), - turn("a2", "conversation-a", "a two"), +test("queuedChatTurnHasContent accepts empty drafts with uploads", () => { + const uploads = [ + { + relativePath: "notes.md", + absolutePath: "/workspace/notes.md", + fileName: "notes.md", + kind: "text", + sizeBytes: 12, + }, ]; - - const moved = queue.moveQueuedChatTurn(mixed, "a2", "up"); - assert.deepEqual( - moved.map((item) => item.id), - ["a2", "b1", "a1"], - ); + assert.equal(queuedChatTurnHasContent(draft({ isEmpty: true, text: "" }), uploads), true); }); -test("gateway web edited queued chat turns return to their original priority slot", () => { - const first = turn("a1", "conversation-a", "first"); - const third = turn("a3", "conversation-a", "third"); - const editedSecond = turn("a2", "conversation-a", "edited second"); - - const reinserted = queue.insertQueuedChatTurnAtSlot([first, third], editedSecond, { - conversationId: "conversation-a", - previousId: "a1", - nextId: "a3", - index: 1, - }); - - assert.deepEqual( - reinserted.map((item) => item.id), - ["a1", "a2", "a3"], - ); - assert.equal(reinserted[1].draft.text, "edited second"); +test("queuedChatTurnHasContent rejects missing or empty drafts", () => { + assert.equal(queuedChatTurnHasContent(null, []), false); + assert.equal(queuedChatTurnHasContent(undefined, []), false); + assert.equal(queuedChatTurnHasContent(draft({ isEmpty: true, text: " " }), []), false); }); -test("gateway web queued chat turn preview keeps structured draft hints compact", () => { - const richDraft = draft("hello long paste", [ - { type: "text", text: "hello " }, - { - type: "largePaste", - paste: { - id: "paste-1", - label: "pasted.txt", - text: "large paste body", - charCount: 16, - lineCount: 1, - preview: "large paste body", - }, - }, - { - type: "fileMention", - reference: { - path: "src/notes.txt", - kind: "file", - }, - }, - { - type: "skillMention", - skill: { - name: "reviewer", - description: "", - skillFile: "SKILL.md", - baseDir: "/skills/reviewer", - }, - }, - ]); - - assert.equal(queue.buildQueuedChatTurnPreview(richDraft), "hello pasted.txtnotes.txt$reviewer"); - assert.equal(queue.queuedChatTurnHasContent(richDraft, []), true); - assert.equal(queue.queuedChatTurnHasContent(draft(""), [{ fileName: "a.txt" }]), true); +test("queuedChatTurnHasContent treats structured-only drafts as content", () => { + assert.equal( + queuedChatTurnHasContent(draft({ isEmpty: false, text: "" }), []), + true, + "non-empty draft flag wins even without plain text", + ); }); diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index 68eb22e6..b017533c 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -2,27 +2,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; -class FakeMessagePort { - messages = []; - closed = false; - onmessage = null; - onmessageerror = null; - - postMessage(message) { - this.messages.push(message); - } - - start() {} - - close() { - this.closed = true; - } - - emit(data) { - this.onmessage?.({ data }); - } -} - class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; @@ -115,17 +94,6 @@ function installBrowser(options = {}) { }; } -class FakeSharedWorker { - static instances = []; - - constructor(url, options) { - this.url = url; - this.options = options; - this.port = new FakeMessagePort(); - FakeSharedWorker.instances.push(this); - } -} - function waitFor(predicate, label) { return new Promise((resolve, reject) => { const startedAt = Date.now(); @@ -408,1465 +376,15 @@ test("GatewayWebSocketClient does not recover mutating git requests", async () = const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); resetGatewayWebSocketClient(); - const client = getGatewayWebSocketClient(" token "); - const stagePromise = client.gitRequest("stage", "/workspace/project", { path: "src/main.rs" }); - const socket = await connectAndAuth(); - await waitFor(() => socket.sent.some((message) => message.type === "git.stage"), "git.stage envelope"); - socket.close({ code: 1006, wasClean: false }); - - await assert.rejects(stagePromise, /Gateway WebSocket disconnected/); - assert.equal(FakeWebSocket.instances.length, 1); - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client sends conversation cancel directly over HTTP", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const realFetch = globalThis.fetch; - const fetchCalls = []; - globalThis.fetch = async (rawUrl, init = {}) => { - fetchCalls.push({ url: new URL(String(rawUrl)), init }); - return new Response(JSON.stringify({ accepted: true }), { - status: 202, - headers: { "Content-Type": "application/json" }, - }); - }; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - await client.cancelChat(" conversation-1 ", " run-1 "); - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.toString(), "https://gateway.example/api/chat/commands"); - assert.equal(fetchCalls[0].init.method, "POST"); - assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer token"); - assert.equal(fetchCalls[0].init.headers["X-LiveAgent-CSRF"], "1"); - assert.deepEqual(JSON.parse(fetchCalls[0].init.body), { - type: "chat.cancel", - payload: { - run_id: "run-1", - conversation_id: "conversation-1", - }, - }); - assert.equal( - port.messages.some((message) => message.type === "request" && message.method === "chat.cancel"), - false, - ); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("SharedWorker gateway client emits chat queue snapshots from worker events", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const snapshots = []; - client.subscribeChatQueue((snapshot) => snapshots.push(snapshot)); - const snapshot = { - conversationId: "conversation-1", - revision: 7, - items: [ - { - id: "queue-1", - previewText: "next prompt", - fileCount: 0, - createdAt: 123, - source: "gui", - editable: true, - }, - ], - }; - port.emit({ - type: "event", - event_type: "chat_queue", - connection_id: connect.connection_id, - payload: snapshot, - }); - - assert.deepEqual(snapshots, [snapshot]); - resetGatewayWebSocketClient(); -}); - -test("GatewayWebSocketClient streamChatEvents sends replay cursor", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: " live-run ", - afterSeq: 41, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.pathname, "/api/chat/events"); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "live-run"); - assert.equal(fetchCalls[0].url.searchParams.get("conversation_id"), "conversation-1"); - assert.equal(fetchCalls[0].url.searchParams.get("after_seq"), "41"); - assert.equal(fetchCalls[0].init.method, "GET"); - assert.equal(fetchCalls[0].init.headers.Accept, "text/event-stream"); - assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer token"); - assert.equal(fetchCalls[0].init.headers["Last-Event-ID"], "41"); - assert.deepEqual(events, [{ type: "done", conversation_id: "conversation-1", seq: 42 }]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents resumes from the last delivered event id", async () => { - installBrowser({ - setTimeout: (fn, _delay, ...args) => setTimeout(fn, 0, ...args), - }); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const attempt = fetchCalls.length; - const body = new ReadableStream({ - start(controller) { - if (attempt === 1) { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"token","text":"hello","conversation_id":"conversation-1"}}\n\n', - ), - ); - } else { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"token","text":"duplicate","conversation_id":"conversation-1"}}\n\n' + - 'id: 43\nevent: chat.event\ndata: {"seq":43,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - } - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { afterSeq: 41 })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 2); - assert.equal(fetchCalls[0].url.searchParams.get("after_seq"), "41"); - assert.equal(fetchCalls[0].init.headers["Last-Event-ID"], "41"); - assert.equal(fetchCalls[1].url.searchParams.get("after_seq"), "42"); - assert.equal(fetchCalls[1].init.headers["Last-Event-ID"], "42"); - assert.deepEqual(events, [ - { type: "token", text: "hello", conversation_id: "conversation-1", seq: 42 }, - { type: "done", conversation_id: "conversation-1", seq: 43 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents retries transient gateway HTTP errors", async () => { - installBrowser({ - setTimeout: (fn, _delay, ...args) => setTimeout(fn, 0, ...args), - }); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - if (fetchCalls.length === 1) { - return new Response( - "502 Bad Gatewaynginx", - { status: 502, headers: { "Content-Type": "text/html" } }, - ); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 42\nevent: chat.event\ndata: {"seq":42,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { afterSeq: 41 })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 2); - assert.equal(fetchCalls[0].url.searchParams.get("after_seq"), "41"); - assert.equal(fetchCalls[1].url.searchParams.get("after_seq"), "41"); - assert.deepEqual(events, [{ type: "done", conversation_id: "conversation-1", seq: 42 }]); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents ignores stale terminal events from older runs", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"live-run","seq":3,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n' + - 'id: 4\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":4,"payload":{"type":"token","text":"second","conversation_id":"conversation-1"}}\n\n' + - 'id: 5\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":5,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { afterSeq: 0 })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.deepEqual(events, [ - { type: "token", text: "second", conversation_id: "conversation-1", seq: 4 }, - { type: "done", conversation_id: "conversation-1", seq: 5 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents ignores stale non-terminal events from older runs", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"live-run","seq":3,"payload":{"type":"token","text":"stale","conversation_id":"conversation-1"}}\n\n' + - 'id: 4\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":4,"payload":{"type":"token","text":"fresh","conversation_id":"conversation-1"}}\n\n' + - 'id: 5\nevent: chat.event\ndata: {"run_id":"live-run","snapshot_run_id":"live-run","seq":5,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: "live-run", - afterSeq: 0, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "live-run"); - assert.deepEqual(events, [ - { type: "token", text: "fresh", conversation_id: "conversation-1", seq: 4 }, - { type: "done", conversation_id: "conversation-1", seq: 5 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("GatewayWebSocketClient streamChatEvents isolates a run after interrupt cancellation", async () => { - installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname !== "/api/chat/events") { - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - } - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.control\ndata: {"run_id":"old-run","snapshot_run_id":"new-run","seq":1,"payload":{"type":"cancelled","state":"cancelled","conversation_id":"conversation-1","seq":1}}\n\n' + - 'id: 2\nevent: chat.event\ndata: {"run_id":"old-run","snapshot_run_id":"new-run","seq":2,"payload":{"type":"token","text":"stale tail","conversation_id":"conversation-1","seq":2}}\n\n' + - 'id: 1\nevent: chat.control\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":1,"payload":{"type":"started","state":"running","conversation_id":"conversation-1","seq":1}}\n\n' + - 'id: 2\nevent: chat.event\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":2,"payload":{"type":"token","text":"fresh","conversation_id":"conversation-1","seq":2}}\n\n' + - 'id: 3\nevent: chat.event\ndata: {"run_id":"new-run","snapshot_run_id":"new-run","seq":3,"payload":{"type":"done","conversation_id":"conversation-1","seq":3}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - }; - - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.streamChatEvents(" conversation-1 ", { - runId: "new-run", - afterSeq: 0, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 1); - assert.equal(fetchCalls[0].url.searchParams.get("run_id"), "new-run"); - assert.deepEqual(events, [ - { type: "started", state: "running", conversation_id: "conversation-1", seq: 1 }, - { type: "token", text: "fresh", conversation_id: "conversation-1", seq: 2 }, - { type: "done", conversation_id: "conversation-1", seq: 3 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("SharedWorker gateway client forwards foreground wakeups to the worker", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - window.dispatchEvent({ type: "pageshow" }); - - assert.deepEqual(port.messages.at(-1), { - type: "wakeup", - connection_id: connect.connection_id, - }); - - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client accepts terminal list sessions from worker payload", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const sessionsPromise = client.listTerminals("/workspace/project"); - await waitFor( - () => port.messages.some((message) => message.method === "terminal.list"), - "shared worker terminal.list request", - ); - const request = port.messages.find((message) => message.method === "terminal.list"); - assert.deepEqual(request.payload, { project_path_key: "/workspace/project" }); - - port.emit({ - type: "response", - connection_id: connect.connection_id, - request_id: request.request_id, - payload: { - sessions: [ - { - id: "terminal-1", - project_path_key: "/workspace/project", - cwd: "/workspace/project", - title: "Terminal 1", - created_at: 1, - updated_at: 2, - running: true, - }, - ], - }, - }); - - const sessions = await sessionsPromise; - assert.equal(sessions.length, 1); - assert.equal(sessions[0].id, "terminal-1"); - assert.equal(sessions[0].projectPathKey, "/workspace/project"); - assert.equal(sessions[0].title, "Terminal 1"); - - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client keeps SSH create snapshots from worker payload", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - const client = getGatewayWebSocketClient(" token "); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const createPromise = client.createSshTerminal({ - cwd: "/workspace/project", - projectPathKey: "project-key", - hostId: "host-1", - }); - await waitFor( - () => port.messages.some((message) => message.type === "request" && message.method === "terminal.create_ssh"), - "terminal.create_ssh worker request", - ); - const request = port.messages.find((message) => message.type === "request" && message.method === "terminal.create_ssh"); - assert.ok(request); - assert.deepEqual(request.payload, { - cwd: "/workspace/project", - project_path_key: "project-key", - ssh_host_id: "host-1", - title: undefined, - cols: undefined, - rows: undefined, - sftp_enabled: false, - }); - port.emit({ - type: "response", - connection_id: connect.connection_id, - request_id: request.request_id, - payload: { - snapshot: { - session: { - id: "ssh-1", - projectPathKey: "project-key", - cwd: "/workspace/project", - shell: "ssh", - title: "Claw-SG", - kind: "ssh", - ssh: { - hostId: "host-1", - hostName: "Claw-SG", - username: "root", - host: "8.219.204.112", - port: 22, - authType: "privateKey", - status: "connected", - reconnectAttempt: 0, - reconnectMaxAttempts: 3, - }, - pid: null, - cols: 80, - rows: 24, - createdAt: 10, - updatedAt: 10, - running: true, - }, - output: "root@s878169:~# ", - truncated: false, - outputStartOffset: 0, - outputEndOffset: 18, - }, - }, - }); - - const result = await createPromise; - assert.equal(result.snapshot?.session.id, "ssh-1"); - assert.equal(result.snapshot?.session.ssh?.hostId, "host-1"); - assert.equal(result.snapshot?.output, "root@s878169:~# "); - resetGatewayWebSocketClient(); -}); - -test("SharedWorker gateway client posts chat commands directly over HTTP", async () => { - installBrowser(); - FakeSharedWorker.instances = []; - globalThis.SharedWorker = FakeSharedWorker; - const realFetch = globalThis.fetch; - const encoder = new TextEncoder(); - const fetchCalls = []; - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname === "/api/chat/commands") { - return new Response( - JSON.stringify({ - run_id: "run-1", - conversation_id: "conversation-1", - accepted_seq: 1, - }), - { - status: 202, - headers: { "Content-Type": "application/json" }, - }, - ); - } - if (url.pathname === "/api/chat/events") { - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.event\ndata: {"seq":1,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); - }, - }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); - } - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); - }; - const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); - resetGatewayWebSocketClient(); - - try { - const client = getGatewayWebSocketClient(" token "); - assert.equal(FakeSharedWorker.instances.length, 1); - const port = FakeSharedWorker.instances[0].port; - const connect = port.messages.find((message) => message.type === "connect"); - assert.ok(connect); - port.emit({ - type: "ready", - connection_id: connect.connection_id, - payload: { status: { online: true }, error: null }, - }); - - const events = []; - for await (const event of client.commandChat({ - type: "chat.submit", - message: "hello", - conversationId: "conversation-1", - selectedModel: { - customProviderId: "claude-provider", - model: "claude-test", - providerType: "claude_code", - }, - systemSettings: { - executionMode: "agent-dev", - workdir: "/workspace", - selectedSystemTools: ["http_get_test"], - }, - uploadedFiles: [ - { - relativePath: "uploads/notes.txt", - absolutePath: "/workspace/uploads/notes.txt", - fileName: "notes.txt", - kind: "text", - sizeBytes: 12, - }, - ], - clientRequestId: "client-submit-1", - runtimeControls: { - thinkingEnabled: false, - nativeWebSearchEnabled: true, - reasoning: "xhigh", - }, - })) { - events.push(event); - } - - assert.equal(fetchCalls.length, 2); - assert.equal(fetchCalls[0].url.toString(), "https://gateway.example/api/chat/commands"); - assert.deepEqual(JSON.parse(fetchCalls[0].init.body), { - type: "chat.submit", - payload: { - message: "hello", - conversation_id: "conversation-1", - client_request_id: "client-submit-1", - execution_mode: "agent-dev", - workdir: "/workspace", - selected_system_tools: ["http_get_test"], - uploaded_files: [ - { - relative_path: "uploads/notes.txt", - absolute_path: "/workspace/uploads/notes.txt", - file_name: "notes.txt", - kind: "text", - size_bytes: 12, - }, - ], - selected_model: { - custom_provider_id: "claude-provider", - model: "claude-test", - provider_type: "claude_code", - }, - runtime_controls: { - thinking_enabled: false, - native_web_search_enabled: true, - reasoning: "xhigh", - }, - queue_policy: "auto", - }, - }); - assert.equal(fetchCalls[1].url.pathname, "/api/chat/events"); - assert.equal(fetchCalls[1].url.searchParams.get("run_id"), "run-1"); - assert.deepEqual(events, [{ type: "done", conversation_id: "conversation-1", seq: 1 }]); - const legacyWorkerChatCommand = "chat" + ".command"; - assert.equal( - port.messages.some( - (message) => message.type === "request" && message.method === legacyWorkerChatCommand, - ), - false, - ); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } -}); - -test("Gateway SharedWorker broadcasts events with each port connection id", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - statusListeners = []; - historyListeners = []; - settingsListeners = []; - sftpTransferListeners = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus(listener) { - this.statusListeners.push(listener); - return () => {}; - } - - subscribeHistory(listener) { - this.historyListeners.push(listener); - return () => {}; - } - - subscribeSettings(listener) { - this.settingsListeners.push(listener); - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers(listener) { - this.sftpTransferListeners.push(listener); - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - assert.equal(typeof globalThis.onconnect, "function"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - - firstPort.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - assert.equal(clientInstances.length, 1); - assert.equal(clientInstances[0].token, "token"); - assert.deepEqual(firstPort.messages.at(-1), { - type: "ready", - connection_id: "connection-1", - payload: { status: null, error: null }, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "ready", - connection_id: "connection-2", - payload: { status: null, error: null }, - }); - - const historyEvent = { kind: "idle", conversation_id: "conversation-1" }; - clientInstances[0].historyListeners[0](historyEvent); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "history", - connection_id: "connection-1", - payload: historyEvent, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "history", - connection_id: "connection-2", - payload: historyEvent, - }); - - const sftpEvent = { - kind: "progress", - transfer: { - id: "transfer-1", - sessionId: "ssh-1", - status: "running", - bytesTransferred: 12, - totalBytes: 24, - }, - }; - clientInstances[0].sftpTransferListeners[0](sftpEvent); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "sftp", - connection_id: "connection-1", - payload: sftpEvent, - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "sftp", - connection_id: "connection-2", - payload: sftpEvent, - }); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker applies foreground wakeups to the managed socket client", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - wakeups = 0; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - noteForegroundWakeup() { - this.wakeups += 1; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - port.emit({ type: "wakeup", connection_id: "connection-1" }); - - assert.equal(clientInstances.length, 1); - assert.equal(clientInstances[0].wakeups, 1); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker terminal metadata reaches every page while output stays scoped", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - async listTerminals(projectPathKey) { - this.calls.push(["listTerminals", projectPathKey ?? ""]); - return [ - { - id: "terminal-1", - projectPathKey: "/workspace/project-a", - cwd: "/workspace/project-a", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 1, - running: true, - }, - ]; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - firstPort.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - firstPort.emit({ - type: "request", - connection_id: "connection-1", - request_id: "terminal-list-all", - method: "terminal.list", - payload: {}, - }); - await waitFor( - () => firstPort.messages.some((message) => message.request_id === "terminal-list-all"), - "terminal list all response", - ); - assert.deepEqual(clientInstances[0].calls, [["listTerminals", ""]]); - const listResponse = firstPort.messages.find( - (message) => message.request_id === "terminal-list-all", - ); - assert.equal(listResponse.payload.sessions[0].id, "terminal-1"); - - const event = { - kind: "created", - sessionId: "terminal-2", - projectPathKey: "/workspace/project-b", - session: { - id: "terminal-2", - projectPathKey: "/workspace/project-b", - cwd: "/workspace/project-b", - shell: "zsh", - title: "Terminal 2", - cols: 80, - rows: 24, - createdAt: 2, - updatedAt: 2, - running: true, - }, - }; - clientInstances[0].terminalListeners[0](event); - - assert.deepEqual(firstPort.messages.at(-1), { - type: "event", - event_type: "terminal", - payload: event, - connection_id: "connection-1", - }); - assert.deepEqual(secondPort.messages.at(-1), { - type: "event", - event_type: "terminal", - payload: event, - connection_id: "connection-2", - }); - - const outputEvent = { - ...event, - kind: "output", - data: "secret\n", - }; - clientInstances[0].terminalListeners[0](outputEvent); - - assert.equal( - firstPort.messages.some((message) => message.payload === outputEvent), - false, - ); - assert.equal( - secondPort.messages.some((message) => message.payload === outputEvent), - false, - ); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker forwards terminal stream snapshot and output messages", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - const streamSession = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; - let resolveAttach = null; - const outputListeners = []; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - terminalStream = { - attach: (session, options) => { - this.calls.push(["terminalStream.attach", session.id, options?.maxBytes]); - return new Promise((resolve) => { - resolveAttach = () => { - resolve({ - snapshot: { - session, - bytes: new Uint8Array([112, 119, 100]), - truncated: false, - outputStartOffset: 10, - outputEndOffset: 13, - }, - write: (bytes) => { - this.calls.push(["terminalStream.write", [...bytes]]); - return true; - }, - resize: (cols, rows) => this.calls.push(["terminalStream.resize", cols, rows]), - dispose: () => this.calls.push(["terminalStream.dispose", session.id]), - subscribeOutput: (listener) => { - outputListeners.push(listener); - return () => {}; - }, - subscribeInputState: () => () => {}, - }); - }; - }); - }, - }; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - port.emit({ - type: "terminal_stream_attach", - connection_id: "connection-1", - stream_id: "page-stream-1", - session: streamSession, - max_bytes: 8192, - }); - await waitFor( - () => clientInstances[0]?.calls.some((call) => call[0] === "terminalStream.attach"), - "terminal stream attach", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "terminalStream.attach", - "terminal-1", - 8192, - ]); - - resolveAttach(); - await waitFor( - () => port.messages.some((message) => message.type === "terminal_stream_snapshot"), - "terminal stream snapshot", - ); - const snapshotMessage = port.messages.find((message) => message.type === "terminal_stream_snapshot"); - assert.equal(snapshotMessage.connection_id, "connection-1"); - assert.equal(snapshotMessage.stream_id, "page-stream-1"); - assert.deepEqual([...snapshotMessage.payload.bytes], [112, 119, 100]); - assert.equal(snapshotMessage.payload.outputStartOffset, 10); - - outputListeners[0]({ - sessionId: "terminal-1", - projectPathKey: "/workspace/project", - bytes: new Uint8Array([13, 10]), - startOffset: 13, - endOffset: 15, - }); - await waitFor( - () => port.messages.some((message) => message.type === "terminal_stream_output"), - "terminal stream output", - ); - const outputMessage = port.messages.find((message) => message.type === "terminal_stream_output"); - assert.equal(outputMessage.connection_id, "connection-1"); - assert.equal(outputMessage.stream_id, "page-stream-1"); - assert.deepEqual([...outputMessage.payload.bytes], [13, 10]); - assert.equal(outputMessage.payload.startOffset, 13); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker keeps one upstream terminal stream until every port detaches", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - const streamSession = { - id: "terminal-1", - projectPathKey: "/workspace/project", - cwd: "/workspace/project", - shell: "zsh", - title: "Terminal 1", - cols: 80, - rows: 24, - createdAt: 1, - updatedAt: 2, - running: true, - }; - const outputListeners = []; - const inputStateListeners = []; - const currentInputState = { - paused: false, - queuedBytes: 17, - highWaterBytes: 256 * 1024, - }; - - class MockGatewayWebSocketClient { - terminalListeners = []; - calls = []; - terminalStream = { - attach: async (session, options) => { - this.calls.push(["terminalStream.attach", session.id, options?.maxBytes]); - return { - snapshot: { - session, - bytes: new Uint8Array(), - truncated: false, - outputStartOffset: 0, - outputEndOffset: 0, - }, - write: (bytes) => { - this.calls.push(["terminalStream.write", [...bytes]]); - return true; - }, - resize: (cols, rows) => this.calls.push(["terminalStream.resize", cols, rows]), - dispose: () => this.calls.push(["terminalStream.dispose", session.id]), - subscribeOutput: (listener) => { - outputListeners.push(listener); - return () => this.calls.push(["terminalStream.unsubscribe", session.id]); - }, - subscribeInputState: (listener) => { - inputStateListeners.push(listener); - listener(currentInputState); - return () => this.calls.push(["terminalStream.inputState.unsubscribe", session.id]); - }, - }; - }, - }; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal(listener) { - this.terminalListeners.push(listener); - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const firstPort = new FakeMessagePort(); - const secondPort = new FakeMessagePort(); - globalThis.onconnect({ ports: [firstPort] }); - globalThis.onconnect({ ports: [secondPort] }); - firstPort.emit({ type: "connect", connection_id: "connection-1", token: "token" }); - secondPort.emit({ type: "connect", connection_id: "connection-2", token: "token" }); - - firstPort.emit({ - type: "terminal_stream_attach", - connection_id: "connection-1", - stream_id: "first-stream", - session: streamSession, - max_bytes: 4096, - }); - await waitFor( - () => firstPort.messages.some((message) => message.type === "terminal_stream_snapshot"), - "first terminal stream snapshot", - ); - - secondPort.emit({ - type: "terminal_stream_attach", - connection_id: "connection-2", - stream_id: "second-stream", - session: streamSession, - max_bytes: 4096, - }); - await waitFor( - () => - secondPort.messages.some((message) => message.type === "terminal_stream_snapshot") && - secondPort.messages.some((message) => message.type === "terminal_stream_input_state"), - "second terminal stream snapshot and input state", - ); - assert.equal( - clientInstances[0].calls.filter((call) => call[0] === "terminalStream.attach").length, - 1, - ); - assert.equal(inputStateListeners.length, 1); - const secondInputState = secondPort.messages.find( - (message) => message.type === "terminal_stream_input_state", - ); - assert.equal(secondInputState.stream_id, "second-stream"); - assert.deepEqual(secondInputState.payload, currentInputState); - - firstPort.emit({ - type: "terminal_stream_write", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - bytes: new Uint8Array([97, 98]), - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.write"), - "terminal stream write", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.write", [97, 98]]); - - firstPort.emit({ - type: "terminal_stream_resize", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - cols: 100, - rows: 30, - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.resize"), - "terminal stream resize", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.resize", 100, 30]); - - firstPort.emit({ - type: "terminal_stream_detach", - connection_id: "connection-1", - stream_id: "first-stream", - session_id: "terminal-1", - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - assert.equal( - clientInstances[0].calls.some((call) => call[0] === "terminalStream.dispose"), - false, - ); - - const firstPortMessageCount = firstPort.messages.length; - outputListeners[0]({ - sessionId: "terminal-1", - projectPathKey: "/workspace/project", - bytes: new Uint8Array([112, 119, 100]), - startOffset: 0, - endOffset: 3, - }); - assert.equal(firstPort.messages.length, firstPortMessageCount); - assert.equal(secondPort.messages.at(-1).type, "terminal_stream_output"); - assert.equal(secondPort.messages.at(-1).stream_id, "second-stream"); - assert.deepEqual([...secondPort.messages.at(-1).payload.bytes], [112, 119, 100]); - - secondPort.emit({ - type: "terminal_stream_detach", - connection_id: "connection-2", - stream_id: "second-stream", - session_id: "terminal-1", - }); - await waitFor( - () => clientInstances[0].calls.some((call) => call[0] === "terminalStream.dispose"), - "upstream terminal stream dispose", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["terminalStream.dispose", "terminal-1"]); + const client = getGatewayWebSocketClient(" token "); + const stagePromise = client.gitRequest("stage", "/workspace/project", { path: "src/main.rs" }); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((message) => message.type === "git.stage"), "git.stage envelope"); + socket.close({ code: 1006, wasClean: false }); - globalThis.onconnect = previousOnConnect; + await assert.rejects(stagePromise, /Gateway WebSocket disconnected/); + assert.equal(FakeWebSocket.instances.length, 1); + resetGatewayWebSocketClient(); }); test("GatewayWebSocketClient sends mention query payloads", async () => { @@ -2299,416 +817,6 @@ test("GatewayWebSocketClient sends history share requests", async () => { resetGatewayWebSocketClient(); }); -test("Gateway SharedWorker forwards history share requests", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - statusListeners = []; - historyListeners = []; - settingsListeners = []; - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus(listener) { - this.statusListeners.push(listener); - return () => {}; - } - - subscribeHistory(listener) { - this.historyListeners.push(listener); - return () => {}; - } - - subscribeSettings(listener) { - this.settingsListeners.push(listener); - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - getHistoryShare(conversationID) { - this.calls.push(["getHistoryShare", conversationID]); - return { - conversation_id: conversationID, - enabled: false, - token: "", - created_at: 0, - updated_at: 0, - }; - } - - setHistoryShare(conversationID, enabled, options) { - this.calls.push(["setHistoryShare", conversationID, enabled, options]); - return { - conversation_id: conversationID, - enabled, - token: enabled ? "share-token" : "", - created_at: 10, - updated_at: 20, - redact_tool_content: options?.redactToolContent === true, - }; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - assert.equal(clientInstances.length, 1); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "share-get", - method: "history.share.get", - payload: { conversation_id: "conversation-1" }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "share-get"), - "shared worker history share get response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["getHistoryShare", "conversation-1"]); - assert.deepEqual(port.messages.at(-1), { - type: "response", - connection_id: "connection-1", - request_id: "share-get", - payload: { - conversation_id: "conversation-1", - enabled: false, - token: "", - created_at: 0, - updated_at: 0, - }, - error: undefined, - }); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "share-set", - method: "history.share.set", - payload: { - conversation_id: "conversation-1", - enabled: true, - redact_tool_content: true, - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "share-set"), - "shared worker history share set response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "setHistoryShare", - "conversation-1", - true, - { redactToolContent: true }, - ]); - assert.deepEqual(port.messages.at(-1), { - type: "response", - connection_id: "connection-1", - request_id: "share-set", - payload: { - conversation_id: "conversation-1", - enabled: true, - token: "share-token", - created_at: 10, - updated_at: 20, - redact_tool_content: true, - }, - error: undefined, - }); - - globalThis.onconnect = previousOnConnect; -}); - -test("Gateway SharedWorker forwards tunnel requests", async () => { - installBrowser(); - const loader = createWebModuleLoader(); - const gatewaySocketPath = loader.resolveLocal("src/lib/gatewaySocket.ts"); - const clientInstances = []; - - class MockGatewayWebSocketClient { - calls = []; - - constructor(token) { - this.token = token; - clientInstances.push(this); - } - - subscribeStatus() { - return () => {}; - } - - subscribeHistory() { - return () => {}; - } - - subscribeSettings() { - return () => {}; - } - - subscribeTerminal() { - return () => {}; - } - - subscribeSftpTransfers() { - return () => {}; - } - - subscribeChatQueue() { - return () => {}; - } - - listTunnels() { - this.calls.push(["listTunnels"]); - return [ - { - id: "tun-1", - slug: "slug-1", - name: "App", - targetUrl: "http://localhost:3000", - publicUrl: "https://gateway.example/t/slug-1/", - createdAt: 10, - expiresAt: 3700, - activeConnections: 0, - status: "active", - diagnostics: [], - }, - ]; - } - - createTunnel(input) { - this.calls.push(["createTunnel", input]); - return { - id: "tun-2", - slug: "slug-2", - name: input.name ?? "", - targetUrl: input.targetUrl, - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: 920, - activeConnections: 0, - status: "active", - diagnostics: [ - { - protocol: "websocket", - status: "ok", - statusCode: 101, - errorCode: "", - message: "WebSocket probe succeeded", - checkedAt: 20, - }, - ], - }; - } - - updateTunnel(input) { - this.calls.push(["updateTunnel", input]); - return { - id: input.id, - slug: "slug-2", - name: input.name ?? "", - targetUrl: input.targetUrl, - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: input.ttlSeconds === 0 ? 0 : 920, - activeConnections: 0, - status: "active", - projectPathKey: input.projectPathKey ?? "", - diagnostics: [], - }; - } - - probeTunnel(id) { - this.calls.push(["probeTunnel", id]); - return { - id, - slug: "slug-2", - name: "Dashboard", - targetUrl: "http://localhost:4000/dashboard", - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: 0, - activeConnections: 0, - status: "active", - projectPathKey: "project:/tmp/liveagent", - diagnostics: [ - { - protocol: "websocket", - status: "failed", - statusCode: 200, - errorCode: "path_or_upgrade_missed", - message: "WebSocket probe returned a 200 HTML response", - checkedAt: 30, - }, - ], - }; - } - - closeTunnel(id) { - this.calls.push(["closeTunnel", id]); - return { - id, - slug: "slug-2", - name: "Closed", - targetUrl: "http://localhost:3000", - publicUrl: "https://gateway.example/t/slug-2/", - createdAt: 20, - expiresAt: 920, - activeConnections: 0, - status: "expired", - diagnostics: [], - }; - } - - dispose() {} - } - - const workerLoader = createWebModuleLoader({ - mocks: { - [gatewaySocketPath]: { - GatewayWebSocketClient: MockGatewayWebSocketClient, - }, - }, - }); - - const previousOnConnect = globalThis.onconnect; - workerLoader.loadModule("src/lib/gatewaySocket.worker.ts"); - - const port = new FakeMessagePort(); - globalThis.onconnect({ ports: [port] }); - port.emit({ type: "connect", connection_id: "connection-1", token: " token " }); - assert.equal(clientInstances.length, 1); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-list", - method: "tunnel.list", - payload: {}, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-list"), - "shared worker tunnel list response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["listTunnels"]); - assert.equal(port.messages.at(-1).payload.tunnels[0].id, "tun-1"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-create", - method: "tunnel.create", - payload: { - targetUrl: "http://localhost:3000/app", - ttlSeconds: 900, - name: "App", - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-create"), - "shared worker tunnel create response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "createTunnel", - { - targetUrl: "http://localhost:3000/app", - ttlSeconds: 900, - name: "App", - }, - ]); - assert.equal(port.messages.at(-1).payload.tunnel.id, "tun-2"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-update-infinite", - method: "tunnel.update", - payload: { - id: "tun-2", - targetUrl: "http://localhost:4000/dashboard", - ttlSeconds: 0, - name: "Dashboard", - projectPathKey: "project:/tmp/liveagent", - }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-update-infinite"), - "shared worker tunnel update response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), [ - "updateTunnel", - { - id: "tun-2", - targetUrl: "http://localhost:4000/dashboard", - ttlSeconds: 0, - name: "Dashboard", - projectPathKey: "project:/tmp/liveagent", - }, - ]); - assert.equal(port.messages.at(-1).payload.tunnel.expiresAt, 0); - assert.equal(port.messages.at(-1).payload.tunnel.projectPathKey, "project:/tmp/liveagent"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-probe", - method: "tunnel.probe", - payload: { id: "tun-2" }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-probe"), - "shared worker tunnel probe response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["probeTunnel", "tun-2"]); - assert.equal(port.messages.at(-1).payload.tunnel.diagnostics[0].errorCode, "path_or_upgrade_missed"); - - port.emit({ - type: "request", - connection_id: "connection-1", - request_id: "tunnel-close", - method: "tunnel.close", - payload: { id: "tun-2" }, - }); - await waitFor( - () => port.messages.some((message) => message.request_id === "tunnel-close"), - "shared worker tunnel close response", - ); - assert.deepEqual(clientInstances[0].calls.at(-1), ["closeTunnel", "tun-2"]); - assert.equal(port.messages.at(-1).payload.tunnel.status, "expired"); - - globalThis.onconnect = previousOnConnect; -}); - test("GatewayWebSocketClient reconnects before read requests when an authenticated socket goes stale", async () => { installBrowser(); const loader = createWebModuleLoader(); @@ -2731,7 +839,7 @@ test("GatewayWebSocketClient reconnects before read requests when an authenticat let mockNow = realDateNow(); Date.now = () => mockNow; - mockNow += 30_000; + mockNow += 46_000; const historyPromise = client.getHistory("conversation-1"); assert.equal(FakeWebSocket.instances.length, 2); @@ -2892,178 +1000,283 @@ test("GatewayWebSocketClient replies to gateway websocket pings", async () => { resetGatewayWebSocketClient(); }); -test("GatewayWebSocketClient commandChat posts commands and streams SSE events until done", async () => { +test("GatewayWebSocketClient chatCommand sends the command envelope and parses the accept response", async () => { installBrowser(); - const realFetch = globalThis.fetch; - const fetchCalls = []; - const encoder = new TextEncoder(); - globalThis.fetch = async (rawUrl, init = {}) => { - const url = new URL(String(rawUrl)); - fetchCalls.push({ url, init }); - if (url.pathname === "/api/chat/commands") { - return new Response( - JSON.stringify({ - run_id: "run-1", + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const commandPromise = client.chatCommand({ + type: "chat.submit", + message: "hello", + conversationId: "conversation-1", + clientRequestId: "req-1", + queuePolicy: "append", + systemSettings: { + executionMode: "agent", + workdir: "/workspace/project", + selectedSystemTools: ["Bash"], + }, + }); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.command"), "chat command envelope"); + const commandEnvelope = socket.sent.find((item) => item.type === "chat.command"); + assert.equal(commandEnvelope.payload.type, "chat.submit"); + assert.equal(commandEnvelope.payload.payload.message, "hello"); + assert.equal(commandEnvelope.payload.payload.conversation_id, "conversation-1"); + assert.equal(commandEnvelope.payload.payload.client_request_id, "req-1"); + assert.equal(commandEnvelope.payload.payload.queue_policy, "append"); + assert.equal(commandEnvelope.payload.payload.workdir, "/workspace/project"); + + socket.receive({ + id: commandEnvelope.id, + type: "response", + payload: { run_id: " run-1 ", conversation_id: "conversation-1", accepted_seq: 7 }, + }); + assert.deepEqual(await commandPromise, { + runId: "run-1", + conversationId: "conversation-1", + acceptedSeq: 7, + }); + resetGatewayWebSocketClient(); +}); + +test("GatewayWebSocketClient cancelChat sends chat.cancel with conversation and run ids", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const cancelPromise = client.cancelChat(" conversation-1 ", " run-9 "); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.cancel"), "chat.cancel envelope"); + const cancelEnvelope = socket.sent.find((item) => item.type === "chat.cancel"); + assert.deepEqual(cancelEnvelope.payload, { + conversation_id: "conversation-1", + run_id: "run-9", + }); + socket.receive({ id: cancelEnvelope.id, type: "response", payload: {} }); + await cancelPromise; + resetGatewayWebSocketClient(); +}); + +test("GatewayWebSocketClient conversation subscriptions subscribe after auth, route pushes, and survive reconnects", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const seen = { syncs: [], events: [] }; + const cleanup = client.subscribeConversationStream("conversation-1", { + onSync: (result) => seen.syncs.push(result), + onEvent: (event) => seen.events.push(event), + }); + + // The transport may legitimately re-issue chat.subscribe (connect + eager + // ensureConnected both call handleConnected); answer every request with the + // caller's cursor plus any replay events staged below. + const answeredSubscribes = new Set(); + let replayEvents = []; + const subscribeCalls = []; + const answerSubscribes = (socket) => { + for (const envelope of socket.sent) { + if (envelope.type !== "chat.subscribe" || answeredSubscribes.has(envelope.id)) { + continue; + } + answeredSubscribes.add(envelope.id); + subscribeCalls.push(envelope.payload); + const events = replayEvents; + replayEvents = []; + const latestSeq = events.length + ? events[events.length - 1].seq + : Math.max(envelope.payload.after_seq ?? 0, 2); + socket.receive({ + id: envelope.id, + type: "response", + payload: { conversation_id: "conversation-1", - accepted_seq: 1, - }), - { - status: 202, - headers: { "Content-Type": "application/json" }, - }, - ); - } - if (url.pathname === "/api/chat/events") { - const body = new ReadableStream({ - start(controller) { - controller.enqueue( - encoder.encode( - 'id: 1\nevent: chat.control\ndata: {"seq":1,"payload":{"type":"accepted","conversation_id":"conversation-1","seq":1}}\n\n', - ), - ); - controller.enqueue( - encoder.encode( - 'id: 2\nevent: chat.event\ndata: {"seq":2,"payload":{"type":"token","text":"hi","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.enqueue( - encoder.encode( - 'id: 3\nevent: chat.event\ndata: {"seq":3,"payload":{"type":"done","conversation_id":"conversation-1"}}\n\n', - ), - ); - controller.close(); + stream_epoch: "epoch-1", + latest_seq: latestSeq, + reset: false, + activity: null, + snapshot: null, + events, }, }); - return new Response(body, { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }); } - return new Response(JSON.stringify({ error: `unexpected ${url.pathname}` }), { status: 404 }); }; + const settle = async (socket) => { + for (let i = 0; i < 20; i += 1) { + answerSubscribes(socket); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + }; + + // Auth completes → the persistent subscription issues chat.subscribe. + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.some((item) => item.type === "chat.subscribe"), "chat.subscribe"); + assert.equal(subscribeCalls.length, 0); + await settle(socket); + assert.ok(seen.syncs.length >= 1, "subscribe sync delivered"); + assert.equal(subscribeCalls[0].conversation_id, "conversation-1"); + assert.equal(subscribeCalls[0].after_seq, 0); + + // chat.event pushes route by conversation id (no subscription id). + socket.receive({ + type: "chat.event", + payload: { type: "run_started", conversation_id: "conversation-1", run_id: "run-1", seq: 3 }, + }); + socket.receive({ + type: "chat.event", + payload: { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 4, text: "hi" }, + }); + socket.receive({ + type: "chat.event", + payload: { type: "token", conversation_id: "conversation-other", run_id: "run-x", seq: 9, text: "ignored" }, + }); + await settle(socket); + assert.deepEqual( + seen.events.map((event) => event.type), + ["run_started", "token"], + ); + + // Disconnect keeps the registration; the reconnect re-subscribes with the + // resume cursor and stream epoch. + const syncsBeforeReconnect = seen.syncs.length; + replayEvents = [ + { type: "token", conversation_id: "conversation-1", run_id: "run-1", seq: 5, text: "re" }, + { + type: "run_finished", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 6, + status: "completed", + }, + ]; + const subscribesBeforeReconnect = subscribeCalls.length; + socket.close(); + await new Promise((resolve, reject) => { + const startedAt = Date.now(); + const tick = () => { + if (FakeWebSocket.instances.length >= 2) { + resolve(); + return; + } + if (Date.now() - startedAt > 3_000) { + reject(new Error("timed out waiting for reconnect socket")); + return; + } + setTimeout(tick, 10); + }; + tick(); + }); + const reconnectSocket = await connectAndAuth(1); + await settle(reconnectSocket); + assert.ok(seen.syncs.length > syncsBeforeReconnect, "resume sync delivered"); + const resumePayload = subscribeCalls[subscribesBeforeReconnect]; + assert.equal(resumePayload.after_seq, 4, "resume cursor from last delivered seq"); + assert.equal(resumePayload.stream_epoch, "epoch-1"); + const resumeSync = seen.syncs[seen.syncs.length - 1]; + assert.deepEqual( + resumeSync.events.map((event) => event.type), + ["token", "run_finished"], + "replayed events delivered with the resume sync", + ); + + // chat.subscription_reset triggers another resync from the cursor. + const subscribesBeforeReset = subscribeCalls.length; + reconnectSocket.receive({ + type: "chat.subscription_reset", + payload: { conversation_id: "conversation-1" }, + }); + await settle(reconnectSocket); + assert.ok(subscribeCalls.length > subscribesBeforeReset, "reset re-subscribed"); + assert.equal(subscribeCalls[subscribesBeforeReset].after_seq, 6); + + // Cleanup unsubscribes on the wire. + cleanup(); + await waitFor( + () => reconnectSocket.sent.some((item) => item.type === "chat.unsubscribe"), + "chat.unsubscribe", + ); + resetGatewayWebSocketClient(); +}); +test("GatewayWebSocketClient fans chat.activity and chat.command_update out to listeners", async () => { + installBrowser(); const loader = createWebModuleLoader(); - const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule( + "src/lib/gatewaySocket.ts", + ); resetGatewayWebSocketClient(); - try { - const client = getGatewayWebSocketClient(" token "); - const events = []; - for await (const event of client.commandChat({ - type: "chat.submit", - message: "hello", - conversationId: "", - selectedModel: { - customProviderId: "claude-provider", - model: "claude-test", - providerType: "claude_code", - }, - systemSettings: { - executionMode: "agent-dev", - workdir: "/workspace", - selectedSystemTools: ["http_get_test"], - }, - uploadedFiles: [ - { - relativePath: "uploads/notes.txt", - absolutePath: "/workspace/uploads/notes.txt", - fileName: "notes.txt", - kind: "text", - sizeBytes: 12, - }, - { - relativePath: "uploads/screenshot.webp", - absolutePath: "/workspace/uploads/screenshot.webp", - fileName: "screenshot.webp", - kind: "image", - sizeBytes: 34, - }, - { - relativePath: "uploads/report.pdf", - absolutePath: "/workspace/uploads/report.pdf", - fileName: "report.pdf", - kind: "pdf", - sizeBytes: 56, - }, - ], - clientRequestId: "client-submit-1", - runtimeControls: { - thinkingEnabled: false, - nativeWebSearchEnabled: true, - reasoning: "xhigh", - }, - })) { - events.push(event); - } + const client = getGatewayWebSocketClient("token"); + const activityEvents = []; + const commandUpdates = []; + client.subscribeChatActivity((event) => activityEvents.push(event)); + client.subscribeChatCommandUpdates((update) => commandUpdates.push(update)); - assert.equal(fetchCalls.length, 2); - const commandCall = fetchCalls[0]; - assert.equal(commandCall.url.toString(), "https://gateway.example/api/chat/commands"); - assert.equal(commandCall.init.method, "POST"); - assert.equal(commandCall.init.headers.Authorization, "Bearer token"); - assert.equal(commandCall.init.headers["X-LiveAgent-CSRF"], "1"); - assert.deepEqual(JSON.parse(commandCall.init.body), { - type: "chat.submit", - payload: { - message: "hello", - conversation_id: "", - client_request_id: "client-submit-1", - execution_mode: "agent-dev", - workdir: "/workspace", - selected_system_tools: ["http_get_test"], - uploaded_files: [ - { - relative_path: "uploads/notes.txt", - absolute_path: "/workspace/uploads/notes.txt", - file_name: "notes.txt", - kind: "text", - size_bytes: 12, - }, - { - relative_path: "uploads/screenshot.webp", - absolute_path: "/workspace/uploads/screenshot.webp", - file_name: "screenshot.webp", - kind: "image", - size_bytes: 34, - }, - { - relative_path: "uploads/report.pdf", - absolute_path: "/workspace/uploads/report.pdf", - file_name: "report.pdf", - kind: "pdf", - size_bytes: 56, - }, - ], - selected_model: { - custom_provider_id: "claude-provider", - model: "claude-test", - provider_type: "claude_code", - }, - runtime_controls: { - thinking_enabled: false, - native_web_search_enabled: true, - reasoning: "xhigh", - }, - queue_policy: "auto", - }, - }); + const statusPromise = client.getStatus(); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.length >= 2, "status envelope"); + socket.receive({ id: socket.sent[1].id, type: "response", payload: { online: true } }); + await statusPromise; - const eventsCall = fetchCalls[1]; - assert.equal(eventsCall.url.pathname, "/api/chat/events"); - assert.equal(eventsCall.url.searchParams.get("run_id"), "run-1"); - assert.equal(eventsCall.url.searchParams.get("conversation_id"), "conversation-1"); - assert.equal(eventsCall.url.searchParams.get("after_seq"), "0"); - assert.equal(eventsCall.init.method, "GET"); - assert.equal(eventsCall.init.headers.Accept, "text/event-stream"); - assert.equal(eventsCall.init.headers.Authorization, "Bearer token"); - assert.deepEqual(events, [ - { type: "accepted", conversation_id: "conversation-1", seq: 1 }, - { type: "token", text: "hi", conversation_id: "conversation-1", seq: 2 }, - { type: "done", conversation_id: "conversation-1", seq: 3 }, - ]); - assert.equal(FakeWebSocket.instances.length, 0); - } finally { - globalThis.fetch = realFetch; - resetGatewayWebSocketClient(); - } + socket.receive({ + type: "chat.activity", + payload: { + conversation_id: "conversation-1", + run_id: "run-1", + running: true, + state: "running", + workdir: "/workspace/project", + updated_at: 1234, + }, + }); + socket.receive({ + type: "chat.activity", + payload: { conversation_id: "", running: true }, + }); + assert.equal(activityEvents.length, 1, "malformed activity payloads are dropped"); + assert.deepEqual(activityEvents[0], { + conversationId: "conversation-1", + runId: "run-1", + running: true, + state: "running", + workdir: "/workspace/project", + updatedAt: 1234, + }); + + socket.receive({ + type: "chat.command_update", + payload: { + run_id: "run-1", + client_request_id: "req-1", + conversation_id: "conversation-9", + phase: "bound", + }, + }); + socket.receive({ + type: "chat.command_update", + payload: { run_id: "run-1", phase: "unknown-phase" }, + }); + assert.equal(commandUpdates.length, 1, "unknown phases are dropped"); + assert.deepEqual(commandUpdates[0], { + runId: "run-1", + clientRequestId: "req-1", + conversationId: "conversation-9", + phase: "bound", + errorCode: null, + message: null, + }); + resetGatewayWebSocketClient(); }); diff --git a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs index 79aa350f..842c1ecc 100644 --- a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs +++ b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs @@ -5,8 +5,7 @@ import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; const loader = createWebModuleLoader(); const historySync = loader.loadModule("src/lib/historySync.ts"); const chatUi = loader.loadModule("src/lib/chatUi.ts"); -const liveStore = loader.loadModule("src/lib/liveConversationStreamStore.ts"); -const liveCommit = loader.loadModule("src/lib/liveConversationCommit.ts"); +const transcriptStoreModule = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); const historyShare = loader.loadModule("src/lib/historyShare.ts"); const requestContextSanitizer = loader.loadModule("src/lib/chat/requestContextSanitizer.ts"); const conversationState = loader.loadModule("src/lib/chat/conversationState.ts"); @@ -95,12 +94,6 @@ test("applyGatewayHistoryEvent upserts newest summaries and removes deleted conv conversation_id: "two", }); assert.deepEqual(deleted.map((item) => item.id), ["one"]); - - const running = historySync.applyGatewayHistoryEvent(deleted, { - kind: "running", - conversation_id: "one", - }); - assert.equal(running, deleted); }); test("applyGatewayHistoryEvent sorts pinned conversations before recent conversations", () => { @@ -361,53 +354,6 @@ test("reconcileConversationSummaries retains running local rows missing from a l assert.equal(refreshed[0], existing[0]); }); -test("normalizeRunningConversations requires run ids and preserves replay cursors", () => { - assert.deepEqual( - historySync.normalizeRunningConversations([ - { - conversation_id: " conversation-1 ", - run_id: " run-1 ", - cwd: " /workspace ", - first_seq: 42.9, - run_epoch: 3.2, - updated_at: 123, - }, - { - conversation_id: "conversation-1", - run_id: "run-duplicate", - first_seq: 7, - }, - { - conversation_id: "conversation-2", - first_seq: 0, - }, - ]), - [ - { - conversation_id: "conversation-1", - run_id: "run-1", - cwd: "/workspace", - first_seq: 42, - run_epoch: 3, - updated_at: 123, - }, - ], - ); -}); - -test("resolveRunningConversationStreamAfterSeq starts remote replay at current run boundary", () => { - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(42), 41); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(42.9), 41); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(1), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(0), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq(undefined), 0); - assert.equal(historySync.resolveRunningConversationStreamAfterSeq("42"), 0); - assert.equal( - historySync.resolveRunningConversationStreamAfterSeq(42, { runId: "chat-command-1" }), - 0, - ); -}); - test("applyGatewayHistoryEvent can protect optimistic titles from summary broadcasts", () => { const existing = [ { @@ -1093,217 +1039,6 @@ test("pushChatEvent keeps a deduped result while applying late result arguments" assert.equal(entries[0].toolCall.arguments.command, "printf duplicate"); }); -test("createLiveConversationStreamStore batches entries and clears tool status on terminal events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - let notifications = 0; - const unsubscribe = store.subscribe(() => { - notifications += 1; - }); - - store.setToolStatus("Compacting...", true); - assert.equal(store.getSnapshot().toolStatus, "Compacting..."); - assert.equal(store.getSnapshot().toolStatusIsCompaction, true); - assert.equal(notifications, 1); - - store.appendEvent({ type: "token", text: "hello", round: 1 }); - assert.equal(store.getSnapshot().entries[0].text, "hello"); - assert.equal(store.getSnapshot().toolStatus, "Compacting..."); - - store.appendEvent({ type: "done", conversation_id: "conversation-1" }); - assert.equal(store.getSnapshot().toolStatus, null); - assert.equal(store.getSnapshot().toolStatusIsCompaction, false); - - unsubscribe(); -}); - -test("createLiveConversationStreamStore commits background events without waiting for animation frames", () => { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - let rafScheduled = 0; - globalThis.window = { - requestAnimationFrame() { - rafScheduled += 1; - return 1; - }, - cancelAnimationFrame() {}, - setTimeout(callback) { - callback(); - return 1; - }, - clearTimeout() {}, - }; - globalThis.document = { visibilityState: "hidden" }; - - try { - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ type: "token", text: "background", round: 1 }); - - assert.equal(rafScheduled, 0); - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "background"); - } finally { - globalThis.window = previousWindow; - globalThis.document = previousDocument; - } -}); - -test("createLiveConversationStreamStore falls back when a scheduled animation frame is paused", () => { - const previousWindow = globalThis.window; - const previousDocument = globalThis.document; - let fallbackCallback = null; - let canceledFrame = null; - globalThis.window = { - requestAnimationFrame() { - return 7; - }, - cancelAnimationFrame(id) { - canceledFrame = id; - }, - setTimeout(callback) { - fallbackCallback = callback; - return 11; - }, - clearTimeout() {}, - }; - globalThis.document = { visibilityState: "visible" }; - - try { - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ type: "token", text: "queued", round: 1 }); - - assert.equal(store.getSnapshot().entries.length, 0); - assert.equal(typeof fallbackCallback, "function"); - - fallbackCallback(); - - assert.equal(canceledFrame, 7); - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "queued"); - } finally { - globalThis.window = previousWindow; - globalThis.document = previousDocument; - } -}); - -test("createLiveConversationStreamStore ignores replayed events with the same seq", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "token", - text: "hello", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: "hello", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: " world", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "hello world"); - - store.reset(); - store.appendEvent({ - type: "token", - text: "fresh", - round: 1, - conversation_id: "conversation-1", - seq: 1, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "fresh"); -}); - -test("createLiveConversationStreamStore does not render queue control events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "accepted", - state: "queued", - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "queued_in_gui", - state: "desktop_queued", - conversation_id: "conversation-1", - seq: 2, - }); - store.appendEvent({ - type: "started", - state: "running", - conversation_id: "conversation-1", - seq: 3, - }); - store.appendEvent({ - type: "token", - text: "visible", - round: 1, - conversation_id: "conversation-1", - seq: 4, - }); - - assert.equal(store.getSnapshot().entries.length, 1); - assert.equal(store.getSnapshot().entries[0].text, "visible"); -}); - -test("createLiveConversationStreamStore renders live user_message events", () => { - globalThis.window = undefined; - globalThis.document = { visibilityState: "visible" }; - - const store = liveStore.createLiveConversationStreamStore(); - store.appendEvent({ - type: "user_message", - message: "queued from gui", - uploaded_files: [ - { - relative_path: "notes.md", - absolute_path: "/workspace/notes.md", - file_name: "notes.md", - kind: "text", - size_bytes: 12, - }, - ], - conversation_id: "conversation-1", - seq: 1, - }); - store.appendEvent({ - type: "token", - text: "reply", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }); - - const entries = store.getSnapshot().entries; - assert.equal(entries.length, 2); - assert.equal(entries[0].kind, "user"); - assert.equal(entries[0].text, "queued from gui"); - assert.equal(entries[0].attachments.length, 1); - assert.equal(entries[0].attachments[0].relativePath, "notes.md"); - assert.equal(entries[1].kind, "assistant"); - assert.equal(entries[1].text, "reply"); -}); - function findTreeNode(node, predicate) { if (Array.isArray(node)) { for (const child of node) { @@ -1331,7 +1066,56 @@ function findTreeNode(node, predicate) { return null; } -test("GatewayTranscript live renderer shows user bubbles before live assistant output", () => { +test("formatConversationTitle falls back to stable labels", () => { + assert.equal(chatUi.formatConversationTitle({ id: "abc", title: " Named " }), "Named"); + assert.equal(chatUi.formatConversationTitle(null, "conversation-abcdef"), "会话 conversa"); + assert.equal(chatUi.formatConversationTitle(null, ""), "新对话"); +}); + +test("resolveConversationBrowserTitle uses project title for project-level empty selection", () => { + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: null, + conversationId: "conversation-abcdef", + projectName: " Project Alpha ", + newConversationTitle: "LiveAgent", + }), + "Project Alpha", + ); + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: { id: "conversation-abcdef", title: " Named " }, + conversationId: "conversation-abcdef", + projectName: "Project Alpha", + newConversationTitle: "LiveAgent", + }), + "Named", + ); + assert.equal( + chatUi.resolveConversationBrowserTitle({ + conversation: null, + conversationId: "__local_draft__:abc", + projectName: "Project Alpha", + isLocalDraftConversation: true, + newConversationTitle: "LiveAgent", + }), + "LiveAgent", + ); +}); + +test("buildOptimisticConversationTitle uses the first ten characters of the first prompt paragraph", () => { + assert.equal( + chatUi.buildOptimisticConversationTitle(" 12345 67890 abc\nstill first paragraph\n\nsecond"), + "12345 6789", + ); + assert.equal( + chatUi.buildOptimisticConversationTitle("这是第一段提示词超过十个字\n\n第二段"), + "这是第一段提示词超过", + ); + assert.equal(chatUi.buildOptimisticConversationTitle(" \n\n "), "新对话"); +}); + +test("GatewayTranscript renders committed and tail regions from disjoint inputs", () => { const fakeReact = { createContext(defaultValue) { return { defaultValue }; @@ -1399,46 +1183,38 @@ test("GatewayTranscript live renderer shows user bubbles before live assistant o }, }, }); - const transcriptLiveStore = transcriptLoader.loadModule("src/lib/liveConversationStreamStore.ts"); const { GatewayTranscript } = transcriptLoader.loadModule("src/components/GatewayTranscript.tsx"); globalThis.window = undefined; globalThis.document = { visibilityState: "visible" }; - const store = transcriptLiveStore.createLiveConversationStreamStore(); - store.appendEvent( - { - type: "user_message", - message: "queued from gui", - conversation_id: "conversation-1", - seq: 1, - }, - { flush: true }, - ); - store.appendEvent( - { - type: "token", - text: "reply", - round: 1, - conversation_id: "conversation-1", - seq: 2, - }, - { flush: true }, - ); + // Tail entries carry a run-id prefix (live-born ids from the transcript + // store); committed entries came from parsed history. + const committed = [ + { id: "user-1", kind: "user", text: "earlier question", attachments: [] }, + { id: "assistant-1", kind: "assistant", text: "earlier answer", round: 1 }, + ]; + const tail = [ + { id: "run-1/live-user-1", kind: "user", text: "queued from gui", attachments: [] }, + { id: "run-1/live-assistant-1", kind: "assistant", text: "reply", round: 1 }, + ]; const transcriptTree = GatewayTranscript({ conversationId: "conversation-1", - entries: [], - liveStore: store, - hasLiveStream: true, + entries: committed, + tailEntries: tail, isStreaming: true, }); const liveStateNode = findTreeNode( transcriptTree, - (node) => typeof node.type === "function" && node.props?.liveSnapshot, + (node) => typeof node.type === "function" && Array.isArray(node.props?.tailEntries), + ); + assert.ok(liveStateNode, "live region receives the tail entries"); + assert.deepEqual( + liveStateNode.props.tailEntries.map((entry) => entry.id), + ["run-1/live-user-1", "run-1/live-assistant-1"], ); - assert.ok(liveStateNode); const liveTree = liveStateNode.type(liveStateNode.props); assert.ok( findTreeNode( @@ -1447,6 +1223,7 @@ test("GatewayTranscript live renderer shows user bubbles before live assistant o typeof node.props?.className === "string" && node.props.className.includes("gateway-transcript-row-user"), ), + "tail user bubble renders before the live assistant output", ); assert.ok( findTreeNode( @@ -1454,396 +1231,41 @@ test("GatewayTranscript live renderer shows user bubbles before live assistant o (node) => typeof node.type === "function" && node.props?.text === "queued from gui", ), ); -}); - -test("mergeHistorySnapshotEntries appends remote user turn without dropping loaded history", () => { - const existing = [ - { id: "existing-user-1", kind: "user", text: "first", attachments: [] }, - { id: "existing-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - ]; - const incoming = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "history-user-2", kind: "user", text: "second prompt", attachments: [] }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["first", "first answer", "second prompt"], - ); - assert.equal(merged[0].id, "history-user-1"); - assert.equal(merged[2].id, "history-user-2"); -}); - -test("mergeHistorySnapshotEntries keeps full-history prefix when incoming snapshot is a suffix", () => { - const existing = [ - { id: "old-user", kind: "user", text: "old", attachments: [] }, - { id: "old-assistant", kind: "assistant", text: "old answer", round: 1 }, - { id: "recent-user", kind: "user", text: "recent", attachments: [] }, - { id: "recent-assistant", kind: "assistant", text: "recent answer", round: 1 }, - ]; - const incoming = [ - { id: "snapshot-user", kind: "user", text: "recent", attachments: [] }, - { id: "snapshot-assistant", kind: "assistant", text: "recent answer", round: 1 }, - { id: "snapshot-new-user", kind: "user", text: "new prompt", attachments: [] }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["old", "old answer", "recent", "recent answer", "new prompt"], + const assistantBubble = findTreeNode( + liveTree, + (node) => typeof node.type === "function" && node.props?.renderMode !== undefined, ); - assert.equal(merged[0].id, "old-user"); - assert.equal(merged[2].id, "snapshot-user"); -}); - -test("mergeHistorySnapshotEntries replaces assistant-only live snapshot with persisted user turn", () => { - const existing = [ - { id: "committed-live-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - const incoming = [ - { id: "history-user", kind: "user", text: "remote prompt", attachments: [] }, - { id: "history-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["remote prompt", "live answer"], - ); - assert.equal(merged[0].id, "history-user"); - assert.equal(merged[1].id, "history-assistant"); -}); - -test("mergeHistorySnapshotEntries keeps live transcript stable when only assistant metadata moves", () => { - const meta = { - provider: "anthropic", - model: "claude-opus", - usage: { input: 1, output: 2, totalTokens: 3 }, - }; - const existing = [ - { id: "live-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "live-assistant-prefix", kind: "assistant", text: "\n", round: 1 }, - { id: "live-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "live-assistant-answer", kind: "assistant", text: "你好!", round: 1, meta }, - ]; - const incoming = [ - { id: "history-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "history-assistant-prefix", kind: "assistant", text: "\n", round: 1, meta }, - { id: "history-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "history-assistant-answer", kind: "assistant", text: "你好!", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - - assert.equal(merged, existing); - assert.deepEqual( - merged.map((entry) => entry.id), - ["live-user-1", "live-assistant-prefix", "live-thinking", "live-assistant-answer"], - ); -}); - -test("omitEquivalentTailEntries hides committed live tail without dropping history", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "committed-live-assistant", kind: "assistant", text: "live answer", round: 1 }, - ]; - const liveEntries = [ - { id: "live-assistant-1", kind: "assistant", text: "live answer", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["first", "first answer"], - ); - assert.equal(visibleHistory[1].id, "history-assistant-1"); -}); - -test("omitEquivalentTailEntries treats assistant metadata placement as the same visible tail", () => { - const meta = { - provider: "anthropic", - model: "claude-opus", - usage: { input: 1, output: 2, totalTokens: 3 }, - }; - const existing = [ - { id: "history-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "history-assistant-prefix", kind: "assistant", text: "\n", round: 1, meta }, - { id: "history-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "history-assistant-answer", kind: "assistant", text: "你好!", round: 1 }, - ]; - const liveEntries = [ - { id: "live-assistant-prefix", kind: "assistant", text: "\n", round: 1 }, - { id: "live-thinking", kind: "thinking", text: "thinking", round: 1 }, - { id: "live-assistant-answer", kind: "assistant", text: "你好!", round: 1, meta }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["你好"], - ); -}); - -test("omitEquivalentTailEntries removes live user and assistant overlap", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "history-assistant-1", kind: "assistant", text: "first answer", round: 1 }, - { id: "history-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "history-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - const liveEntries = [ - { id: "live-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "live-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual( - visibleHistory.map((entry) => entry.text), - ["first", "first answer"], - ); -}); - -test("omitEquivalentTailEntries removes uploaded live user overlap", () => { - const historyAttachment = { - relativePath: ".liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - }; - const liveAttachment = { - relativePath: ".liveagent/uploads/08-conclusion.png", - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - }; - const existing = [ - { id: "history-user-1", kind: "user", text: "这是什么", attachments: [historyAttachment] }, - { id: "history-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - const liveEntries = [ - { id: "live-user-1", kind: "user", text: "这是什么", attachments: [liveAttachment] }, - { id: "live-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - - const visibleHistory = liveCommit.omitEquivalentTailEntries(existing, liveEntries); - - assert.deepEqual(visibleHistory, []); -}); - -test("appendCommittedLiveEntries does not duplicate optimistic user message overlaps", () => { - const existing = [ - { id: "history-user-1", kind: "user", text: "first", attachments: [] }, - { id: "optimistic-user-2", kind: "user", text: "queued from gui", attachments: [] }, - ]; - const liveEntries = [ - { id: "live-user-2", kind: "user", text: "queued from gui", attachments: [] }, - { id: "live-assistant-2", kind: "assistant", text: "reply", round: 1 }, - ]; - - const merged = liveCommit.appendCommittedLiveEntries(existing, liveEntries); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["first", "queued from gui", "reply"], - ); - assert.equal(merged[1].id, "optimistic-user-2"); - assert.equal(merged[2].kind, "assistant"); -}); - -test("appendCommittedLiveEntries does not duplicate uploaded optimistic user overlaps", () => { - const existing = [ - { - id: "optimistic-user-1", - kind: "user", - text: "这是什么", - attachments: [ - { - relativePath: ".liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - }, - ], - }, - ]; - const liveEntries = [ - { - id: "live-user-1", - kind: "user", - text: "这是什么", - attachments: [ - { - relativePath: ".liveagent/uploads/08-conclusion.png", - absolutePath: "/workspace/.liveagent/uploads/08-conclusion.png", - fileName: "08-conclusion.png", - kind: "image", - sizeBytes: 58368, - }, - ], - }, - { id: "live-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; - - const merged = liveCommit.appendCommittedLiveEntries(existing, liveEntries); - - assert.deepEqual( - merged.map((entry) => entry.text), - ["这是什么", "reply"], - ); - assert.equal(merged[0].id, "optimistic-user-1"); -}); - -test("mergeHistorySnapshotEntries replaces stale local entries when an authoritative snapshot is shorter", () => { - // Simulates: peer A edits the user prompt and resends, server-side history - // is now just the new user turn. Without `isFullSnapshot`, the local two-turn - // tail is kept and collides with the incoming live stream, producing the - // duplicated assistant bubble. With the flag set we yield to the server. - const existing = [ - { id: "local-user-1", kind: "user", text: "old prompt", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "old answer", round: 1 }, - ]; - const incoming = [ - { id: "server-user-1", kind: "user", text: "edited prompt", attachments: [] }, - ]; - - const withoutFlag = liveCommit.mergeHistorySnapshotEntries(existing, incoming); + assert.ok(assistantBubble, "tail assistant bubble rendered"); assert.equal( - withoutFlag, - existing, - "without the hint we conservatively keep existing — the bug being fixed", - ); - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming, { - isFullSnapshot: true, - }); - assert.deepEqual( - merged.map((entry) => entry.text), - ["edited prompt"], - ); - assert.notStrictEqual(merged, existing); -}); - -test("mergeHistorySnapshotEntries with isFullSnapshot=true clears entries when the server is empty", () => { - const existing = [ - { id: "local-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - - assert.deepEqual( - liveCommit.mergeHistorySnapshotEntries(existing, [], { isFullSnapshot: true }), - [], + assistantBubble.props.renderMode, + "streaming", + "live-born entries keep the streaming render mode", ); - // Without the hint, empty incoming is treated as "no update available" and - // existing is preserved (used during paginated tail fetches). - assert.equal(liveCommit.mergeHistorySnapshotEntries(existing, []), existing); }); -test("mergeHistorySnapshotEntries keeps existing user ids when only the server-side messageRef appears", () => { - // Locally created user entries do not have `messageRef` yet — the gateway - // assigns it when persisting. The post-stream history refresh would - // otherwise see "different" entries and replace them, changing every user - // article's React key and re-firing the `chat-bubble-enter` animation on - // every user bubble at once. - const existing = [ - { id: "local-user-1", kind: "user", text: "你好", attachments: [] }, - { id: "live-assistant-1", kind: "assistant", text: "回复", round: 1 }, - ]; - const incoming = [ - { - id: "server-user-1", - kind: "user", - text: "你好", - attachments: [], - messageRef: { segmentIndex: 0, messageIndex: 0 }, - }, - { id: "server-assistant-1", kind: "assistant", text: "回复", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming); - +test("transcript store history snapshot merge stays quiet for identical content", () => { + globalThis.window = undefined; + globalThis.document = { visibilityState: "visible" }; + const store = transcriptStoreModule.createTranscriptStore(); + + store.applyHistorySnapshot([ + { id: "user-1", kind: "user", text: "hello", attachments: [] }, + { id: "assistant-1", kind: "assistant", text: "world", round: 1 }, + ]); + store.flush(); + const first = store.getSnapshot(); + assert.equal(first.committed.length, 2); + + // The quiet upsert merge preserves rendered ids even when the incoming + // parse assigned fresh ones (dedup key identity, not id identity). + store.applyHistorySnapshot([ + { id: "user-renamed", kind: "user", text: "hello", attachments: [] }, + { id: "assistant-renamed", kind: "assistant", text: "world", round: 1 }, + ]); + store.flush(); + const second = store.getSnapshot(); assert.deepEqual( - merged.map((entry) => entry.id), - ["local-user-1", "live-assistant-1"], - "existing ids must be preserved so React keys stay stable", - ); - assert.deepEqual(merged[0].messageRef, { segmentIndex: 0, messageIndex: 0 }); -}); - -test("mergeHistorySnapshotEntries with isFullSnapshot=true still preserves identity for matching snapshots", () => { - const existing = [ - { id: "local-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "local-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - const incoming = [ - { id: "server-user-1", kind: "user", text: "hi", attachments: [] }, - { id: "server-assistant-1", kind: "assistant", text: "hello", round: 1 }, - ]; - - const merged = liveCommit.mergeHistorySnapshotEntries(existing, incoming, { - isFullSnapshot: true, - }); - // Visually equivalent → keep existing references so the React tree does not - // remount and the article keys stay stable. - assert.equal(merged, existing); -}); - -test("formatConversationTitle falls back to stable labels", () => { - assert.equal(chatUi.formatConversationTitle({ id: "abc", title: " Named " }), "Named"); - assert.equal(chatUi.formatConversationTitle(null, "conversation-abcdef"), "会话 conversa"); - assert.equal(chatUi.formatConversationTitle(null, ""), "新对话"); -}); - -test("resolveConversationBrowserTitle uses project title for project-level empty selection", () => { - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: null, - conversationId: "conversation-abcdef", - projectName: " Project Alpha ", - newConversationTitle: "LiveAgent", - }), - "Project Alpha", - ); - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: { id: "conversation-abcdef", title: " Named " }, - conversationId: "conversation-abcdef", - projectName: "Project Alpha", - newConversationTitle: "LiveAgent", - }), - "Named", + second.committed.map((entry) => entry.id), + ["user-1", "assistant-1"], ); - assert.equal( - chatUi.resolveConversationBrowserTitle({ - conversation: null, - conversationId: "__local_draft__:abc", - projectName: "Project Alpha", - isLocalDraftConversation: true, - newConversationTitle: "LiveAgent", - }), - "LiveAgent", - ); -}); - -test("buildOptimisticConversationTitle uses the first ten characters of the first prompt paragraph", () => { - assert.equal( - chatUi.buildOptimisticConversationTitle(" 12345 67890 abc\nstill first paragraph\n\nsecond"), - "12345 6789", - ); - assert.equal( - chatUi.buildOptimisticConversationTitle("这是第一段提示词超过十个字\n\n第二段"), - "这是第一段提示词超过", - ); - assert.equal(chatUi.buildOptimisticConversationTitle(" \n\n "), "新对话"); }); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 4161c6b7..92e57e46 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -5,6 +5,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, type DragEvent, } from "react"; import { flushSync } from "react-dom"; @@ -38,7 +39,7 @@ import type { SectionId } from "@/pages/settings/types"; import { useChatSkills } from "@/pages/chat/useChatSkills"; import { queuedChatTurnHasContent } from "@/pages/chat/queue/chatTurnQueue"; import { mergeAlwaysEnabledSkillNames } from "@/lib/skills"; -import { buildModelOptions, sortHistoryItems, VIBING_STATUS } from "@/lib/chat/chatPageHelpers"; +import { buildModelOptions, sortHistoryItems } from "@/lib/chat/chatPageHelpers"; import { SettingsPage } from "@/pages/SettingsPage"; import { findProviderModelConfig, @@ -75,14 +76,11 @@ import type { ChatQueueItemSummary, ChatQueueSnapshot, ChatEvent, - ChatRuntimeSnapshotEvent, ConversationSummary, GatewayHistoryEvent, HistoryDetail, HistoryShareStatus, HistoryWorkdirSummary, - LiveConversationStreamSnapshot, - LiveConversationStreamStore, } from "@/lib/gatewayTypes"; import { filterConversationSummariesForScope, @@ -90,232 +88,30 @@ import { } from "@/lib/chat/historyListScope"; import { buildOptimisticConversationTitle, - chatEntryDedupKey, - pushChatEvent, resolveConversationBrowserTitle, type ChatEntry, } from "@/lib/chatUi"; import { parseHistoryMessagesJsonAsync } from "@/lib/historyParser"; +import { createActivityStore } from "@/lib/chat/stream/activityStore"; +import { + ChatCommandPipeline, + type ChatCommandOutcome, + type PendingChatCommand, +} from "@/lib/chat/stream/chatCommandPipeline"; +import { + readEventRunId, + type ChatCommandUpdate, + type ConversationActivityEvent, + type ConversationStreamEvent, + type ConversationSubscribeResult, +} from "@/lib/chat/stream/streamTypes"; +import { + createTranscriptStoreRegistry, + useConversationChat, +} from "@/lib/chat/stream/useConversationChat"; +import type { GatewayChatCommandInput } from "@/lib/gatewaySocket"; -const CHAT_STREAM_NOT_AVAILABLE_RE = /\bchat stream not available\b/i; -const RECOVERABLE_TRANSPORT_MESSAGE_RE = - /\b(?:502\s+bad\s+gateway|503\s+service\s+unavailable|504\s+gateway\s+timeout|bad\s+gateway|gateway\s+timeout|service\s+unavailable|temporarily\s+unavailable|failed\s+to\s+fetch|networkerror|network\s+error|load\s+failed|connection\s+(?:reset|closed|lost)|socket\s+hang\s+up|err_(?:network|internet_disconnected|connection_(?:reset|closed|refused)))\b/i; - -function isChatStreamNotAvailableMessage(value: unknown) { - const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); - return CHAT_STREAM_NOT_AVAILABLE_RE.test(message.trim()); -} -function isRecoverableChatStreamTransportMessage(value: unknown) { - const message = value instanceof Error ? value.message : typeof value === "string" ? value : String(value ?? ""); - return RECOVERABLE_TRANSPORT_MESSAGE_RE.test(message.trim()); -} -function isChatStreamNotAvailableEvent(event: ChatEvent) { - return event.type === "error" && isChatStreamNotAvailableMessage(event.message); -} -function collectAssistantLikeText(entries: ChatEntry[]) { - return entries.map((e) => (e.kind === "assistant" || e.kind === "thinking" || e.kind === "error" ? e.text : "")).join("\n").trim(); -} -function shouldHydrateRestoredConversationSnapshot(params: { - currentEntries: ChatEntry[]; - historyEntries: ChatEntry[]; - liveEntries?: ChatEntry[]; -}) { - if (params.historyEntries.length === 0) return false; - const liveEntries = params.liveEntries ?? []; - if (params.currentEntries.length === 0 && liveEntries.length === 0) return true; - const currentText = collectAssistantLikeText([...params.currentEntries, ...liveEntries]); - const historyText = collectAssistantLikeText(params.historyEntries); - if (historyText.length === 0) return liveEntries.length === 0 && params.historyEntries.length > params.currentEntries.length; - if (currentText.length === 0) return true; - return historyText.length > currentText.length; -} import { memoryDeleteProject } from "@/lib/memory/api"; -function chatEntryContentKey(entry: ChatEntry) { - return chatEntryDedupKey(entry); -} - -function hasEquivalentTailEntries(existing: ChatEntry[], tail: ChatEntry[]) { - if (tail.length === 0 || existing.length < tail.length) return false; - const offset = existing.length - tail.length; - for (let i = 0; i < tail.length; i++) { - if (chatEntryContentKey(existing[offset + i]) !== chatEntryContentKey(tail[i])) return false; - } - return true; -} - -function findEntryOverlap(existing: ChatEntry[], incoming: ChatEntry[]) { - const max = Math.min(existing.length, incoming.length); - for (let overlap = max; overlap > 0; overlap--) { - let matches = true; - const start = existing.length - overlap; - for (let i = 0; i < overlap; i++) { - if (chatEntryContentKey(existing[start + i]) !== chatEntryContentKey(incoming[i])) { - matches = false; - break; - } - } - if (matches) return overlap; - } - return 0; -} - -function mergeHistorySnapshotEntries( - existing: ChatEntry[], - incoming: ChatEntry[], - options?: { isFullSnapshot?: boolean }, -) { - const isFullSnapshot = options?.isFullSnapshot === true; - if (incoming.length === 0) return isFullSnapshot ? [] : existing; - if (hasEquivalentTailEntries(existing, incoming)) return existing; - if (existing.length === 0) return incoming.map((e) => structuredClone(e)); - const overlap = findEntryOverlap(existing, incoming); - if (overlap > 0) { - return [...existing.slice(0, existing.length - overlap), ...incoming.map((e) => structuredClone(e))]; - } - if (incoming.length >= existing.length || isFullSnapshot) { - return incoming.map((e) => structuredClone(e)); - } - return existing; -} - -function appendCommittedLiveEntries(existing: ChatEntry[], live: ChatEntry[]) { - if (live.length === 0 || hasEquivalentTailEntries(existing, live)) return existing; - const overlap = findEntryOverlap(existing, live); - const base = overlap > 0 ? existing.slice(0, existing.length - overlap) : existing; - const toCommit = overlap > 0 ? live.slice(overlap) : live; - if (toCommit.length === 0) return base; - return [...base, ...toCommit.map((e, i) => ({ ...structuredClone(e), id: `committed-live-${e.kind}-${base.length + i}` }))]; -} -const EMPTY_LIVE_SNAPSHOT: LiveConversationStreamSnapshot = { - revision: 0, - entries: [], - toolStatus: null, - toolStatusIsCompaction: false, -}; - -function isSnapshotChatEntry(value: unknown): value is ChatEntry { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const v = value as Record; - if (typeof v.id !== "string" || typeof v.kind !== "string") return false; - switch (v.kind) { - case "user": return typeof v.text === "string" && Array.isArray(v.attachments); - case "assistant": case "thinking": case "error": return typeof v.text === "string"; - case "tool_call": return v.toolCall != null && typeof v.toolCall === "object"; - case "tool_result": return v.toolResult != null && typeof v.toolResult === "object"; - case "hosted_search": return v.hostedSearch != null && typeof v.hostedSearch === "object"; - default: return false; - } -} - -function parseSnapshotEntries(json: string | undefined): ChatEntry[] { - const raw = typeof json === "string" ? json.trim() : ""; - if (!raw) return []; - try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; } - catch { return []; } -} - -function isTerminalStreamEvent(event: ChatEvent) { - if (event.type === "done" || event.type === "error") return true; - if (event.type !== "completed" && event.type !== "failed" && event.type !== "cancelled") return false; - return event.state === "completed" || event.state === "failed" || event.state === "cancelled"; -} - -function isControlStreamEvent(event: ChatEvent) { - switch (event.type) { - case "accepted": case "user_message": case "rebased": case "projection_updated": - case "delivered": case "claimed": case "starting": case "queued_in_gui": - case "started": case "progress": case "completed": case "failed": case "cancelled": - return true; - default: return false; - } -} - -function createLiveConversationStreamStore(): LiveConversationStreamStore { - let snapshot = EMPTY_LIVE_SNAPSHOT; - let draft = EMPTY_LIVE_SNAPSHOT; - let rafId: number | null = null; - let latestSnapshotRev = 0; - let latestSnapshotSeq = 0; - const seenSeqs = new Set(); - const listeners = new Set<() => void>(); - - const emit = () => { listeners.forEach((l) => l()); }; - const cancel = () => { if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; } }; - const commit = () => { rafId = null; if (snapshot !== draft) { snapshot = draft; emit(); } }; - const schedule = (flush?: boolean) => { - if (flush) { cancel(); snapshot = draft; emit(); return; } - if (rafId === null) rafId = requestAnimationFrame(commit); - }; - const update = (next: LiveConversationStreamSnapshot, flush?: boolean) => { - draft = { ...next, revision: draft.revision + 1 }; - schedule(flush); - }; - - const readSeq = (event: { seq?: number }) => { - const s = event.seq; - return typeof s === "number" && Number.isFinite(s) && s > 0 ? Math.floor(s) : null; - }; - - return { - getSnapshot: () => snapshot, - subscribe: (l) => { listeners.add(l); return () => { listeners.delete(l); }; }, - appendEvent: (event, opts) => { - const seq = readSeq(event); - if (seq !== null) { - if (latestSnapshotSeq > 0 && seq <= latestSnapshotSeq) return; - if (seenSeqs.has(seq)) return; - seenSeqs.add(seq); - } - if (event.type === "tool_status") { - const status = typeof event.status === "string" && event.status.trim() ? event.status.trim() : null; - const isCompaction = Boolean(status) && event.isCompaction === true; - if (draft.toolStatus === status && draft.toolStatusIsCompaction === isCompaction) return; - update({ ...draft, toolStatus: status, toolStatusIsCompaction: isCompaction }, opts?.flush); - return; - } - const terminal = isTerminalStreamEvent(event); - const shouldAppend = event.type === "user_message" || event.type === "error" || event.type === "failed" || (!terminal && !isControlStreamEvent(event)); - const nextEntries = shouldAppend ? pushChatEvent(draft.entries, event) : draft.entries; - const clearStatus = terminal; - if (nextEntries === draft.entries && (!clearStatus || (draft.toolStatus === null && !draft.toolStatusIsCompaction))) return; - update({ - ...draft, - entries: nextEntries, - toolStatus: clearStatus ? null : draft.toolStatus, - toolStatusIsCompaction: clearStatus ? false : draft.toolStatusIsCompaction, - }, opts?.flush); - }, - applySnapshot: (event, opts) => { - const seq = readSeq(event); - const rev = typeof event.revision === "number" && event.revision > 0 ? Math.floor(event.revision) : 0; - if (rev > 0 && latestSnapshotRev > 0 && rev < latestSnapshotRev) return; - if (rev > 0 && rev === latestSnapshotRev && seq !== null && latestSnapshotSeq > 0 && seq <= latestSnapshotSeq) return; - const entries = parseSnapshotEntries(event.entries_json); - const toolStatus = typeof event.tool_status === "string" && event.tool_status.trim() ? event.tool_status.trim() : null; - const toolStatusIsCompaction = Boolean(toolStatus) && event.tool_status_is_compaction === true; - latestSnapshotRev = Math.max(latestSnapshotRev, rev); - if (seq !== null) { latestSnapshotSeq = Math.max(latestSnapshotSeq, seq); seenSeqs.add(seq); } - update({ ...draft, entries, toolStatus, toolStatusIsCompaction }, opts?.flush); - }, - setToolStatus: (toolStatus, isCompaction = false, opts) => { - const status = typeof toolStatus === "string" && toolStatus.trim() ? toolStatus.trim() : null; - const nextIsCompaction = Boolean(status) && isCompaction; - if (draft.toolStatus === status && draft.toolStatusIsCompaction === nextIsCompaction) return; - update({ ...draft, toolStatus: status, toolStatusIsCompaction: nextIsCompaction }, opts?.flush); - }, - reset: () => { - if (draft.entries.length === 0 && draft.toolStatus === null && !draft.toolStatusIsCompaction) { cancel(); return; } - draft = { ...EMPTY_LIVE_SNAPSHOT, revision: draft.revision + 1 }; - seenSeqs.clear(); - latestSnapshotRev = 0; - latestSnapshotSeq = 0; - cancel(); - snapshot = draft; - emit(); - }, - flush: () => { cancel(); commit(); }, - }; -} const LOCAL_DRAFT_PREFIX = "__local_draft__:"; function createLocalDraftConversationId() { @@ -326,9 +122,7 @@ function isLocalDraftConversationId(id: string) { } import { applyGatewayHistoryEvent, - normalizeRunningConversations, reconcileConversationSummaries, - resolveRunningConversationStreamAfterSeq, upsertConversationSummary, } from "@/lib/historySync"; import { parseHistoryShareToken } from "@/lib/historyShare"; @@ -352,19 +146,14 @@ import { CHAT_RUNTIME_KEEP_WARM_INTERVAL_MS, CHAT_RUNTIME_PREPARE_TIMEOUT_MS, CHAT_RUNTIME_PREPARING_STATUS, - CHAT_RUNTIME_STARTING_STATUS, - CHAT_RUNTIME_STARTING_STATUS_DELAY_MS, DEFAULT_BROWSER_TITLE, - DRAFT_HISTORY_ADOPTION_WINDOW_MS, HISTORY_DETAIL_INITIAL_MAX_MESSAGES, HISTORY_LIST_PAGE_SIZE, HISTORY_SWITCH_OVERLAY_MIN_MS, HISTORY_TITLE_POSITION_LOCK_MS, - LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS, MAX_UPLOAD_FILES, MCP_HUB_BROWSER_TITLE, NEW_CONVERSATION_BROWSER_TITLE, - PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS, PROJECT_HISTORY_DELETE_PAGE_SIZE, PROTECTED_DRAFT_CONVERSATION, SHARED_HISTORY_BROWSER_TITLE, @@ -376,15 +165,7 @@ import { buildGatewaySystemSettings, asErrorMessage, isAbortError, - isChatControlEvent, isChatEventTitleFinal, - isTerminalChatEvent, - isChatRuntimeReadyStatus, - isPreparingChatControlEvent, - isRuntimeStartedChatControlEvent, - isTerminalChatControlEvent, - normalizeGatewayTimestampMs, - normalizeOptionalStatus, readChatEventTitle, readTunnelManagerToolChange, waitForMinimumHistoryListLoading, @@ -398,11 +179,9 @@ import { HistorySwitchLoadingOverlay } from "./HistorySwitchLoadingOverlay"; import { UserMenu } from "./UserMenu"; import { WorkspaceOverlayHost } from "./WorkspaceOverlayHost"; import { - createConversationRuntimeEntry, createWorkspaceProjectFromPath, formatTranslation, getDefaultWorkspaceProjectPath, - hasDetachedHistorySelection, hasLocalDraftConversation, isMobileSidebarLayout, pickConversationSummary, @@ -412,12 +191,9 @@ import { toChatHistorySummary, } from "./historyUtils"; import type { - ConversationRuntimeEntry, ModelProviderSource, OverlayState, - PendingDraftConversationMigration, ReloadHistoryOptions, - RunningConversationRuntime, SendChatFn, SendChatOptions, } from "./types"; @@ -427,15 +203,38 @@ import { useGatewaySettingsSync } from "./hooks/useGatewaySettingsSync"; import { usePendingUploads } from "./hooks/usePendingUploads"; import { useProjectToolsRuntime } from "./hooks/useProjectToolsRuntime"; -function resolveRunningConversationRunKey( - runtime: Pick | null | undefined, -) { - return runtime?.runId?.trim() ?? ""; -} - -function readGatewayChatEventRunId(event: ChatEvent) { - const runId = (event as ChatEvent & { __gatewayRunId?: unknown }).__gatewayRunId; - return typeof runId === "string" ? runId.trim() : ""; +// history.list `running_conversations` items → activity store hydration shape. +function normalizeActivityHydrationItems(items: readonly unknown[] | undefined) { + const normalized: Array<{ + conversationId: string; + runId: string; + state?: string; + workdir?: string | null; + updatedAt?: number; + }> = []; + for (const value of items ?? []) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const source = value as Record; + const conversationId = + typeof source.conversation_id === "string" ? source.conversation_id.trim() : ""; + const runId = typeof source.run_id === "string" ? source.run_id.trim() : ""; + if (!conversationId || !runId) { + continue; + } + normalized.push({ + conversationId, + runId, + state: typeof source.state === "string" ? source.state : undefined, + workdir: typeof source.cwd === "string" ? source.cwd : null, + updatedAt: + typeof source.updated_at === "number" && Number.isFinite(source.updated_at) + ? source.updated_at + : undefined, + }); + } + return normalized; } export default function GatewayApp() { @@ -453,12 +252,8 @@ export default function GatewayApp() { const { api, terminalClient, sftpClient, gitClient } = useGatewayClients(token); const [status, setStatus] = useState(null); const [statusError, setStatusError] = useState(null); - const [chatMessages, setChatMessages] = useState([]); const [conversationId, setConversationId] = useState(""); - const [chatBusy, setChatBusy] = useState(false); const [chatError, setChatError] = useState(null); - const [chatToolStatus, setChatToolStatus] = useState(null); - const [chatToolStatusIsCompaction, setChatToolStatusIsCompaction] = useState(false); const [historyListLoading, setHistoryListLoading] = useState(false); const [historyListLoadingMore, setHistoryListLoadingMore] = useState(false); const [historyDetailLoading, setHistoryDetailLoading] = useState(false); @@ -468,23 +263,18 @@ export default function GatewayApp() { const [historyTotal, setHistoryTotal] = useState(0); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyError, setHistoryError] = useState(null); - const [localRunningConversationIds, setLocalRunningConversationIds] = useState< - ReadonlySet - >(() => new Set()); const [queuedChatTurns, setQueuedChatTurns] = useState([]); const [, setChatQueueRevision] = useState(0); - const [remoteRunningConversationIds, setRemoteRunningConversationIds] = useState< - ReadonlySet - >(() => new Set()); - const [remoteRunningConversationRuntime, setRemoteRunningConversationRuntime] = useState< - ReadonlyMap - >(() => new Map()); const [projectActivityUpdatedAtOverrides, setProjectActivityUpdatedAtOverrides] = useState< ReadonlyMap >(() => new Map()); const [selectedHistoryId, setSelectedHistoryId] = useState(""); const [selectedHistory, setSelectedHistory] = useState(null); - const [selectedHistoryEntries, setSelectedHistoryEntries] = useState([]); + // Bumped whenever the command pipeline's pending set changes so busy state + // re-derives. + const [pendingCommandRevision, setPendingCommandRevision] = useState(0); + // Bumped inside flushSync to synchronously commit a settled-tail fold. + const [, setFoldFlushTick] = useState(0); const [historySwitchOverlay, setHistorySwitchOverlay] = useState<{ conversationId: string; startedAt: number; @@ -577,17 +367,11 @@ export default function GatewayApp() { const conversationIdRef = useRef(conversationId); const selectedHistoryIdRef = useRef(selectedHistoryId); const statusRef = useRef(status); - const chatBusyRef = useRef(chatBusy); - const chatMessagesRef = useRef(chatMessages); - const chatErrorRef = useRef(chatError); - const chatToolStatusRef = useRef(chatToolStatus); - const chatToolStatusIsCompactionRef = useRef(chatToolStatusIsCompaction); const queuedChatTurnsRef = useRef([]); const chatQueueConversationIdRef = useRef(""); const chatQueueRevisionRef = useRef(0); const queuedChatEditSessionRef = useRef<{ itemId: string; revision: number } | null>(null); const selectedHistoryRef = useRef(selectedHistory); - const selectedHistoryEntriesRef = useRef(selectedHistoryEntries); const historyItemsRef = useRef(historyItems); const historyTotalRef = useRef(historyTotal); const historyHasMoreRef = useRef(historyHasMore); @@ -597,49 +381,58 @@ export default function GatewayApp() { const historyListPageLoadingRef = useRef(false); const sharedHistoryItemsRef = useRef([]); const sharedHistoryListRequestRef = useRef | null>(null); - const localRunningConversationIdsRef = useRef>(new Set()); - const remoteRunningConversationIdsRef = useRef>(new Set()); - const remoteRunningConversationRuntimeRef = useRef< - ReadonlyMap - >(new Map()); - const liveConversationStreamStoresRef = useRef>( - new Map(), - ); - const conversationRuntimeCacheRef = useRef>(new Map()); + // Per-conversation runtime workdir (drafts have no persisted summary yet). + const conversationWorkdirsRef = useRef>(new Map()); const displayedConversationWorkdirRef = useRef(""); - const conversationAbortControllersRef = useRef>(new Map()); - const conversationEventStreamsRef = useRef>(new Map()); - const completedLiveStreamMarkersRef = useRef>(new Map()); - const pendingHistoryRefreshAfterLiveCompletionRef = useRef>(new Set()); + const displayedConversationBusyRef = useRef(false); const optimisticTitleConversationIdsRef = useRef>(new Set()); const titlePositionLockedConversationIdsRef = useRef>(new Set()); const titlePositionLockTimeoutsRef = useRef>(new Map()); - const blockedHistoryHydrationConversationIdsRef = useRef>(new Set()); - const activeEditResendTransactionsRef = useRef< - Map - >(new Map()); - const visibleHistorySnapshotRefreshSeqRef = useRef>(new Map()); - const restoredPageHistoryRefreshAtRef = useRef>(new Map()); const historyLoadSequenceRef = useRef(0); const visibleConversationRevisionRef = useRef(0); const previousDisplayedConversationIdRef = useRef(""); const pendingDisplayedConversationAutoBottomRef = useRef(null); const draftConversationPinnedRef = useRef(false); const protectedConversationRef = useRef(""); - const chatStartLocksRef = useRef>(new Set()); - const chatPreflightInFlightRef = useRef(false); - const chatStartInFlightRef = useRef(false); const chatRuntimePreparePromiseRef = useRef | null>(null); const submitInFlightRef = useRef(false); - const pendingDraftConversationMigrationRef = useRef( - null, - ); + // clientRequestId → draft conversation id, until the command binds. + const draftClientRequestsRef = useRef>(new Map()); const sendChatRef = useRef(null); const isImportingPastedTextRef = useRef(false); const resetProjectToolsRuntimeRef = useRef(() => undefined as void); const persistProjectConversationActivityRef = useRef( (_activity: ReadonlyMap) => undefined as void, ); + + // --- Chat streaming infrastructure (Phase 4) ----------------------------- + // Transcript stores (one per conversation), the global activity map, and + // the command pipeline replace the old live-store registry, running-id + // unions, and recovery machinery. + const transcriptStoreRegistry = useMemo(() => createTranscriptStoreRegistry(), []); + const activityStore = useMemo(() => createActivityStore(), []); + const pipelineOnBoundRef = useRef<(update: ChatCommandUpdate, pending: PendingChatCommand) => void>( + () => undefined, + ); + const pipelineOnQueuedInGuiRef = useRef< + (update: ChatCommandUpdate, pending: PendingChatCommand) => void + >(() => undefined); + const pipelineOnFailedRef = useRef< + (pending: PendingChatCommand, errorCode: string | null, message: string) => void + >(() => undefined); + const chatCommandPipeline = useMemo( + () => + new ChatCommandPipeline({ + getTranscriptStore: (targetConversationId) => + transcriptStoreRegistry.get(targetConversationId), + onBound: (update, pending) => pipelineOnBoundRef.current(update, pending), + onQueuedInGui: (update, pending) => pipelineOnQueuedInGuiRef.current(update, pending), + onFailed: (pending, errorCode, message) => + pipelineOnFailedRef.current(pending, errorCode, message), + onPendingChanged: () => setPendingCommandRevision((current) => current + 1), + }), + [transcriptStoreRegistry], + ); const { pendingUploadedFiles, pendingUploadedFilesRef, @@ -834,18 +627,10 @@ export default function GatewayApp() { titlePositionLockTimeoutsRef.current.set(conversationId, timeoutId); }, []); - const isHydrationBlocked = (conversationId: string) => - blockedHistoryHydrationConversationIdsRef.current.has(conversationId) || - getConversationAbortController(conversationId) !== null; - - const getPendingDraftId = () => - pendingDraftConversationMigrationRef.current?.draftConversationId.trim() ?? ""; - const getHistoryPositionLockedConversationIds = useCallback(() => { const conversationIds = new Set([ ...optimisticTitleConversationIdsRef.current, ...titlePositionLockedConversationIdsRef.current, - ...blockedHistoryHydrationConversationIdsRef.current, ]); return conversationIds; }, []); @@ -872,34 +657,10 @@ export default function GatewayApp() { statusRef.current = status; }, [status]); - useEffect(() => { - chatBusyRef.current = chatBusy; - }, [chatBusy]); - - useEffect(() => { - chatMessagesRef.current = chatMessages; - }, [chatMessages]); - - useEffect(() => { - chatErrorRef.current = chatError; - }, [chatError]); - - useEffect(() => { - chatToolStatusRef.current = chatToolStatus; - }, [chatToolStatus]); - - useEffect(() => { - chatToolStatusIsCompactionRef.current = chatToolStatusIsCompaction; - }, [chatToolStatusIsCompaction]); - useEffect(() => { selectedHistoryRef.current = selectedHistory; }, [selectedHistory]); - useEffect(() => { - selectedHistoryEntriesRef.current = selectedHistoryEntries; - }, [selectedHistoryEntries]); - useEffect(() => { historyItemsRef.current = historyItems; }, [historyItems]); @@ -936,10 +697,6 @@ export default function GatewayApp() { return conversationIdValue !== "" && getDisplayedConversationId() === conversationIdValue; } - useEffect(() => { - localRunningConversationIdsRef.current = localRunningConversationIds; - }, [localRunningConversationIds]); - const applyLiveConversationTitle = useCallback( ( targetConversationId: string, @@ -977,113 +734,35 @@ export default function GatewayApp() { [lockHistoryTitlePosition, updateHistoryItems], ); - const applyChatToolStatus = useCallback( - (nextStatus: string | null | undefined, isCompaction = false) => { - const status = typeof nextStatus === "string" ? nextStatus.trim() : ""; - setChatToolStatus(status || null); - setChatToolStatusIsCompaction(Boolean(status) && isCompaction); - }, - [], - ); - - const getConversationLiveStreamStore = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return null; - } - const existing = liveConversationStreamStoresRef.current.get(conversationIdValue); - if (existing) { - return existing; - } - const created = createLiveConversationStreamStore(); - liveConversationStreamStoresRef.current.set(conversationIdValue, created); - return created; - }, []); - - const buildVisibleRuntimeEntry = useCallback( - () => - createConversationRuntimeEntry({ - messages: chatMessagesRef.current, - error: chatErrorRef.current, - toolStatus: chatToolStatusRef.current, - toolStatusIsCompaction: chatToolStatusIsCompactionRef.current, - isSending: chatBusyRef.current, - workdir: conversationRuntimeCacheRef.current.get(conversationIdRef.current.trim())?.workdir, - }), - [], - ); - - const syncVisibleConversationRuntime = useCallback( - (targetConversationId: string, entry: ConversationRuntimeEntry) => { - conversationIdRef.current = targetConversationId; - selectedHistoryIdRef.current = targetConversationId; - chatMessagesRef.current = entry.messages; - chatErrorRef.current = entry.error; - chatToolStatusRef.current = entry.toolStatus; - chatToolStatusIsCompactionRef.current = entry.toolStatusIsCompaction; - chatBusyRef.current = entry.isSending; - setConversationId(targetConversationId); - setSelectedHistoryId(targetConversationId); - setChatMessages(entry.messages); - setChatError(entry.error); - applyChatToolStatus(entry.toolStatus, entry.toolStatusIsCompaction); - setChatBusy(entry.isSending); - }, - [applyChatToolStatus], - ); - - const cacheVisibleConversationRuntime = useCallback( - (targetConversationId?: string) => { - const conversationIdValue = (targetConversationId ?? conversationIdRef.current).trim(); - if (!conversationIdValue) { - return; + // Committed + tail entry count of a conversation's transcript store. + const getConversationTranscriptEntryCount = useCallback( + (targetConversationId: string) => { + const store = transcriptStoreRegistry.peek(targetConversationId.trim()); + if (!store) { + return 0; } - conversationRuntimeCacheRef.current.set(conversationIdValue, buildVisibleRuntimeEntry()); + const snapshot = store.getSnapshot(); + return snapshot.committed.length + snapshot.tail.length; }, - [buildVisibleRuntimeEntry], + [transcriptStoreRegistry], ); - const updateConversationRuntimeEntry = useCallback( - ( - targetConversationId: string, - updater: (previous: ConversationRuntimeEntry) => ConversationRuntimeEntry, - ) => { + const isConversationBusy = useCallback( + (targetConversationId: string) => { const conversationIdValue = targetConversationId.trim(); if (!conversationIdValue) { - return createConversationRuntimeEntry(); - } - - const previous = - conversationIdRef.current === conversationIdValue && - (selectedHistoryIdRef.current === "" || - selectedHistoryIdRef.current === conversationIdValue) - ? buildVisibleRuntimeEntry() - : (conversationRuntimeCacheRef.current.get(conversationIdValue) ?? - createConversationRuntimeEntry()); - const next = createConversationRuntimeEntry(updater(previous)); - conversationRuntimeCacheRef.current.set(conversationIdValue, next); - - if ( - conversationIdRef.current === conversationIdValue && - (selectedHistoryIdRef.current === "" || - selectedHistoryIdRef.current === conversationIdValue) - ) { - chatMessagesRef.current = next.messages; - chatErrorRef.current = next.error; - chatToolStatusRef.current = next.toolStatus; - chatToolStatusIsCompactionRef.current = next.toolStatusIsCompaction; - chatBusyRef.current = next.isSending; - setChatMessages(next.messages); - setChatError(next.error); - applyChatToolStatus(next.toolStatus, next.toolStatusIsCompaction); - setChatBusy(next.isSending); + return false; } - - return next; + return ( + activityStore.isRunning(conversationIdValue) || + chatCommandPipeline.hasPending(conversationIdValue) || + transcriptStoreRegistry.peek(conversationIdValue)?.getSnapshot().activeRun != null + ); }, - [applyChatToolStatus, buildVisibleRuntimeEntry], + [activityStore, chatCommandPipeline, transcriptStoreRegistry], ); + // Keep an empty draft conversation's workdir following the active project. useEffect(() => { const nextWorkdir = activeWorkspaceProjectPath.trim(); if (!isAgentMode || !nextWorkdir) { @@ -1096,301 +775,76 @@ export default function GatewayApp() { if (!conversationIdValue || !isLocalDraftConversationId(conversationIdValue)) { return; } - if ( - chatBusyRef.current || - localRunningConversationIdsRef.current.has(conversationIdValue) || - remoteRunningConversationIdsRef.current.has(conversationIdValue) - ) { - return; - } - if (chatMessagesRef.current.length > 0 || pendingUploadedFilesRef.current.length > 0) { + if (isConversationBusy(conversationIdValue)) { return; } - const currentWorkdir = - conversationRuntimeCacheRef.current.get(conversationIdValue)?.workdir?.trim() || ""; - if (currentWorkdir === nextWorkdir) { + if ( + getConversationTranscriptEntryCount(conversationIdValue) > 0 || + pendingUploadedFilesRef.current.length > 0 + ) { return; } - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - workdir: nextWorkdir, - })); - }, [activeWorkspaceProjectPath, isAgentMode, updateConversationRuntimeEntry]); - - const setConversationRunningState = useCallback( - (targetConversationId: string, isRunning: boolean) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - - const runtimeWorkdir = - conversationRuntimeCacheRef.current.get(conversationIdValue)?.workdir?.trim() || ""; - const persistedWorkdir = - historyItemsRef.current.find((item) => item.id === conversationIdValue)?.cwd?.trim() || ""; - recordProjectActivity(runtimeWorkdir || persistedWorkdir, Date.now()); - - updateConversationRuntimeEntry(conversationIdValue, (previous) => ({ - ...previous, - isSending: isRunning, - })); - setLocalRunningConversationIds((current) => { - const hasConversation = current.has(conversationIdValue); - if ((isRunning && hasConversation) || (!isRunning && !hasConversation)) { - return current; - } - const next = new Set(current); - if (isRunning) { - next.add(conversationIdValue); - } else { - next.delete(conversationIdValue); - } - localRunningConversationIdsRef.current = next; - return next; - }); - }, - [recordProjectActivity, updateConversationRuntimeEntry], - ); + conversationWorkdirsRef.current.set(conversationIdValue, nextWorkdir); + }, [ + activeWorkspaceProjectPath, + getConversationTranscriptEntryCount, + isAgentMode, + isConversationBusy, + pendingUploadedFilesRef, + ]); - const setConversationAbortController = useCallback( - (targetConversationId: string, controller: AbortController | null) => { + // Quiet history refresh for the displayed conversation: fetch → parse → + // id-preserving merge into the transcript store (no flicker, no remount). + // Only runs while the conversation is idle; a run started mid-fetch aborts + // the merge so a stale snapshot can never truncate freshly folded entries. + const refreshDisplayedConversationHistorySnapshot = useCallback( + async (targetConversationId: string, currentApi = api) => { const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - if (controller) { - conversationAbortControllersRef.current.set(conversationIdValue, controller); + if (!currentApi || !conversationIdValue || isLocalDraftConversationId(conversationIdValue)) { return; } - conversationAbortControllersRef.current.delete(conversationIdValue); - }, - [], - ); - - const getConversationAbortController = useCallback((targetConversationId: string) => { - return conversationAbortControllersRef.current.get(targetConversationId.trim()) ?? null; - }, []); - const clearConversationLiveStream = useCallback( - (targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { + const isStillDisplayedAndIdle = () => + resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === + conversationIdValue && !isConversationBusy(conversationIdValue); + if (!isStillDisplayedAndIdle()) { return; } - liveConversationStreamStoresRef.current.get(conversationIdValue)?.reset(); - liveConversationStreamStoresRef.current.delete(conversationIdValue); - pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); - }, - [], - ); - - const clearAllConversationLiveStreams = useCallback(() => { - liveConversationStreamStoresRef.current.forEach((store) => store.reset()); - liveConversationStreamStoresRef.current.clear(); - pendingHistoryRefreshAfterLiveCompletionRef.current.clear(); - }, []); - - const hasRetainedConversationLiveStream = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return false; - } - const store = liveConversationStreamStoresRef.current.get(conversationIdValue); - if (!store) { - return false; - } - return store.getSnapshot().entries.length > 0 || - conversationEventStreamsRef.current.has(conversationIdValue); - }, []); - - const commitConversationLiveStreamToRuntime = useCallback( - ( - targetConversationId: string, - options?: { - clearLiveStream?: boolean; - }, - ) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return false; - } - - const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); - liveStore?.flush(); - const liveEntries = liveStore?.getSnapshot().entries ?? []; - if (liveEntries.length === 0) { - if (options?.clearLiveStream) { - clearConversationLiveStream(conversationIdValue); - } - return false; - } - - updateConversationRuntimeEntry(conversationIdValue, (current) => { - const nextMessages = appendCommittedLiveEntries(current.messages, liveEntries); - if (nextMessages === current.messages) { - return current; - } - return { - ...current, - messages: nextMessages, - }; - }); - const selectedConversationId = selectedHistoryIdRef.current.trim(); - const visibleConversationId = conversationIdRef.current.trim(); - if ( - selectedConversationId === conversationIdValue && - visibleConversationId !== conversationIdValue - ) { - setSelectedHistoryEntries((current) => appendCommittedLiveEntries(current, liveEntries)); - } + // If the full history is already loaded, refresh the full transcript so + // the merge cannot truncate it back to the most recent page. + const hasFullHistoryLoaded = + selectedHistoryRef.current?.conversation_id === conversationIdValue && + selectedHistoryRef.current.has_more === false; - if (options?.clearLiveStream) { - clearConversationLiveStream(conversationIdValue); + let detail: HistoryDetail; + let entries: ChatEntry[]; + try { + detail = await currentApi.getHistory( + conversationIdValue, + hasFullHistoryLoaded ? undefined : { maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES }, + ); + entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + } catch { + return; } - return true; - }, - [clearConversationLiveStream, updateConversationRuntimeEntry], - ); - - const fetchVisibleHistoryGuarded = async ( - targetConversationId: string, - currentApi: typeof api, - options?: { allowDuringEditTransaction?: boolean }, - ): Promise<{ detail: HistoryDetail; entries: ChatEntry[]; conversationId: string } | null> => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) return null; - if ( - options?.allowDuringEditTransaction !== true && - activeEditResendTransactionsRef.current.has(conversationIdValue) - ) { - return null; - } - - const isStillVisible = () => - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - conversationIdValue; - - if (!isStillVisible()) return null; - - const refreshSeq = - (visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) ?? 0) + 1; - visibleHistorySnapshotRefreshSeqRef.current.set(conversationIdValue, refreshSeq); - - let detail: HistoryDetail; - let entries: ChatEntry[]; - try { - detail = await currentApi.getHistory(conversationIdValue, { - maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, - }); - entries = await parseHistoryMessagesJsonAsync(detail.messages_json); - } catch { - return null; - } - - if ( - visibleHistorySnapshotRefreshSeqRef.current.get(conversationIdValue) !== refreshSeq || - !isStillVisible() - ) { - return null; - } - - const detailConversationId = detail.conversation_id.trim(); - if (detailConversationId !== "" && detailConversationId !== conversationIdValue) return null; - - return { detail, entries, conversationId: conversationIdValue }; - }; - - const refreshVisibleConversationHistorySnapshot = useCallback( - async ( - targetConversationId: string, - currentApi = api, - options?: { allowIdle?: boolean; allowDuringEditTransaction?: boolean }, - ) => { - const cid = targetConversationId.trim(); - if ( - getConversationAbortController(cid) !== null || - localRunningConversationIdsRef.current.has(cid) - ) { + if (!isStillDisplayedAndIdle()) { return; } - - const result = await fetchVisibleHistoryGuarded(targetConversationId, currentApi, options); - if (!result) return; - const { detail, entries, conversationId: conversationIdValue } = result; - - if ( - getConversationAbortController(conversationIdValue) !== null || - localRunningConversationIdsRef.current.has(conversationIdValue) || - (options?.allowIdle !== true && - !remoteRunningConversationIdsRef.current.has(conversationIdValue) && - !hasRetainedConversationLiveStream(conversationIdValue)) - ) { + const detailConversationId = detail.conversation_id.trim(); + if (detailConversationId !== "" && detailConversationId !== conversationIdValue) { return; } - const isFullSnapshot = detail.has_more === false; - const mergeOptions = { isFullSnapshot }; - const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); - const liveSnapshotEntries = liveStore?.getSnapshot().entries ?? []; - const marker = completedLiveStreamMarkersRef.current.get(conversationIdValue); - const liveSnapshotIsCompletedRetention = - marker !== undefined && - Date.now() - marker.at <= LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS; - const liveStreamConflictsWithSnapshot = - isFullSnapshot && - liveSnapshotEntries.length > 0 && - liveSnapshotIsCompletedRetention && - !hasEquivalentTailEntries(entries, liveSnapshotEntries); - if (selectedHistoryIdRef.current.trim() === conversationIdValue) { selectedHistoryRef.current = detail; setSelectedHistory(detail); - setSelectedHistoryEntries((current) => - mergeHistorySnapshotEntries(current, entries, mergeOptions), - ); - } - - updateConversationRuntimeEntry(conversationIdValue, (current) => { - const nextMessages = mergeHistorySnapshotEntries(current.messages, entries, mergeOptions); - if (nextMessages === current.messages) { - return current; - } - return { - ...current, - messages: nextMessages, - }; - }); - - if (liveStreamConflictsWithSnapshot) { - clearConversationLiveStream(conversationIdValue); } + transcriptStoreRegistry.get(conversationIdValue).applyHistorySnapshot(entries); }, - [ - api, - clearConversationLiveStream, - getConversationAbortController, - hasRetainedConversationLiveStream, - updateConversationRuntimeEntry, - ], + [api, isConversationBusy, transcriptStoreRegistry], ); - const migrateConversationLiveStream = useCallback( - (previousConversationId: string, nextConversationId: string) => { - const previousId = previousConversationId.trim(); - const nextId = nextConversationId.trim(); - if (!previousId || !nextId || previousId === nextId) { - return; - } - - const previousStore = liveConversationStreamStoresRef.current.get(previousId); - if (previousStore) { - liveConversationStreamStoresRef.current.delete(previousId); - liveConversationStreamStoresRef.current.set(nextId, previousStore); - } - }, - [], - ); const markVisibleConversationRevision = useCallback(() => { visibleConversationRevisionRef.current += 1; @@ -1402,7 +856,9 @@ export default function GatewayApp() { return historyLoadSequenceRef.current; }, []); - const migrateConversation = useCallback( + // A draft conversation got its real id (authoritative `command_update + // bound`): re-key every draft-scoped resource onto the real conversation. + const bindDraftConversation = useCallback( (previousConversationId: string, nextConversationId: string) => { const previousId = previousConversationId.trim(); const nextId = nextConversationId.trim(); @@ -1410,32 +866,27 @@ export default function GatewayApp() { return; } - const previousRuntime = - conversationRuntimeCacheRef.current.get(previousId) ?? - (conversationIdRef.current === previousId ? buildVisibleRuntimeEntry() : null); - if (previousRuntime) { - conversationRuntimeCacheRef.current.set(nextId, previousRuntime); - } - conversationRuntimeCacheRef.current.delete(previousId); + transcriptStoreRegistry.move(previousId, nextId); - const controller = conversationAbortControllersRef.current.get(previousId); - if (controller) { - conversationAbortControllersRef.current.delete(previousId); - conversationAbortControllersRef.current.set(nextId, controller); + const workdir = conversationWorkdirsRef.current.get(previousId); + if (workdir !== undefined) { + conversationWorkdirsRef.current.delete(previousId); + conversationWorkdirsRef.current.set(nextId, workdir); } - setLocalRunningConversationIds((current) => { - if (!current.has(previousId)) { - return current; - } - const next = new Set(current); - next.delete(previousId); - next.add(nextId); - localRunningConversationIdsRef.current = next; - return next; - }); - - migrateConversationLiveStream(previousId, nextId); + const cachedComposerDraft = composerDraftCacheRef.current.get(previousId); + if (cachedComposerDraft) { + composerDraftCacheRef.current.delete(previousId); + composerDraftCacheRef.current.set(nextId, cachedComposerDraft); + } + const pendingUploads = pendingUploadsByConversationRef.current.get(previousId); + if (pendingUploads !== undefined) { + pendingUploadsByConversationRef.current.delete(previousId); + pendingUploadsByConversationRef.current.set(nextId, pendingUploads); + } + if (chatQueueConversationIdRef.current === previousId) { + chatQueueConversationIdRef.current = nextId; + } if (conversationIdRef.current === previousId) { conversationIdRef.current = nextId; @@ -1448,9 +899,6 @@ export default function GatewayApp() { if (protectedConversationRef.current.trim() === previousId) { protectedConversationRef.current = nextId; } - if (blockedHistoryHydrationConversationIdsRef.current.delete(previousId)) { - blockedHistoryHydrationConversationIdsRef.current.add(nextId); - } const shouldPreserveOptimisticTitle = optimisticTitleConversationIdsRef.current.delete(previousId); @@ -1494,70 +942,14 @@ export default function GatewayApp() { }); }, [ - buildVisibleRuntimeEntry, lockHistoryTitlePosition, - migrateConversationLiveStream, + pendingUploadsByConversationRef, + transcriptStoreRegistry, unlockHistoryTitlePosition, updateHistoryItems, ], ); - const getActivePendingDraftMigration = () => { - const pending = pendingDraftConversationMigrationRef.current; - if (!pending) { - return null; - } - const draftId = pending.draftConversationId.trim(); - if ( - !draftId || - !isLocalDraftConversationId(draftId) || - conversationIdRef.current.trim() !== draftId || - selectedHistoryIdRef.current.trim() !== draftId || - protectedConversationRef.current.trim() !== draftId - ) { - return null; - } - return localRunningConversationIdsRef.current.has(draftId) || isHydrationBlocked(draftId) - ? pending - : null; - }; - - const isRecentPendingDraftConversation = ( - conversation: Pick | undefined, - pending: PendingDraftConversationMigration, - ) => { - const conversationTimestamp = - normalizeGatewayTimestampMs(conversation?.created_at) || - normalizeGatewayTimestampMs(conversation?.updated_at); - return ( - conversationTimestamp <= 0 || - conversationTimestamp >= pending.startedAt - DRAFT_HISTORY_ADOPTION_WINDOW_MS - ); - }; - - const maybeAdoptActiveDraftConversation = useCallback( - (conversation: ConversationSummary | undefined) => { - const nextId = conversation?.id?.trim() ?? ""; - const pending = getActivePendingDraftMigration(); - if (!pending || !nextId || isLocalDraftConversationId(nextId)) { - return false; - } - - const draftId = pending.draftConversationId.trim(); - if (!draftId || draftId === nextId) { - return false; - } - - if (!isRecentPendingDraftConversation(conversation, pending)) { - return false; - } - - pendingDraftConversationMigrationRef.current = null; - migrateConversation(draftId, nextId); - return true; - }, - [migrateConversation], - ); const ensureTunnelToolTab = useCallback( (projectPathKey?: string) => { @@ -1964,571 +1356,212 @@ export default function GatewayApp() { }; }, [api]); - useEffect(() => { - remoteRunningConversationIdsRef.current = remoteRunningConversationIds; - }, [remoteRunningConversationIds]); - - useEffect(() => { - remoteRunningConversationRuntimeRef.current = remoteRunningConversationRuntime; - }, [remoteRunningConversationRuntime]); - - const setRemoteConversationRunningState = useCallback( - ( - targetConversationId: string, - isRunning: boolean, - runtime?: { - runId?: string; - workdir?: string; - firstSeq?: number; - runEpoch?: number; - updatedAt?: number; - }, - ) => { + const refreshChatQueueSnapshot = useCallback( + (targetConversationId: string, currentApi = api) => { const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - const current = remoteRunningConversationIdsRef.current; - const hasConversation = current.has(conversationIdValue); - const existingRuntime = remoteRunningConversationRuntimeRef.current.get(conversationIdValue); - const runtimeRunId = runtime?.runId?.trim() || existingRuntime?.runId || ""; - if (isRunning && !runtimeRunId) { - return; - } - const runtimeWorkdir = runtime?.workdir?.trim() || existingRuntime?.workdir || ""; - const runtimeFirstSeq = - typeof runtime?.firstSeq === "number" && - Number.isFinite(runtime.firstSeq) && - runtime.firstSeq > 0 - ? Math.floor(runtime.firstSeq) - : existingRuntime?.firstSeq; - const runtimeRunEpoch = - typeof runtime?.runEpoch === "number" && - Number.isFinite(runtime.runEpoch) && - runtime.runEpoch > 0 - ? Math.floor(runtime.runEpoch) - : existingRuntime?.runEpoch; - const runtimeUpdatedAt = - typeof runtime?.updatedAt === "number" && - Number.isFinite(runtime.updatedAt) && - runtime.updatedAt > 0 - ? runtime.updatedAt - : existingRuntime?.updatedAt || Date.now(); - const nextRunKey = isRunning - ? resolveRunningConversationRunKey({ - runId: runtimeRunId || undefined, - }) - : ""; - const previousRunKey = - resolveRunningConversationRunKey(existingRuntime) || - (conversationEventStreamsRef.current.get(conversationIdValue)?.runId ?? ""); - if (isRunning && nextRunKey && previousRunKey !== nextRunKey) { - const existingStream = conversationEventStreamsRef.current.get(conversationIdValue); - if (existingStream) { - conversationEventStreamsRef.current.delete(conversationIdValue); - existingStream.controller.abort(); - } - const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); - if (existingMarker) { - window.clearTimeout(existingMarker.timeoutId); - completedLiveStreamMarkersRef.current.delete(conversationIdValue); - } - clearConversationLiveStream(conversationIdValue); - } - recordProjectActivity(runtimeWorkdir, runtimeUpdatedAt); - if (!isRunning && !hasConversation && !existingRuntime) { - return; - } - if (!isRunning || !hasConversation) { - const next = new Set(current); - if (isRunning) { - next.add(conversationIdValue); - } else { - next.delete(conversationIdValue); - } - remoteRunningConversationIdsRef.current = next; - setRemoteRunningConversationIds(next); - } - - const currentRuntime = remoteRunningConversationRuntimeRef.current; - const existing = currentRuntime.get(conversationIdValue); - if (!isRunning) { - if (!existing) { - return; - } - const nextRuntime = new Map(currentRuntime); - nextRuntime.delete(conversationIdValue); - remoteRunningConversationRuntimeRef.current = nextRuntime; - setRemoteRunningConversationRuntime(nextRuntime); - return; - } - - const nextRuntimeEntry: RunningConversationRuntime = { - runId: runtimeRunId || undefined, - workdir: runtimeWorkdir || undefined, - firstSeq: runtimeFirstSeq, - runEpoch: runtimeRunEpoch, - updatedAt: Math.max(existing?.updatedAt ?? 0, runtimeUpdatedAt), - }; - if ( - existing?.runId === nextRuntimeEntry.runId && - existing?.workdir === nextRuntimeEntry.workdir && - existing?.firstSeq === nextRuntimeEntry.firstSeq && - existing?.runEpoch === nextRuntimeEntry.runEpoch && - existing?.updatedAt === nextRuntimeEntry.updatedAt - ) { + if (!currentApi || !conversationIdValue) { return; } - const nextRuntime = new Map(currentRuntime); - nextRuntime.set(conversationIdValue, nextRuntimeEntry); - remoteRunningConversationRuntimeRef.current = nextRuntime; - setRemoteRunningConversationRuntime(nextRuntime); + void currentApi + .chatQueueGet(conversationIdValue) + .then((response) => applyChatQueueSnapshot(response.snapshot)) + .catch(() => undefined); }, - [clearConversationLiveStream, recordProjectActivity], + [api, applyChatQueueSnapshot], ); - const stopConversationEventStreamSubscription = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; + // Command pipeline hooks (assigned per render so they see fresh closures). + pipelineOnBoundRef.current = (update, pending) => { + const draftId = draftClientRequestsRef.current.get(pending.clientRequestId)?.trim() ?? ""; + draftClientRequestsRef.current.delete(pending.clientRequestId); + const realId = update.conversationId?.trim() ?? ""; + if (draftId && realId && draftId !== realId) { + bindDraftConversation(draftId, realId); } - const stream = conversationEventStreamsRef.current.get(conversationIdValue); - if (!stream) { - return; + }; + pipelineOnQueuedInGuiRef.current = (update, pending) => { + draftClientRequestsRef.current.delete(pending.clientRequestId); + refreshChatQueueSnapshot(update.conversationId?.trim() || pending.conversationId); + }; + pipelineOnFailedRef.current = (pending, _errorCode, message) => { + draftClientRequestsRef.current.delete(pending.clientRequestId); + const conversationIdValue = pending.conversationId.trim(); + if (isLocalDraftConversationId(conversationIdValue)) { + // The draft never materialized: drop its optimistic sidebar row. The + // transcript keeps the pipeline's error entry. + optimisticTitleConversationIdsRef.current.delete(conversationIdValue); + unlockHistoryTitlePosition(conversationIdValue); + updateHistoryItems((current) => current.filter((item) => item.id !== conversationIdValue)); } - conversationEventStreamsRef.current.delete(conversationIdValue); - stream.controller.abort(); - }, []); - - const stopAllConversationEventStreamSubscriptions = useCallback(() => { - const streams = [...conversationEventStreamsRef.current.values()]; - conversationEventStreamsRef.current.clear(); - for (const { controller } of streams) { - controller.abort(); + if (isDisplayedConversation(conversationIdValue)) { + setChatError(message); } - }, []); + }; - const clearCompletedLiveStreamMarker = useCallback((targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { + // chat.activity is the single global source for running conversations + // (sidebar dots, busy fallbacks) plus project activity bookkeeping. + useEffect(() => { + if (!api) { + activityStore.clear(); return; } - const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); - if (existingMarker) { - window.clearTimeout(existingMarker.timeoutId); - } - completedLiveStreamMarkersRef.current.delete(conversationIdValue); - }, []); + const unsubscribe = api.subscribeChatActivity((event: ConversationActivityEvent) => { + const previous = activityStore.get(event.conversationId); + activityStore.applyActivityEvent(event); + const workdir = + event.workdir?.trim() || + previous?.workdir?.trim() || + historyItemsRef.current.find((item) => item.id === event.conversationId)?.cwd?.trim() || + ""; + recordProjectActivity(workdir, event.updatedAt); + if (!event.running && previous) { + void refreshHistoryWorkdirs(api); + } + }); + return unsubscribe; + }, [activityStore, api, recordProjectActivity, refreshHistoryWorkdirs]); - const clearAllCompletedLiveStreamMarkers = useCallback(() => { - for (const { timeoutId } of completedLiveStreamMarkersRef.current.values()) { - window.clearTimeout(timeoutId); + useEffect(() => { + if (!api) { + return; } - completedLiveStreamMarkersRef.current.clear(); - }, []); + return api.subscribeChatCommandUpdates((update) => { + chatCommandPipeline.handleCommandUpdate(update); + }); + }, [api, chatCommandPipeline]); - const hasRecentlyCompletedLiveStream = useCallback( - (targetConversationId: string) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return false; - } - const marker = completedLiveStreamMarkersRef.current.get(conversationIdValue); - if (!marker) { - return false; - } - if (Date.now() - marker.at > LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS) { - clearCompletedLiveStreamMarker(conversationIdValue); - return false; - } - return true; - }, - [clearCompletedLiveStreamMarker], + const subscribeActivityStore = useCallback( + (listener: () => void) => activityStore.subscribe(listener), + [activityStore], ); - - const clearConversationStreamingState = useCallback( - ( - targetConversationId: string, - options?: { - remoteRunId?: string; - preserveRemoteRun?: boolean; - preserveRemoteRunOnMismatch?: boolean; - }, - ) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - - liveConversationStreamStoresRef.current - .get(conversationIdValue) - ?.setToolStatus(null, false, { flush: true }); - const expectedRemoteRunKey = resolveRunningConversationRunKey({ - runId: options?.remoteRunId, - }); - const currentRemoteRunKey = resolveRunningConversationRunKey( - remoteRunningConversationRuntimeRef.current.get(conversationIdValue), - ); - const shouldClearRemoteRunning = - !options?.preserveRemoteRun && - (!options?.preserveRemoteRunOnMismatch || - !expectedRemoteRunKey || - !currentRemoteRunKey || - expectedRemoteRunKey === currentRemoteRunKey); - if (shouldClearRemoteRunning) { - setRemoteConversationRunningState(conversationIdValue, false); - } - setConversationAbortController(conversationIdValue, null); - setConversationRunningState(conversationIdValue, false); - updateConversationRuntimeEntry(conversationIdValue, (current) => { - if (!current.isSending && current.toolStatus === null && !current.toolStatusIsCompaction) { - return current; - } - return { - ...current, - isSending: false, - toolStatus: null, - toolStatusIsCompaction: false, - }; - }); - }, - [ - setConversationAbortController, - setConversationRunningState, - setRemoteConversationRunningState, - updateConversationRuntimeEntry, - ], + const activitySnapshot = useSyncExternalStore( + subscribeActivityStore, + activityStore.getSnapshot, + activityStore.getSnapshot, ); - const finalizeTerminalLiveStream = useCallback( + // App-level observation of the displayed conversation's stream: titles, + // pipeline settlement, queue refreshes, tunnel side effects, and the one + // scroll-compensated fold commit at run_started. + const observeConversationStreamEvent = useCallback( ( targetConversationId: string, - currentApi = api, - clearStreamingStateOptions?: { - remoteRunId?: string; - preserveRemoteRun?: boolean; - preserveRemoteRunOnMismatch?: boolean; - }, + event: ConversationStreamEvent, + options?: { replay?: boolean }, ) => { - const conversationIdValue = targetConversationId.trim(); - if (!conversationIdValue) { - return; - } - - const existingMarker = completedLiveStreamMarkersRef.current.get(conversationIdValue); - if (existingMarker) { - window.clearTimeout(existingMarker.timeoutId); - } - const timeoutId = window.setTimeout(() => { - completedLiveStreamMarkersRef.current.delete(conversationIdValue); - }, LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS); - completedLiveStreamMarkersRef.current.set(conversationIdValue, { at: Date.now(), timeoutId }); - - const isVisibleConversation = - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - conversationIdValue; - const shouldKeepBottom = isVisibleConversation && isTranscriptAtBottom(); - - if (!isVisibleConversation) { - flushSync(() => { - clearConversationLiveStream(conversationIdValue); - clearConversationStreamingState(conversationIdValue, clearStreamingStateOptions); - }); - } else { - preserveTranscriptScrollPosition( - () => { - flushSync(() => { - commitConversationLiveStreamToRuntime(conversationIdValue, { - clearLiveStream: false, - }); - clearConversationStreamingState(conversationIdValue, clearStreamingStateOptions); - }); - }, - { stickToBottom: shouldKeepBottom }, - ); - - if (shouldKeepBottom) { - stickTranscriptToBottom(); - } else { - refreshTranscriptScrollState(); + const isReplay = options?.replay === true; + switch (event.type) { + case "run_started": { + chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + if (!isReplay && isDisplayedConversation(targetConversationId)) { + // The transcript store folded the settled tail into committed + // when it applied this event. Commit that fold to the DOM in one + // synchronous, scroll-compensated pass — otherwise the + // virtualizer paints a frame with estimated row heights and the + // transcript visibly jumps right as the next run starts. + const shouldKeepBottom = isTranscriptAtBottom(); + preserveTranscriptScrollPosition( + () => { + flushSync(() => { + setFoldFlushTick((current) => current + 1); + }); + }, + { stickToBottom: shouldKeepBottom }, + ); + if (shouldKeepBottom) { + stickTranscriptToBottom(); + } else { + refreshTranscriptScrollState(); + } + } + return; + } + case "run_finished": { + chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + const finishedTitle = + typeof (event as { title?: unknown }).title === "string" + ? ((event as { title: string }).title ?? "").trim() + : ""; + if (finishedTitle) { + applyLiveConversationTitle(targetConversationId, finishedTitle, { isFinal: true }); + } + return; + } + case "run_queued": { + chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + if (!isReplay) { + refreshChatQueueSnapshot(targetConversationId); + } + return; + } + default: { + const chatEvent = event as ChatEvent; + const liveTitle = readChatEventTitle(chatEvent); + if (liveTitle && isChatEventTitleFinal(chatEvent)) { + applyLiveConversationTitle(targetConversationId, liveTitle, { isFinal: true }); + } + if (!isReplay) { + handleTunnelManagerChatEvent(chatEvent); + } } - } - - if (currentApi && pendingHistoryRefreshAfterLiveCompletionRef.current.has(conversationIdValue)) { - pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); - void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi, { - allowIdle: true, - }); } }, [ - api, - clearConversationLiveStream, - clearConversationStreamingState, - commitConversationLiveStreamToRuntime, + applyLiveConversationTitle, + chatCommandPipeline, + handleTunnelManagerChatEvent, isTranscriptAtBottom, preserveTranscriptScrollPosition, + refreshChatQueueSnapshot, refreshTranscriptScrollState, - refreshVisibleConversationHistorySnapshot, stickTranscriptToBottom, ], ); - const applyChatRuntimeSnapshotEvent = useCallback( - ( - event: ChatRuntimeSnapshotEvent, - fallbackConversationId: string, - currentApi = api, - ) => { - const conversationIdValue = - event.conversation_id?.trim() || fallbackConversationId.trim(); - if (!conversationIdValue) { - return false; - } - - const liveStore = getConversationLiveStreamStore(conversationIdValue); - if (!liveStore) { - return false; + const handleConversationStreamSync = useCallback( + (targetConversationId: string, result: ConversationSubscribeResult) => { + if (result.activity) { + chatCommandPipeline.handleRunSignal(targetConversationId, result.activity.runId); } - - liveStore.applySnapshot(event, { flush: true }); - - const state = event.state ?? "running"; - const terminalState = - state === "completed" || state === "failed" || state === "cancelled"; - setRemoteConversationRunningState(conversationIdValue, !terminalState, { - runId: event.run_id, - workdir: event.workdir, - updatedAt: normalizeGatewayTimestampMs(event.updated_at), - }); - - if (terminalState) { - finalizeTerminalLiveStream(conversationIdValue, currentApi); + for (const event of result.events) { + observeConversationStreamEvent(targetConversationId, event, { replay: true }); } - - return terminalState; }, - [ - api, - finalizeTerminalLiveStream, - getConversationLiveStreamStore, - setRemoteConversationRunningState, - ], + [chatCommandPipeline, observeConversationStreamEvent], ); - const recoverUnavailableConversationStream = useCallback( - (targetConversationId: string, currentApi = api) => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) { - return; - } - - clearConversationLiveStream(conversationIdValue); - clearConversationStreamingState(conversationIdValue); - void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi, { - allowIdle: true, - }); + const handleConversationStreamEvent = useCallback( + (targetConversationId: string, event: ConversationStreamEvent) => { + observeConversationStreamEvent(targetConversationId, event); }, - [ - api, - clearConversationLiveStream, - clearConversationStreamingState, - refreshVisibleConversationHistorySnapshot, - ], + [observeConversationStreamEvent], ); - const subscribeVisibleConversationEventStream = useCallback( - (targetConversationId: string, currentApi = api) => { - const conversationIdValue = targetConversationId.trim(); - if (!currentApi || !conversationIdValue) { - return; - } - if (isLocalDraftConversationId(conversationIdValue)) { - return; - } - if (!remoteRunningConversationIdsRef.current.has(conversationIdValue)) { - return; - } - if ( - localRunningConversationIdsRef.current.has(conversationIdValue) || - getConversationAbortController(conversationIdValue) !== null - ) { - return; - } - if ( - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) !== - conversationIdValue - ) { - return; - } - - const runtime = remoteRunningConversationRuntimeRef.current.get(conversationIdValue); - const nextRunKey = resolveRunningConversationRunKey(runtime); - if (!nextRunKey) { - return; - } - const existingStream = - conversationEventStreamsRef.current.get(conversationIdValue); - if (existingStream) { - if (!nextRunKey || existingStream.runId === nextRunKey) { - return; - } - conversationEventStreamsRef.current.delete(conversationIdValue); - existingStream.controller.abort(); - } - - const controller = new AbortController(); - conversationEventStreamsRef.current.set(conversationIdValue, { controller, runId: nextRunKey }); - clearCompletedLiveStreamMarker(conversationIdValue); - const liveStore = getConversationLiveStreamStore(conversationIdValue); - if (!liveStore) { - conversationEventStreamsRef.current.delete(conversationIdValue); - return; - } - void refreshVisibleConversationHistorySnapshot(conversationIdValue, currentApi); - const streamAfterSeq = resolveRunningConversationStreamAfterSeq(runtime?.firstSeq, { - runId: runtime?.runId, - }); - - void (async () => { - let terminalEventSeen = false; - let recoverableTransportErrorSeen = false; - try { - for await (const event of currentApi.streamChatEvents(conversationIdValue, { - runId: runtime?.runId, - afterSeq: streamAfterSeq, - signal: controller.signal, - })) { - if (controller.signal.aborted) { - return; - } - - const eventConversationId = event.conversation_id?.trim() || conversationIdValue; - const liveTitle = readChatEventTitle(event); - if (liveTitle && isChatEventTitleFinal(event)) { - applyLiveConversationTitle(eventConversationId, liveTitle, { - isFinal: true, - }); - } - - if (event.type === "runtime_snapshot") { - const terminalSnapshot = applyChatRuntimeSnapshotEvent( - event, - conversationIdValue, - currentApi, - ); - if (terminalSnapshot) { - terminalEventSeen = true; - return; - } - continue; - } - - if (event.type === "tool_status") { - const normalizedStatus = normalizeOptionalStatus(event.status); - const isCompaction = normalizedStatus !== null && event.isCompaction === true; - liveStore.setToolStatus(normalizedStatus, isCompaction); - continue; - } - - if (isChatStreamNotAvailableEvent(event)) { - terminalEventSeen = true; - recoverUnavailableConversationStream(conversationIdValue, currentApi); - return; - } - - const terminalEvent = isTerminalChatEvent(event); - liveStore.appendEvent(event, { flush: terminalEvent }); - handleTunnelManagerChatEvent(event); - - if (terminalEvent) { - terminalEventSeen = true; - finalizeTerminalLiveStream(conversationIdValue, currentApi, { - remoteRunId: nextRunKey, - preserveRemoteRunOnMismatch: true, - }); - return; - } - - } - } catch (error) { - if (!isAbortError(error)) { - const message = asErrorMessage(error, "chat event stream failed"); - if (isChatStreamNotAvailableMessage(message)) { - terminalEventSeen = true; - recoverUnavailableConversationStream(conversationIdValue, currentApi); - return; - } - if (isRecoverableChatStreamTransportMessage(message)) { - recoverableTransportErrorSeen = true; - liveStore.flush(); - return; - } - liveStore.appendEvent( - { - type: "error", - message, - conversation_id: conversationIdValue, - }, - { flush: true }, - ); - terminalEventSeen = true; - finalizeTerminalLiveStream(conversationIdValue, currentApi, { - remoteRunId: nextRunKey, - preserveRemoteRunOnMismatch: true, - }); - } - } finally { - if ( - conversationEventStreamsRef.current.get(conversationIdValue)?.controller === controller - ) { - conversationEventStreamsRef.current.delete(conversationIdValue); - } - if (!terminalEventSeen && controller.signal.aborted) { - liveStore.flush(); - } - if (recoverableTransportErrorSeen && !controller.signal.aborted) { - liveStore.flush(); - clearConversationStreamingState(conversationIdValue, { preserveRemoteRun: true }); - window.setTimeout(() => { - if ( - remoteRunningConversationIdsRef.current.has(conversationIdValue) && - !conversationEventStreamsRef.current.has(conversationIdValue) - ) { - subscribeVisibleConversationEventStream(conversationIdValue, currentApi); - } - }, 500); - } else if (!terminalEventSeen && !controller.signal.aborted) { - liveStore.flush(); - clearConversationStreamingState(conversationIdValue); - } - } - })(); - }, - [ - api, - applyLiveConversationTitle, - clearCompletedLiveStreamMarker, - clearConversationStreamingState, - finalizeTerminalLiveStream, - getConversationAbortController, - getConversationLiveStreamStore, - applyChatRuntimeSnapshotEvent, - handleTunnelManagerChatEvent, - recoverUnavailableConversationStream, - refreshVisibleConversationHistorySnapshot, - ], + const hasPendingChatCommand = useCallback( + (targetConversationId: string) => chatCommandPipeline.hasPending(targetConversationId), + [chatCommandPipeline], ); + // THE transcript source: the displayed conversation's store snapshot plus a + // persistent stream subscription (subscribed whenever the id is real — + // regardless of running state, which is what makes GUI queue auto-sends + // race-free: the next run's events simply flow in). + const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); + const { transcript: displayedTranscript, busy: displayedConversationBusy } = useConversationChat({ + api, + conversationId: displayedConversationId || null, + registry: transcriptStoreRegistry, + activityStore, + isLocalDraft: isLocalDraftConversationId, + onStreamEvent: handleConversationStreamEvent, + onStreamSync: handleConversationStreamSync, + hasPendingCommand: hasPendingChatCommand, + pendingRevision: pendingCommandRevision, + }); + displayedConversationBusyRef.current = displayedConversationBusy; + useEffect(() => { if (!api) { - remoteRunningConversationIdsRef.current = new Set(); - setRemoteRunningConversationIds(new Set()); - remoteRunningConversationRuntimeRef.current = new Map(); - setRemoteRunningConversationRuntime(new Map()); - stopAllConversationEventStreamSubscriptions(); - clearAllCompletedLiveStreamMarkers(); - clearAllConversationLiveStreams(); return; } @@ -2538,89 +1571,8 @@ export default function GatewayApp() { return; } - if (event.kind === "running" || event.kind === "idle") { - const pending = getActivePendingDraftMigration(); - if (pending) { - const targetId = targetConversationId; - if ( - !isLocalDraftConversationId(targetId) && - targetId !== pending.draftConversationId.trim() - ) { - const existing = pickConversationSummary(historyItemsRef.current, targetId); - if (!existing || isRecentPendingDraftConversation(existing, pending)) { - return; - } - } - } - } - - if (event.kind === "running" || event.kind === "idle") { - if (event.kind === "running" && !event.run_id?.trim()) { - return; - } - const eventRunKey = event.run_id?.trim() - ? resolveRunningConversationRunKey({ runId: event.run_id }) - : ""; - if (event.kind === "idle") { - const activeStream = conversationEventStreamsRef.current.get(targetConversationId); - const activeRunKey = - activeStream?.runId ?? - resolveRunningConversationRunKey( - remoteRunningConversationRuntimeRef.current.get(targetConversationId), - ); - if (activeRunKey && (!eventRunKey || activeRunKey !== eventRunKey)) { - if (!activeStream) { - subscribeVisibleConversationEventStream(targetConversationId, api); - } - void refreshHistoryWorkdirs(api); - return; - } - } - setRemoteConversationRunningState(targetConversationId, event.kind === "running", { - runId: event.run_id, - workdir: event.conversation?.cwd, - firstSeq: event.first_seq, - runEpoch: event.run_epoch, - updatedAt: event.updated_at ?? event.conversation?.updated_at, - }); - if (event.kind === "running") { - subscribeVisibleConversationEventStream(targetConversationId, api); - return; - } - - void refreshHistoryWorkdirs(api); - - if (hasRecentlyCompletedLiveStream(targetConversationId)) { - return; - } - - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ); - const hasLocalDraft = - pendingUploadedFilesRef.current.length > 0 || - (composerRef.current?.hasContent() ?? false); - const hasRetainedLiveTranscript = hasRetainedConversationLiveStream(targetConversationId); - if ( - visibleConversationId === targetConversationId && - !isHydrationBlocked(targetConversationId) && - !chatBusyRef.current && - !hasLocalDraft && - !hasRetainedLiveTranscript - ) { - void refreshVisibleConversationHistorySnapshot(targetConversationId, api, { - allowIdle: true, - }); - } else if (!hasRetainedLiveTranscript) { - clearConversationLiveStream(targetConversationId); - } - return; - } - if (event.kind === "upsert") { recordProjectActivity(event.conversation.cwd, event.conversation.updated_at); - maybeAdoptActiveDraftConversation(event.conversation); } const matchesCurrentHistoryScope = @@ -2635,39 +1587,14 @@ export default function GatewayApp() { preserveUpdatedAtConversationIds: getHistoryPositionLockedConversationIds(), }); }); - if (event.kind === "upsert" || event.kind === "delete" || event.kind === "idle") { - void refreshHistoryWorkdirs(api); - } + void refreshHistoryWorkdirs(api); setHistoryError(null); - setRemoteRunningConversationIds((current) => { - if (event.kind !== "delete" || !current.has(targetConversationId)) { - return current; - } - const next = new Set(current); - next.delete(targetConversationId); - remoteRunningConversationIdsRef.current = next; - return next; - }); - if (event.kind === "delete") { - setRemoteRunningConversationRuntime((current) => { - if (!current.has(targetConversationId)) { - return current; - } - const next = new Map(current); - next.delete(targetConversationId); - remoteRunningConversationRuntimeRef.current = next; - return next; - }); - } - // Keep the current draft/selection stable: remote changes only refresh - // the recent-conversations list unless they target the active row. if (event.kind === "delete") { optimisticTitleConversationIdsRef.current.delete(targetConversationId); unlockHistoryTitlePosition(targetConversationId); - stopConversationEventStreamSubscription(targetConversationId); - clearCompletedLiveStreamMarker(targetConversationId); - clearConversationLiveStream(targetConversationId); + transcriptStoreRegistry.remove(targetConversationId); + conversationWorkdirsRef.current.delete(targetConversationId); if ( conversationIdRef.current === targetConversationId || selectedHistoryIdRef.current === targetConversationId @@ -2679,50 +1606,14 @@ export default function GatewayApp() { return; } - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ); - const isRemoteConversationRunning = - remoteRunningConversationIdsRef.current.has(targetConversationId); - const hydrationBlocked = isHydrationBlocked(targetConversationId); - const hasLocalDraft = - pendingUploadedFilesRef.current.length > 0 || (composerRef.current?.hasContent() ?? false); - if ( - event.kind === "upsert" && - visibleConversationId === targetConversationId && - !isRemoteConversationRunning && - hasRecentlyCompletedLiveStream(targetConversationId) - ) { - pendingHistoryRefreshAfterLiveCompletionRef.current.delete(targetConversationId); - void refreshVisibleConversationHistorySnapshot(targetConversationId, api, { - allowIdle: true, - }); - return; - } + // Upsert of the displayed conversation while idle: quiet, id-preserving + // refresh. Run completion never triggers a refetch — the settled tail + // already shows the final reply. if ( - event.kind === "upsert" && - visibleConversationId === targetConversationId && - isRemoteConversationRunning && - !hydrationBlocked && - !localRunningConversationIdsRef.current.has(targetConversationId) + isDisplayedConversation(targetConversationId) && + !isConversationBusy(targetConversationId) ) { - pendingHistoryRefreshAfterLiveCompletionRef.current.add(targetConversationId); - subscribeVisibleConversationEventStream(targetConversationId, api); - void refreshVisibleConversationHistorySnapshot(targetConversationId, api); - } - if ( - event.kind === "upsert" && - visibleConversationId === targetConversationId && - !isRemoteConversationRunning && - !hydrationBlocked && - !chatBusyRef.current && - !hasLocalDraft && - !hasRetainedConversationLiveStream(targetConversationId) - ) { - void refreshVisibleConversationHistorySnapshot(targetConversationId, api, { - allowIdle: true, - }); + void refreshDisplayedConversationHistorySnapshot(targetConversationId, api); } }); @@ -2732,49 +1623,22 @@ export default function GatewayApp() { }, [ api, activeWorkspaceProjectPath, - subscribeVisibleConversationEventStream, - clearAllCompletedLiveStreamMarkers, - clearAllConversationLiveStreams, - clearCompletedLiveStreamMarker, - clearConversationLiveStream, - getConversationAbortController, getHistoryPositionLockedConversationIds, - handleTunnelManagerChatEvent, - hasRecentlyCompletedLiveStream, - hasRetainedConversationLiveStream, isAgentMode, - maybeAdoptActiveDraftConversation, + isConversationBusy, recordProjectActivity, - refreshVisibleConversationHistorySnapshot, + refreshDisplayedConversationHistorySnapshot, refreshHistoryWorkdirs, - setRemoteConversationRunningState, - stopAllConversationEventStreamSubscriptions, - stopConversationEventStreamSubscription, + transcriptStoreRegistry, unlockHistoryTitlePosition, updateHistoryItems, ]); - useEffect(() => { - if (!status?.online) { - stopAllConversationEventStreamSubscriptions(); - return; - } - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ); - if (visibleConversationId && remoteRunningConversationIdsRef.current.has(visibleConversationId)) { - subscribeVisibleConversationEventStream(visibleConversationId, api); - } - }, [api, status?.online, stopAllConversationEventStreamSubscriptions, subscribeVisibleConversationEventStream]); async function selectHistory( conversationIdValue: string, currentApi = api, options?: { - syncChat?: boolean; - resetLiveStream?: boolean; - clearLiveStream?: boolean; fullHistory?: boolean; scrollToBottom?: boolean; }, @@ -2785,29 +1649,26 @@ export default function GatewayApp() { const loadSequence = invalidateHistoryLoad(); const selectionRevision = markVisibleConversationRevision(); - const currentVisibleConversationId = conversationIdRef.current.trim(); - const previousSelectedHistoryId = selectedHistoryIdRef.current.trim(); - const isChangingSelectedHistory = previousSelectedHistoryId !== conversationIdValue; - const isSwitchingVisibleConversation = - currentVisibleConversationId !== "" && currentVisibleConversationId !== conversationIdValue; + const previousDisplayedConversationId = getDisplayedConversationId(); + const isChangingConversation = previousDisplayedConversationId !== conversationIdValue; if (options?.scrollToBottom) { pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; } - if (options?.syncChat && isSwitchingVisibleConversation) { - cacheVisibleConversationRuntime(currentVisibleConversationId); + if (isChangingConversation && previousDisplayedConversationId) { + // Fold the previous conversation's settled tail so a revisit starts + // with a clean committed transcript. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTail(); } draftConversationPinnedRef.current = false; protectedConversationRef.current = conversationIdValue; + conversationIdRef.current = conversationIdValue; selectedHistoryIdRef.current = conversationIdValue; + setConversationId(conversationIdValue); setSelectedHistoryId(conversationIdValue); - if (isChangingSelectedHistory) { + if (isChangingConversation) { + setChatError(null); setSelectedHistory(null); - setSelectedHistoryEntries([]); - } - - if (options?.resetLiveStream) { - clearConversationLiveStream(conversationIdValue); } setHistoryDetailLoading(true); @@ -2830,48 +1691,10 @@ export default function GatewayApp() { return; } setSelectedHistory(detail); - setSelectedHistoryEntries(entries); - if (options?.syncChat) { - const shouldPreserveLiveRuntime = - isHydrationBlocked(detail.conversation_id) || - remoteRunningConversationIdsRef.current.has(detail.conversation_id); - if (shouldPreserveLiveRuntime) { - return; - } - if (options.clearLiveStream) { - clearConversationLiveStream(conversationIdValue); - } - const currentRuntime = - conversationIdRef.current.trim() === detail.conversation_id && - (selectedHistoryIdRef.current.trim() === "" || - selectedHistoryIdRef.current.trim() === detail.conversation_id) - ? buildVisibleRuntimeEntry() - : (conversationRuntimeCacheRef.current.get(detail.conversation_id) ?? - createConversationRuntimeEntry()); - const nextMessages = mergeHistorySnapshotEntries(currentRuntime.messages, entries, { - isFullSnapshot: detail.has_more === false, - }); - const nextRuntime = createConversationRuntimeEntry({ - messages: nextMessages, - error: null, - toolStatus: null, - isSending: localRunningConversationIdsRef.current.has(detail.conversation_id), - workdir: detail.conversation?.cwd, - }); - conversationRuntimeCacheRef.current.set(detail.conversation_id, nextRuntime); - const shouldSyncRuntime = - conversationIdRef.current.trim() !== detail.conversation_id || - selectedHistoryIdRef.current.trim() !== detail.conversation_id || - nextRuntime.messages !== currentRuntime.messages || - nextRuntime.error !== currentRuntime.error || - nextRuntime.toolStatus !== currentRuntime.toolStatus || - nextRuntime.toolStatusIsCompaction !== currentRuntime.toolStatusIsCompaction || - nextRuntime.isSending !== currentRuntime.isSending || - nextRuntime.workdir !== currentRuntime.workdir; - if (!shouldSyncRuntime) { - return; - } - syncVisibleConversationRuntime(detail.conversation_id, nextRuntime); + transcriptStoreRegistry.get(conversationIdValue).applyHistorySnapshot(entries); + const detailWorkdir = detail.conversation?.cwd?.trim(); + if (detailWorkdir) { + conversationWorkdirsRef.current.set(conversationIdValue, detailWorkdir); } } catch (error) { if ( @@ -2881,37 +1704,12 @@ export default function GatewayApp() { return; } const message = asErrorMessage(error, "history detail request failed"); - const fallbackDetail = { + setSelectedHistory({ conversation_id: conversationIdValue, messages_json: message, has_more: false, - } satisfies HistoryDetail; - const fallbackEntries: ChatEntry[] = [ - { - id: `history-error-${conversationIdValue}`, - kind: "error", - text: message, - }, - ]; - setSelectedHistory(fallbackDetail); - setSelectedHistoryEntries(fallbackEntries); - if (options?.syncChat) { - if (isHydrationBlocked(conversationIdValue)) { - return; - } - if (options.clearLiveStream) { - clearConversationLiveStream(conversationIdValue); - } - const nextRuntime = createConversationRuntimeEntry({ - messages: fallbackEntries, - error: message, - toolStatus: null, - isSending: localRunningConversationIdsRef.current.has(conversationIdValue), - workdir: conversationRuntimeCacheRef.current.get(conversationIdValue)?.workdir, - }); - conversationRuntimeCacheRef.current.set(conversationIdValue, nextRuntime); - syncVisibleConversationRuntime(conversationIdValue, nextRuntime); - } + } satisfies HistoryDetail); + setChatError(message); } finally { if ( historyLoadSequenceRef.current === loadSequence && @@ -2922,6 +1720,7 @@ export default function GatewayApp() { } } + async function reloadHistory(currentApi = api, options?: ReloadHistoryOptions) { if (!currentApi) { return; @@ -2940,29 +1739,25 @@ export default function GatewayApp() { if (requestScopeKey !== historyScopeKeyRef.current) { return; } - const runningConversations = normalizeRunningConversations(response.running_conversations); - for (const runningConversation of runningConversations) { - setRemoteConversationRunningState(runningConversation.conversation_id, true, { - runId: runningConversation.run_id, - workdir: runningConversation.cwd, - firstSeq: runningConversation.first_seq, - runEpoch: runningConversation.run_epoch, - updatedAt: runningConversation.updated_at, - }); - } + // Authoritative running snapshot: hydrate the activity store (sidebar + // dots) and project activity bookkeeping. + const runningConversations = normalizeActivityHydrationItems( + response.running_conversations, + ); + activityStore.hydrate(runningConversations); for (const runningConversation of runningConversations) { - subscribeVisibleConversationEventStream(runningConversation.conversation_id, currentApi); + recordProjectActivity(runningConversation.workdir, runningConversation.updatedAt); } - const runningConversationIds = runningConversations.map((item) => item.conversation_id); - const retainedConversationIds = new Set([ - ...blockedHistoryHydrationConversationIdsRef.current, - ...localRunningConversationIdsRef.current, - ...remoteRunningConversationIdsRef.current, - ...runningConversationIds, - ]); - for (const [id, store] of liveConversationStreamStoresRef.current) { - if (store.getSnapshot().entries.length > 0) { - retainedConversationIds.add(id); + const retainedConversationIds = new Set( + runningConversations.map((item) => item.conversationId), + ); + for (const item of historyItemsRef.current) { + if ( + isLocalDraftConversationId(item.id) || + isConversationBusy(item.id) || + getConversationTranscriptEntryCount(item.id) > 0 + ) { + retainedConversationIds.add(item.id); } } if (silent) { @@ -2985,31 +1780,16 @@ export default function GatewayApp() { : refreshedNextPage; commitHistoryListState(conversations, response.total_count, nextPage); - let adoptedPendingDraftConversationId = ""; - if (options?.adoptPendingDraftConversation === true) { - for (const conversation of conversations) { - if (maybeAdoptActiveDraftConversation(conversation)) { - adoptedPendingDraftConversationId = conversation.id.trim(); - blockedHistoryHydrationConversationIdsRef.current.delete(adoptedPendingDraftConversationId); - break; - } - } - } - if (options?.skipSelectionSync) { return; } const currentConversationId = conversationIdRef.current; const currentSelectedHistoryId = selectedHistoryIdRef.current; - const currentChatMessages = chatMessagesRef.current; + const currentTranscriptEntryCount = + getConversationTranscriptEntryCount(currentConversationId); const currentSelectedHistory = selectedHistoryRef.current; - const requestedPreferredConversationId = options?.preferredConversationId?.trim() ?? ""; - const requestedConversationId = - requestedPreferredConversationId !== "" && - !isLocalDraftConversationId(requestedPreferredConversationId) - ? requestedPreferredConversationId - : adoptedPendingDraftConversationId || requestedPreferredConversationId; + const requestedConversationId = options?.preferredConversationId?.trim() ?? ""; const protectedConversationId = protectedConversationRef.current.trim(); const isProtectedDraftConversation = protectedConversationId === PROTECTED_DRAFT_CONVERSATION; const hadCurrentConversationInHistory = @@ -3048,14 +1828,14 @@ export default function GatewayApp() { } const requestedConversationSummary = - requestedConversationId !== "" + requestedConversationId !== "" && !isLocalDraftConversationId(requestedConversationId) ? pickConversationSummary(conversations, requestedConversationId) : null; const shouldKeepCurrentConversation = requestedConversationId !== "" && requestedConversationSummary === null && currentConversationId === requestedConversationId && - currentChatMessages.length > 0; + currentTranscriptEntryCount > 0; if (shouldKeepCurrentConversation) { return; @@ -3065,7 +1845,7 @@ export default function GatewayApp() { conversationId: currentConversationId, selectedHistoryId: currentSelectedHistoryId, requestedConversationId, - chatMessageCount: currentChatMessages.length, + chatMessageCount: currentTranscriptEntryCount, pendingUploadCount: pendingUploadedFilesRef.current.length, draftPinned: draftConversationPinnedRef.current, }); @@ -3075,7 +1855,7 @@ export default function GatewayApp() { const isCurrentConversationRunning = currentConversationId !== "" && - localRunningConversationIdsRef.current.has(currentConversationId) && + isConversationBusy(currentConversationId) && (currentSelectedHistoryId === "" || currentSelectedHistoryId === currentConversationId) && requestedConversationId === ""; if (isCurrentConversationRunning) { @@ -3089,7 +1869,7 @@ export default function GatewayApp() { ? currentSelectedHistoryId : pickConversationSummary(conversations, currentConversationId) ? currentConversationId - : currentConversationId && currentChatMessages.length > 0 + : currentConversationId && currentTranscriptEntryCount > 0 ? "" : (conversations[0]?.id ?? "")); @@ -3097,28 +1877,18 @@ export default function GatewayApp() { if (!currentConversationId) { setSelectedHistoryId(""); setSelectedHistory(null); - setSelectedHistoryEntries([]); } return; } - setSelectedHistoryId(preferredConversationId); - protectedConversationRef.current = preferredConversationId; - - const shouldSyncChat = - options?.hydrateSelection === true || - ((currentConversationId === "" || isLocalDraftConversationId(currentConversationId)) && - currentChatMessages.length === 0); const shouldHydrateSelection = - shouldSyncChat || currentSelectedHistory?.conversation_id !== preferredConversationId; + options?.hydrateSelection === true || + currentSelectedHistory?.conversation_id !== preferredConversationId || + getDisplayedConversationId() !== preferredConversationId; if (shouldHydrateSelection) { await selectHistory(preferredConversationId, currentApi, { - syncChat: shouldSyncChat, - resetLiveStream: false, - clearLiveStream: - shouldSyncChat && !remoteRunningConversationIdsRef.current.has(preferredConversationId), - scrollToBottom: shouldSyncChat, + scrollToBottom: true, }); } } catch (error) { @@ -3127,20 +1897,6 @@ export default function GatewayApp() { } const message = asErrorMessage(error, "history request failed"); setHistoryError(message); - if (silent) { - return; - } - setSelectedHistory({ - conversation_id: "", - messages_json: message, - }); - setSelectedHistoryEntries([ - { - id: "history-list-error", - kind: "error", - text: message, - }, - ]); } finally { if (!silent && requestScopeKey === historyScopeKeyRef.current) { await waitForMinimumHistoryListLoading(loadingStartedAt); @@ -3149,6 +1905,7 @@ export default function GatewayApp() { } } + const loadMoreHistory = useCallback(async () => { if (!api || historyListPageLoadingRef.current || !historyHasMoreRef.current) { return; @@ -3164,15 +1921,12 @@ export default function GatewayApp() { if (requestScopeKey !== historyScopeKeyRef.current) { return; } - const runningConversations = normalizeRunningConversations(response.running_conversations); + const runningConversations = normalizeActivityHydrationItems( + response.running_conversations, + ); + activityStore.hydrate(runningConversations); for (const runningConversation of runningConversations) { - setRemoteConversationRunningState(runningConversation.conversation_id, true, { - runId: runningConversation.run_id, - workdir: runningConversation.cwd, - firstSeq: runningConversation.first_seq, - runEpoch: runningConversation.run_epoch, - updatedAt: runningConversation.updated_at, - }); + recordProjectActivity(runningConversation.workdir, runningConversation.updatedAt); } const retainConversationIds = new Set(historyItemsRef.current.map((item) => item.id)); const conversations = reconcileConversationSummaries( @@ -3199,119 +1953,13 @@ export default function GatewayApp() { } } }, [ + activityStore, api, commitHistoryListState, getHistoryPositionLockedConversationIds, - setRemoteConversationRunningState, + recordProjectActivity, ]); - const recoverUnavailableActiveConversationStream = useCallback( - (targetConversationId: string, currentApi = api) => { - const targetId = targetConversationId.trim(); - if (!currentApi || !targetId) { - return; - } - - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ).trim(); - const effectiveConversationId = - !isLocalDraftConversationId(targetId) && targetId !== "" - ? targetId - : visibleConversationId !== "" && !isLocalDraftConversationId(visibleConversationId) - ? visibleConversationId - : targetId; - - clearConversationLiveStream(targetId); - clearConversationStreamingState(targetId); - if (effectiveConversationId !== targetId) { - clearConversationLiveStream(effectiveConversationId); - clearConversationStreamingState(effectiveConversationId); - } - - if (!isLocalDraftConversationId(effectiveConversationId)) { - recoverUnavailableConversationStream(effectiveConversationId, currentApi); - return; - } - - void reloadHistory(currentApi, { - hydrateSelection: true, - silent: true, - adoptPendingDraftConversation: true, - }); - }, - [ - api, - clearConversationLiveStream, - clearConversationStreamingState, - recoverUnavailableConversationStream, - reloadHistory, - ], - ); - - const recoverCompletedVisibleConversationFromHistorySnapshot = useCallback( - async (targetConversationId: string, currentApi = api) => { - const result = await fetchVisibleHistoryGuarded(targetConversationId, currentApi); - if (!result) return false; - const { detail, entries, conversationId: conversationIdValue } = result; - - const liveStore = liveConversationStreamStoresRef.current.get(conversationIdValue); - liveStore?.flush(); - const liveEntries = liveStore?.getSnapshot().entries ?? []; - const currentEntries = - conversationIdRef.current.trim() === conversationIdValue && - (selectedHistoryIdRef.current.trim() === "" || - selectedHistoryIdRef.current.trim() === conversationIdValue) - ? chatMessagesRef.current - : selectedHistoryIdRef.current.trim() === conversationIdValue - ? selectedHistoryEntriesRef.current - : (conversationRuntimeCacheRef.current.get(conversationIdValue)?.messages ?? []); - - if ( - !shouldHydrateRestoredConversationSnapshot({ - currentEntries, - historyEntries: entries, - liveEntries, - }) - ) { - return false; - } - - const mergeOptions = { isFullSnapshot: detail.has_more === false }; - pendingHistoryRefreshAfterLiveCompletionRef.current.delete(conversationIdValue); - blockedHistoryHydrationConversationIdsRef.current.delete(conversationIdValue); - clearConversationLiveStream(conversationIdValue); - clearConversationStreamingState(conversationIdValue); - setHistoryDetailLoading(false); - - if (selectedHistoryIdRef.current.trim() === conversationIdValue) { - selectedHistoryRef.current = detail; - setSelectedHistory(detail); - setSelectedHistoryEntries((current) => - mergeHistorySnapshotEntries(current, entries, mergeOptions), - ); - } - - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - messages: mergeHistorySnapshotEntries(current.messages, entries, mergeOptions), - error: null, - toolStatus: null, - toolStatusIsCompaction: false, - isSending: false, - })); - pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; - return true; - }, - [ - api, - clearConversationLiveStream, - clearConversationStreamingState, - updateConversationRuntimeEntry, - ], - ); - const prepareChatRuntime = useCallback( async ( reason: string, @@ -3323,7 +1971,6 @@ export default function GatewayApp() { } if (!chatRuntimePreparePromiseRef.current) { - chatPreflightInFlightRef.current = true; chatRuntimePreparePromiseRef.current = currentApi .prepareChatRuntime(reason) .then((nextStatus) => { @@ -3338,7 +1985,6 @@ export default function GatewayApp() { }) .finally(() => { chatRuntimePreparePromiseRef.current = null; - chatPreflightInFlightRef.current = false; }); } @@ -3353,117 +1999,31 @@ export default function GatewayApp() { let timeoutId: number | null = null; try { return await Promise.race([ - preparePromise, - new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - reject(new Error("Desktop chat runtime is recovering. Please retry shortly.")); - }, timeoutMs); - }), - ]); - } finally { - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - } - }, - [api], - ); - - const recoverVisibleConversationAfterPageRestore = useCallback( - (currentApi = api) => { - if (!currentApi) { - return; - } - - if ( - chatPreflightInFlightRef.current || - chatStartInFlightRef.current || - submitInFlightRef.current - ) { - return; - } - - const visibleConversationId = resolveVisibleConversationId( - selectedHistoryIdRef.current, - conversationIdRef.current, - ).trim(); - if (!visibleConversationId) { - return; - } - - const now = Date.now(); - const lastRefreshAt = - restoredPageHistoryRefreshAtRef.current.get(visibleConversationId) ?? 0; - if (now - lastRefreshAt < PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS) { - return; - } - restoredPageHistoryRefreshAtRef.current.set(visibleConversationId, now); - - if (isLocalDraftConversationId(visibleConversationId)) { - void reloadHistory(currentApi, { - preferredConversationId: visibleConversationId, - hydrateSelection: true, - silent: true, - adoptPendingDraftConversation: true, - }); - return; - } - - const hasLocalRunningState = - isHydrationBlocked(visibleConversationId) || - localRunningConversationIdsRef.current.has(visibleConversationId); - const hasRetainedLiveStream = hasRetainedConversationLiveStream(visibleConversationId); - const isRemoteRunning = remoteRunningConversationIdsRef.current.has(visibleConversationId); - - if (hasLocalRunningState || hasRetainedLiveStream || isRemoteRunning) { - void recoverCompletedVisibleConversationFromHistorySnapshot( - visibleConversationId, - currentApi, - ).then((hydrated) => { - if (hydrated) { - return; - } - if (remoteRunningConversationIdsRef.current.has(visibleConversationId)) { - subscribeVisibleConversationEventStream(visibleConversationId, currentApi); - } - }); - return; + preparePromise, + new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new Error("Desktop chat runtime is recovering. Please retry shortly.")); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } } - - void refreshVisibleConversationHistorySnapshot(visibleConversationId, currentApi, { - allowIdle: true, - }); }, - [ - api, - subscribeVisibleConversationEventStream, - getConversationAbortController, - hasRetainedConversationLiveStream, - recoverCompletedVisibleConversationFromHistorySnapshot, - refreshVisibleConversationHistorySnapshot, - reloadHistory, - ], - ); - const recoverVisibleConversationAfterPageRestoreRef = useRef( - recoverVisibleConversationAfterPageRestore, + [api], ); - useEffect(() => { - recoverVisibleConversationAfterPageRestoreRef.current = - recoverVisibleConversationAfterPageRestore; - }, [recoverVisibleConversationAfterPageRestore]); - useEffect(() => { if (!api || !status?.online) { return; } - if (chatPreflightInFlightRef.current || chatStartInFlightRef.current) { - return; - } const currentConversationId = conversationIdRef.current.trim(); + const currentTranscriptEntryCount = getConversationTranscriptEntryCount(currentConversationId); const shouldKeepNewConversation = - chatMessagesRef.current.length === 0 && + currentTranscriptEntryCount === 0 && selectedHistoryIdRef.current.trim() === "" && (currentConversationId === "" || isLocalDraftConversationId(currentConversationId)); @@ -3471,18 +2031,20 @@ export default function GatewayApp() { skipSelectionSync: shouldKeepNewConversation, hydrateSelection: !shouldKeepNewConversation && - chatMessagesRef.current.length === 0 && + currentTranscriptEntryCount === 0 && (currentConversationId === "" || isLocalDraftConversationId(currentConversationId)), }); }, [api, historyScopeKey, status?.online]); + // Foreground nudge: waking the page just pings the runtime keep-warm; the + // socket's own wakeup/reconnect plus per-conversation subscription resume + // replaces the old page-restore recovery machinery. useEffect(() => { if (!api || historyShareToken || status?.online !== true) { return; } - let delayedRestoreTimer: number | null = null; - const runRecovery = () => { + const nudgeRuntime = () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } @@ -3490,43 +2052,30 @@ export default function GatewayApp() { "foreground", api, CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS, - ) - .catch(() => undefined) - .finally(() => { - recoverVisibleConversationAfterPageRestoreRef.current(api); - if (delayedRestoreTimer !== null) { - window.clearTimeout(delayedRestoreTimer); - } - delayedRestoreTimer = window.setTimeout(() => { - delayedRestoreTimer = null; - recoverVisibleConversationAfterPageRestoreRef.current(api); - }, PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS + 350); - }); + ).catch(() => undefined); }; const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - runRecovery(); + nudgeRuntime(); } }; - window.addEventListener("pageshow", runRecovery); - window.addEventListener("focus", runRecovery); + window.addEventListener("pageshow", nudgeRuntime); + window.addEventListener("focus", nudgeRuntime); document.addEventListener("visibilitychange", handleVisibilityChange); - document.addEventListener("resume", runRecovery); - runRecovery(); + document.addEventListener("resume", nudgeRuntime); + nudgeRuntime(); return () => { - if (delayedRestoreTimer !== null) { - window.clearTimeout(delayedRestoreTimer); - } - window.removeEventListener("pageshow", runRecovery); - window.removeEventListener("focus", runRecovery); + window.removeEventListener("pageshow", nudgeRuntime); + window.removeEventListener("focus", nudgeRuntime); document.removeEventListener("visibilitychange", handleVisibilityChange); - document.removeEventListener("resume", runRecovery); + document.removeEventListener("resume", nudgeRuntime); }; }, [api, historyShareToken, prepareChatRuntime, status?.online]); + useEffect(() => { if (!api || historyShareToken || status?.online !== true) { return; @@ -3553,9 +2102,15 @@ export default function GatewayApp() { }; }, [api, historyShareToken, prepareChatRuntime, status?.online]); - async function sendChat(message: string, options?: SendChatOptions) { - if (!api || chatBusyRef.current) { - return; + // Lean submission flow: optimistic echo + chat.command through the pipeline. + // Everything after run start flows through the persistent conversation + // stream subscription — sendChat does not consume stream events at all. + async function sendChat( + message: string, + options?: SendChatOptions, + ): Promise { + if (!api) { + return null; } const uploadedFiles = options?.uploadedFiles ?? []; @@ -3568,64 +2123,19 @@ export default function GatewayApp() { setSelectedHistoryId(activeConversationId); } const startedAsDraftConversation = isLocalDraftConversationId(activeConversationId); - const pendingDraft = getPendingDraftId(); - if (pendingDraft && pendingDraft !== activeConversationId) { - const message = "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: message, - })); - return; - } - if ( - chatStartLocksRef.current.has(activeConversationId) || - getConversationAbortController(activeConversationId) !== null || - localRunningConversationIdsRef.current.has(activeConversationId) - ) { - return; + if (chatCommandPipeline.hasPending(activeConversationId)) { + // One in-flight submission per conversation; the composer routes busy + // conversations to the GUI queue instead. + return null; } clearCachedComposerDraft(activeConversationId); - const lockedConversationIds = new Set([activeConversationId]); - chatStartLocksRef.current.add(activeConversationId); - - // Committing a retained live reply moves it from the live section into the - // virtualized history list. For the visible conversation, run that move as - // one synchronous, scroll-compensated commit — otherwise the virtualizer - // paints a frame with estimated row heights and the transcript visibly - // jumps right as the prompt is sent. - const retainedLiveEntryCount = - liveConversationStreamStoresRef.current.get(activeConversationId)?.getSnapshot().entries - .length ?? 0; - const isVisibleConversationAtSend = - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - activeConversationId; - if (retainedLiveEntryCount > 0 && isVisibleConversationAtSend) { - preserveTranscriptScrollPosition( - () => { - flushSync(() => { - commitConversationLiveStreamToRuntime(activeConversationId, { - clearLiveStream: true, - }); - }); - }, - { stickToBottom: isTranscriptAtBottom() }, - ); - } else { - commitConversationLiveStreamToRuntime(activeConversationId, { - clearLiveStream: true, - }); - } - getConversationLiveStreamStore(activeConversationId); - const controller = new AbortController(); - setConversationAbortController(activeConversationId, controller); - const clientRequestId = - options?.clientRequestId?.trim() || - `webui-chat-${activeConversationId}-${crypto.randomUUID()}`; + + const clientRequestId = options?.clientRequestId?.trim() || crypto.randomUUID(); const startedAt = Date.now(); const persistedConversationWorkdir = pickConversationSummary(historyItemsRef.current, activeConversationId)?.cwd?.trim() || ""; const runtimeConversationWorkdir = - conversationRuntimeCacheRef.current.get(activeConversationId)?.workdir?.trim() || ""; + conversationWorkdirsRef.current.get(activeConversationId)?.trim() || ""; const effectiveWorkdir = isAgentMode ? options?.workdir?.trim() || persistedConversationWorkdir || @@ -3633,52 +2143,24 @@ export default function GatewayApp() { activeWorkspaceProjectPath || settings.system.workdir.trim() : ""; - const optimisticDraftTitle = buildOptimisticConversationTitle(message); + if (effectiveWorkdir) { + conversationWorkdirsRef.current.set(activeConversationId, effectiveWorkdir); + } draftConversationPinnedRef.current = false; - pendingDraftConversationMigrationRef.current = startedAsDraftConversation - ? { - draftConversationId: activeConversationId, - startedAt, - } - : null; protectedConversationRef.current = activeConversationId; - blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); - if ( - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - activeConversationId - ) { + setChatError(null); + if (isDisplayedConversation(activeConversationId)) { stickTranscriptToBottom(); } - const optimisticUserEntryId = - options?.optimisticUserEntryId?.trim() || `user-${crypto.randomUUID()}`; - const shouldAppendOptimisticUserEntry = options?.skipOptimisticUserEntry !== true; - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: null, - toolStatus: null, - toolStatusIsCompaction: false, - isSending: true, - workdir: effectiveWorkdir || undefined, - messages: shouldAppendOptimisticUserEntry - ? [ - ...current.messages, - { - id: optimisticUserEntryId, - kind: "user", - text: message, - attachments: uploadedFiles, - }, - ] - : current.messages, - })); if (startedAsDraftConversation) { + draftClientRequestsRef.current.set(clientRequestId, activeConversationId); optimisticTitleConversationIdsRef.current.add(activeConversationId); updateHistoryItems((current) => upsertConversationSummary( current, { id: activeConversationId, - title: optimisticDraftTitle, + title: buildOptimisticConversationTitle(message), created_at: startedAt, updated_at: startedAt, message_count: 1, @@ -3691,94 +2173,11 @@ export default function GatewayApp() { ); } - let terminalEventSeen = false; - let queuedInGuiSeen = false; - let recoverableTransportErrorSeen = false; - let runActive = false; - let runtimeStarted = false; - let activeGatewayRunId = ""; - const preserveRemoteRunCleanupOptions = () => ({ - remoteRunId: activeGatewayRunId, - preserveRemoteRunOnMismatch: true, - }); - let runtimeStartingStatusTimer: number | null = null; - const clearRuntimeStartingStatusTimer = () => { - if (runtimeStartingStatusTimer === null) { - return; - } - window.clearTimeout(runtimeStartingStatusTimer); - runtimeStartingStatusTimer = null; - }; - const clearRuntimeStartingStatus = () => { - updateConversationRuntimeEntry(activeConversationId, (current) => { - if (current.toolStatus !== CHAT_RUNTIME_STARTING_STATUS) { - return current; - } - return { - ...current, - toolStatus: null, - toolStatusIsCompaction: false, - }; - }); - }; - const shouldShowRuntimeStartingStatus = () => - !isChatRuntimeReadyStatus(statusRef.current); - const scheduleRuntimeStartingStatusTimer = () => { - clearRuntimeStartingStatusTimer(); - if (!shouldShowRuntimeStartingStatus()) { - return; - } - runtimeStartingStatusTimer = window.setTimeout(() => { - runtimeStartingStatusTimer = null; - if ( - runtimeStarted || - terminalEventSeen || - controller.signal.aborted || - !shouldShowRuntimeStartingStatus() - ) { - return; - } - updateConversationRuntimeEntry(activeConversationId, (current) => { - if (!current.isSending || current.toolStatus) { - return current; - } - return { - ...current, - toolStatus: CHAT_RUNTIME_STARTING_STATUS, - toolStatusIsCompaction: false, - }; - }); - }, CHAT_RUNTIME_STARTING_STATUS_DELAY_MS); - }; - const markRunActive = () => { - if (runActive) { - return; - } - runActive = true; - setConversationRunningState(activeConversationId, true); - }; - const markRuntimeStarted = () => { - if (runtimeStarted) { - return; - } - runtimeStarted = true; - clearRuntimeStartingStatusTimer(); - markRunActive(); - clearRuntimeStartingStatus(); - }; - const markRunPreparing = () => { - markRunActive(); - updateConversationRuntimeEntry(activeConversationId, (current) => { - if (!current.isSending || current.toolStatus) { - return current; - } - return { - ...current, - toolStatus: CHAT_RUNTIME_PREPARING_STATUS, - toolStatusIsCompaction: false, - }; - }); - }; + // Keep-warm preflight: the command request itself is the reliable wake-up + // signal for a suspended desktop WebView; the status refresh stays in the + // background so a stale heartbeat cannot block it. + void prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS).catch(() => undefined); + const runtimeControls = normalizeChatRuntimeControlsForProvider( options?.runtimeControls ?? settings.chatRuntimeControls, { @@ -3786,283 +2185,50 @@ export default function GatewayApp() { requestFormat: currentChatProvider?.requestFormat, }, ); - try { - chatStartInFlightRef.current = true; - scheduleRuntimeStartingStatusTimer(); - // The command request is itself the reliable wake-up signal for a suspended - // desktop WebView. Keep the status refresh in the background so a stale - // runtime heartbeat cannot block the request that would wake it. - void prepareChatRuntime("send", api, CHAT_RUNTIME_PREPARE_TIMEOUT_MS) - .then((nextStatus) => { - statusRef.current = nextStatus; - if (isChatRuntimeReadyStatus(nextStatus)) { - clearRuntimeStartingStatusTimer(); - clearRuntimeStartingStatus(); - } - }) - .catch(() => undefined); - const gatewaySelectedModel = buildGatewaySelectedModel(settings.selectedModel, activeProviders); - const gatewaySystemSettings = buildGatewaySystemSettings(settings, effectiveWorkdir); - const targetConversationId = isLocalDraftConversationId(activeConversationId) - ? undefined - : activeConversationId; - const chatStream = api.commandChat({ - type: options?.editMessageRef ? "chat.edit_resend" : "chat.submit", - message, - conversationId: targetConversationId, - selectedModel: gatewaySelectedModel, - systemSettings: gatewaySystemSettings, - signal: controller.signal, - uploadedFiles, - clientRequestId, - runtimeControls, - baseMessageRef: options?.editMessageRef, - queuePolicy: options?.queuePolicy ?? "auto", - }); - for await (const event of chatStream) { - const eventRunId = readGatewayChatEventRunId(event); - if (eventRunId) { - activeGatewayRunId = eventRunId; - } - if (event.conversation_id && event.conversation_id !== "") { - const nextConversationId = event.conversation_id.trim(); - if (nextConversationId !== activeConversationId) { - const previousConversationId = activeConversationId; - if ( - pendingDraftConversationMigrationRef.current?.draftConversationId === - previousConversationId - ) { - pendingDraftConversationMigrationRef.current = null; - } - migrateConversation(previousConversationId, nextConversationId); - activeConversationId = nextConversationId; - lockedConversationIds.add(activeConversationId); - if (runActive) { - setConversationRunningState(previousConversationId, false); - setConversationRunningState(activeConversationId, true); - } - } - const summary = pickConversationSummary(historyItemsRef.current, activeConversationId); - if (!summary && startedAsDraftConversation) { - optimisticTitleConversationIdsRef.current.add(activeConversationId); - updateHistoryItems((current) => { - const existing = pickConversationSummary(current, activeConversationId); - if (existing) { - return current; - } - return upsertConversationSummary( - current, - { - id: activeConversationId, - title: optimisticDraftTitle, - created_at: startedAt, - updated_at: startedAt, - message_count: 1, - cwd: effectiveWorkdir || undefined, - }, - { preserveExistingTitle: true }, - ); - }); - } - } - if (event.type === "runtime_snapshot") { - markRuntimeStarted(); - const terminalSnapshot = applyChatRuntimeSnapshotEvent(event, activeConversationId, api); - if (terminalSnapshot) { - terminalEventSeen = true; - clearRuntimeStartingStatusTimer(); - clearConversationStreamingState( - activeConversationId, - preserveRemoteRunCleanupOptions(), - ); - break; - } - continue; - } + const commandInput: GatewayChatCommandInput = { + type: options?.editMessageRef ? "chat.edit_resend" : "chat.submit", + message, + conversationId: startedAsDraftConversation ? undefined : activeConversationId, + selectedModel: buildGatewaySelectedModel(settings.selectedModel, activeProviders), + systemSettings: buildGatewaySystemSettings(settings, effectiveWorkdir), + uploadedFiles, + clientRequestId, + runtimeControls, + baseMessageRef: options?.editMessageRef, + queuePolicy: options?.queuePolicy ?? "auto", + }; - if (isChatControlEvent(event)) { - if (event.type === "queued_in_gui" || event.state === "desktop_queued") { - terminalEventSeen = true; - queuedInGuiSeen = true; - clearRuntimeStartingStatusTimer(); - clearConversationStreamingState( - activeConversationId, - preserveRemoteRunCleanupOptions(), - ); - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - messages: current.messages.filter((entry) => entry.id !== optimisticUserEntryId), - error: null, - toolStatus: null, - toolStatusIsCompaction: false, - isSending: false, - })); - const queueConversationId = event.conversation_id?.trim() || activeConversationId; - void api - .chatQueueGet(queueConversationId) - .then((response) => applyChatQueueSnapshot(response.snapshot)) - .catch(() => undefined); - return; - } - if (event.type === "user_message") { - markRunPreparing(); - continue; - } - if (isTerminalChatControlEvent(event)) { - terminalEventSeen = true; - clearRuntimeStartingStatusTimer(); - if (event.type === "failed" || event.state === "failed") { - getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { - flush: true, - }); - } - finalizeTerminalLiveStream( - activeConversationId, - api, - preserveRemoteRunCleanupOptions(), - ); - break; - } else if (isRuntimeStartedChatControlEvent(event)) { - markRuntimeStarted(); - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - toolStatus: VIBING_STATUS, - toolStatusIsCompaction: false, - })); - } else if (isPreparingChatControlEvent(event)) { - markRunPreparing(); - } - continue; - } - markRuntimeStarted(); - if (isChatStreamNotAvailableEvent(event)) { - terminalEventSeen = true; - recoverUnavailableActiveConversationStream(activeConversationId, api); - return; - } - if (event.type === "tool_status") { - const normalizedStatus = normalizeOptionalStatus(event.status); - const isCompaction = normalizedStatus !== null && event.isCompaction === true; - getConversationLiveStreamStore(activeConversationId)?.setToolStatus( - normalizedStatus, - isCompaction, - ); - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - toolStatus: normalizedStatus, - toolStatusIsCompaction: isCompaction, - })); - } else { - const terminalEvent = isTerminalChatEvent(event); - getConversationLiveStreamStore(activeConversationId)?.appendEvent(event, { - flush: terminalEvent, - }); - handleTunnelManagerChatEvent(event); - if (terminalEvent) { - terminalEventSeen = true; - clearRuntimeStartingStatusTimer(); - const liveTitle = readChatEventTitle(event); - if (liveTitle && isChatEventTitleFinal(event)) { - applyLiveConversationTitle( - event.conversation_id?.trim() || activeConversationId, - liveTitle, - { isFinal: true }, - ); - } - finalizeTerminalLiveStream( - activeConversationId, - api, - preserveRemoteRunCleanupOptions(), - ); - break; - } - } - const liveTitle = readChatEventTitle(event); - if (liveTitle && isChatEventTitleFinal(event)) { - applyLiveConversationTitle( - event.conversation_id?.trim() || activeConversationId, - liveTitle, - { isFinal: true }, - ); - } - } - } catch (error) { - if (!isAbortError(error)) { - const message = asErrorMessage(error, "chat request failed"); - if (isChatStreamNotAvailableMessage(message)) { - terminalEventSeen = true; - recoverUnavailableActiveConversationStream(activeConversationId, api); - } else if ( - isRecoverableChatStreamTransportMessage(message) && - (runActive || - runtimeStarted || - activeGatewayRunId !== "" || - remoteRunningConversationIdsRef.current.has(activeConversationId)) - ) { - recoverableTransportErrorSeen = true; - getConversationLiveStreamStore(activeConversationId)?.flush(); - } else { - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: message, - })); - } - } - } finally { - clearRuntimeStartingStatusTimer(); - chatStartInFlightRef.current = false; - clearConversationStreamingState(activeConversationId, { - ...preserveRemoteRunCleanupOptions(), - preserveRemoteRun: recoverableTransportErrorSeen, - }); - if (remoteRunningConversationIdsRef.current.has(activeConversationId)) { - subscribeVisibleConversationEventStream(activeConversationId, api); - } - if (status?.online && (!terminalEventSeen || queuedInGuiSeen)) { - void reloadHistory(api, { - preferredConversationId: activeConversationId, - skipSelectionSync: true, - silent: true, - }); - } - if (queuedInGuiSeen && status?.online) { - const queuedConvId = activeConversationId; - const delayedReload = () => - void reloadHistory(api, { - preferredConversationId: queuedConvId, - skipSelectionSync: true, - silent: true, - }); - setTimeout(delayedReload, 3000); - setTimeout(delayedReload, 8000); - } - if (!options?.editMessageRef) { - blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); - } + const outcome = await chatCommandPipeline.submit({ + conversationId: activeConversationId, + clientRequestId, + message, + attachments: uploadedFiles, + submit: () => api.chatCommand(commandInput), + }); + + if (outcome.kind === "accepted") { + const acceptedConversationId = outcome.accepted.conversationId.trim(); if ( - pendingDraftConversationMigrationRef.current?.draftConversationId === activeConversationId + startedAsDraftConversation && + acceptedConversationId && + acceptedConversationId !== activeConversationId && + !isLocalDraftConversationId(acceptedConversationId) ) { - pendingDraftConversationMigrationRef.current = null; - if (startedAsDraftConversation && isLocalDraftConversationId(activeConversationId)) { - optimisticTitleConversationIdsRef.current.delete(activeConversationId); - unlockHistoryTitlePosition(activeConversationId); - updateHistoryItems((current) => - current.filter((item) => item.id !== activeConversationId), - ); - if ( - conversationIdRef.current === activeConversationId || - selectedHistoryIdRef.current === activeConversationId - ) { - startNewConversation({ - workdir: isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, - }); - } - } - } - for (const conversationIdValue of lockedConversationIds) { - chatStartLocksRef.current.delete(conversationIdValue); + // The accept response already carries the real conversation id; run + // the same binding path a `command_update bound` would take. + chatCommandPipeline.handleCommandUpdate({ + runId: outcome.accepted.runId, + clientRequestId, + conversationId: acceptedConversationId, + phase: "bound", + errorCode: null, + message: null, + }); } + } else if (outcome.kind === "failed") { + draftClientRequestsRef.current.delete(clientRequestId); } + return outcome; } // Edit-resend is memoized across settings sync; always call the latest sender @@ -4070,60 +2236,31 @@ export default function GatewayApp() { sendChatRef.current = sendChat; async function cancelChat(targetConversationId?: string) { - const activeConversationId = targetConversationId?.trim() || conversationIdRef.current.trim(); - if (!activeConversationId) { + const activeConversationId = targetConversationId?.trim() || getDisplayedConversationId(); + if ( + !api || + !activeConversationId || + isLocalDraftConversationId(activeConversationId) + ) { return; } - const controller = getConversationAbortController(activeConversationId); - const cancelRunId = - conversationEventStreamsRef.current.get(activeConversationId)?.runId ?? - remoteRunningConversationRuntimeRef.current.get(activeConversationId)?.runId ?? - ""; - const isVisibleConversation = - resolveVisibleConversationId(selectedHistoryIdRef.current, conversationIdRef.current) === - activeConversationId; - const shouldKeepBottom = isVisibleConversation && isTranscriptAtBottom(); - const cancelRequest = - !controller && - api && - activeConversationId !== "" && - !isLocalDraftConversationId(activeConversationId) - ? api.cancelChat(activeConversationId, cancelRunId).catch((error) => { - if (!isAbortError(error)) { - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: asErrorMessage(error, "cancel chat request failed"), - })); - } - }) - : null; - controller?.abort(); - stopConversationEventStreamSubscription(activeConversationId); - if (isVisibleConversation) { - preserveTranscriptScrollPosition( - () => { - flushSync(() => { - commitConversationLiveStreamToRuntime(activeConversationId, { - clearLiveStream: false, - }); - clearConversationStreamingState(activeConversationId); - }); - }, - { stickToBottom: shouldKeepBottom }, - ); - if (shouldKeepBottom) { - stickTranscriptToBottom(); - } else { - refreshTranscriptScrollState(); + // No local terminal marking: the stream's run_finished settles the UI + // (cancelling state shows until the agent confirms or the gateway + // watchdog forces the terminal event). + const runId = + transcriptStoreRegistry.peek(activeConversationId)?.getSnapshot().activeRun?.runId ?? + activityStore.get(activeConversationId)?.runId ?? + undefined; + try { + await api.cancelChat(activeConversationId, runId); + } catch (error) { + if (!isAbortError(error)) { + setChatError(asErrorMessage(error, "cancel chat request failed")); } - } else { - clearConversationStreamingState(activeConversationId); - } - if (cancelRequest) { - await cancelRequest; } } + async function materializeComposerDraftForSend( draft: MentionComposerDraft, files: PendingUploadedFile[], @@ -4176,9 +2313,8 @@ export default function GatewayApp() { return false; } - const cachedRuntime = conversationRuntimeCacheRef.current.get(conversationIdValue); const workdirForTurn = ( - cachedRuntime?.workdir ?? + conversationWorkdirsRef.current.get(conversationIdValue) ?? displayedConversationWorkdirRef.current ?? activeWorkspaceProjectPath ?? settings.system.workdir @@ -4190,35 +2326,20 @@ export default function GatewayApp() { } clearCurrentComposerDraftForQueuedTurn(conversationIdValue); clearedComposer = true; - const gatewaySelectedModel = buildGatewaySelectedModel(settings.selectedModel, activeProviders); - const gatewaySystemSettings = buildGatewaySystemSettings(settings, workdirForTurn); - const stream = api.commandChat({ - type: "chat.submit", - message: materialized.text, - conversationId: isLocalDraftConversationId(conversationIdValue) - ? undefined - : conversationIdValue, - selectedModel: gatewaySelectedModel, - systemSettings: gatewaySystemSettings, + // Same pipeline path as a normal send: `command_update queued_in_gui` + // (or the stream's run_queued event) refreshes the queue snapshot; a + // direct start settles through run_started. + const outcome = await sendChat(materialized.text, { + conversationId: conversationIdValue, uploadedFiles: materialized.uploadedFiles, runtimeControls: chatRuntimeControlsForCurrentProvider, + workdir: workdirForTurn, queuePolicy, }); - for await (const event of stream) { - if (event.type === "queued_in_gui" || ("state" in event && event.state === "desktop_queued")) { - const queueConversationId = event.conversation_id?.trim() || conversationIdValue; - void api - .chatQueueGet(queueConversationId) - .then((response) => applyChatQueueSnapshot(response.snapshot)) - .catch(() => undefined); - return true; - } - if (event.type === "failed" || event.type === "error") { - throw new Error("message" in event && typeof event.message === "string" ? event.message : "queue request failed"); - } - if (event.type === "started" || ("state" in event && event.state === "running")) { - return true; - } + if (!outcome || outcome.kind === "failed") { + throw new Error( + outcome && outcome.kind === "failed" ? outcome.message : "queue request failed", + ); } return true; } catch (error) { @@ -4230,14 +2351,16 @@ export default function GatewayApp() { setPendingUploadsForConversation(conversationIdValue, uploadedFiles); } } - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - error: asErrorMessage(error, "queued chat request failed"), - })); + reportChatQueueActionError( + conversationIdValue, + error, + "queued chat request failed", + ); return false; } } + async function commitQueuedChatEdit() { const session = queuedChatEditSessionRef.current; const conversationIdValue = getDisplayedConversationId(); @@ -4256,10 +2379,11 @@ export default function GatewayApp() { uploadedFilesJson: JSON.stringify(uploadedFiles), }); if (!response.accepted) { - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - error: response.message || "queued edit failed", - })); + reportChatQueueActionError( + conversationIdValue, + response.message || "queued edit failed", + "queued edit failed", + ); return false; } queuedChatEditSessionRef.current = null; @@ -4269,10 +2393,7 @@ export default function GatewayApp() { applyChatQueueSnapshot(response.snapshot); return true; } catch (error) { - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - error: asErrorMessage(error, "queued edit failed"), - })); + reportChatQueueActionError(conversationIdValue, error, "queued edit failed"); return false; } } @@ -4284,10 +2405,9 @@ export default function GatewayApp() { ) { const key = conversationId.trim(); if (!key) return; - updateConversationRuntimeEntry(key, (current) => ({ - ...current, - error: asErrorMessage(error, fallback), - })); + if (isDisplayedConversation(key)) { + setChatError(asErrorMessage(error, fallback)); + } } function runQueuedTurnNow(id: string) { @@ -4344,10 +2464,11 @@ export default function GatewayApp() { try { if (!response.accepted || !response.item) { if (!response.accepted) { - updateConversationRuntimeEntry(conversationIdValue, (current) => ({ - ...current, - error: response.message || "queued edit failed", - })); + reportChatQueueActionError( + conversationIdValue, + response.message || "queued edit failed", + "queued edit failed", + ); } return; } @@ -4391,9 +2512,8 @@ export default function GatewayApp() { function startNewConversation(options?: { workdir?: string }) { const currentConversationId = conversationIdRef.current.trim(); if (currentConversationId) { - cacheVisibleConversationRuntime(currentConversationId); + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTail(); optimisticTitleConversationIdsRef.current.delete(currentConversationId); - stopConversationEventStreamSubscription(currentConversationId); clearCachedComposerDraft(currentConversationId); } invalidateHistoryLoad(); @@ -4403,24 +2523,18 @@ export default function GatewayApp() { const nextConversationId = createLocalDraftConversationId(); draftConversationPinnedRef.current = true; protectedConversationRef.current = PROTECTED_DRAFT_CONVERSATION; - chatStartLocksRef.current.clear(); submitInFlightRef.current = false; - const pendingDraft = getPendingDraftId(); - const pendingDraftStillActive = - pendingDraft !== "" && - (localRunningConversationIdsRef.current.has(pendingDraft) || - isHydrationBlocked(pendingDraft)); - if (!pendingDraftStillActive) { - pendingDraftConversationMigrationRef.current = null; - } composerRef.current?.clear(); - const nextRuntime = createConversationRuntimeEntry({ - workdir: options?.workdir?.trim() || undefined, - }); - conversationRuntimeCacheRef.current.set(nextConversationId, nextRuntime); - syncVisibleConversationRuntime(nextConversationId, nextRuntime); + const nextWorkdir = options?.workdir?.trim() || ""; + if (nextWorkdir) { + conversationWorkdirsRef.current.set(nextConversationId, nextWorkdir); + } + conversationIdRef.current = nextConversationId; + selectedHistoryIdRef.current = nextConversationId; + setConversationId(nextConversationId); + setSelectedHistoryId(nextConversationId); + setChatError(null); setSelectedHistory(null); - setSelectedHistoryEntries([]); setPendingUploadsForConversation(nextConversationId, []); } @@ -4489,19 +2603,11 @@ export default function GatewayApp() { const runningMessage = "项目中仍有后台任务运行,暂时不能删除该项目。"; const projectHasRunningConversation = () => { if (!pathKey) return false; - const runningIds = new Set(); - for (const id of localRunningConversationIdsRef.current) { - const conversationId = id.trim(); - if (conversationId) runningIds.add(conversationId); - } - for (const id of remoteRunningConversationIdsRef.current) { - const conversationId = id.trim(); - if (conversationId) runningIds.add(conversationId); - } - - for (const conversationId of runningIds) { + for (const [conversationId, activity] of activityStore.getSnapshot().activities) { const runtimeWorkdir = - conversationRuntimeCacheRef.current.get(conversationId)?.workdir?.trim() || ""; + activity.workdir?.trim() || + conversationWorkdirsRef.current.get(conversationId)?.trim() || + ""; const persistedWorkdir = historyItemsRef.current.find((item) => item.id === conversationId)?.cwd?.trim() || ""; if (workspaceProjectPathKey(runtimeWorkdir || persistedWorkdir) === pathKey) { @@ -4545,10 +2651,8 @@ export default function GatewayApp() { } } - const runningConversationIdsInProject = conversationIds.filter( - (id) => - localRunningConversationIdsRef.current.has(id) || - remoteRunningConversationIdsRef.current.has(id), + const runningConversationIdsInProject = conversationIds.filter((id) => + isConversationBusy(id), ); if (runningConversationIdsInProject.length > 0 || projectHasRunningConversation()) { setHistoryError(runningMessage); @@ -4614,7 +2718,7 @@ export default function GatewayApp() { conversationIdRef.current, ); const visibleRuntimeWorkdir = - conversationRuntimeCacheRef.current.get(visibleConversationId)?.workdir?.trim() || ""; + conversationWorkdirsRef.current.get(visibleConversationId)?.trim() || ""; const visiblePersistedWorkdir = historyItemsRef.current .find((item) => item.id === visibleConversationId) @@ -4642,10 +2746,8 @@ export default function GatewayApp() { for (const conversationId of deletedConversationIds) { optimisticTitleConversationIdsRef.current.delete(conversationId); unlockHistoryTitlePosition(conversationId); - conversationRuntimeCacheRef.current.delete(conversationId); - conversationAbortControllersRef.current.delete(conversationId); - blockedHistoryHydrationConversationIdsRef.current.delete(conversationId); - clearConversationLiveStream(conversationId); + transcriptStoreRegistry.remove(conversationId); + conversationWorkdirsRef.current.delete(conversationId); clearCachedComposerDraft(conversationId); pendingUploadsByConversationRef.current.delete(conversationId); } @@ -4688,10 +2790,11 @@ export default function GatewayApp() { }, [ activeWorkspaceProjectPath, + activityStore, api, clearCachedComposerDraft, - clearConversationLiveStream, isAgentMode, + isConversationBusy, refreshHistoryWorkdirs, removeWorkspaceProjectFromSettings, requestConfirmDialog, @@ -4740,33 +2843,31 @@ export default function GatewayApp() { const currentConversationId = conversationIdRef.current.trim(); if (currentConversationId && currentConversationId !== targetConversationId) { - cacheVisibleConversationRuntime(currentConversationId); + cacheVisibleComposerDraft(currentConversationId); } pendingDisplayedConversationAutoBottomRef.current = targetConversationId; - const cachedRuntime = conversationRuntimeCacheRef.current.get(targetConversationId); - if (cachedRuntime) { - protectedConversationRef.current = targetConversationId; - syncVisibleConversationRuntime(targetConversationId, cachedRuntime); - } - if ( - cachedRuntime && - (cachedRuntime.isSending || localRunningConversationIdsRef.current.has(targetConversationId)) - ) { + if (isLocalDraftConversationId(targetConversationId)) { + // Local drafts have no server history to load; the transcript store is + // already the source (optimistic entries and error entries included). invalidateHistoryLoad(); markVisibleConversationRevision(); + if (currentConversationId && currentConversationId !== targetConversationId) { + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTail(); + } + protectedConversationRef.current = targetConversationId; + conversationIdRef.current = targetConversationId; + selectedHistoryIdRef.current = targetConversationId; + setConversationId(targetConversationId); + setSelectedHistoryId(targetConversationId); + setChatError(null); setHistoryDetailLoading(false); setSelectedHistory(null); - setSelectedHistoryEntries([]); return; } - const isRemoteRunning = remoteRunningConversationIdsRef.current.has(targetConversationId); void selectHistory(targetConversationId, api, { - syncChat: true, - resetLiveStream: false, - clearLiveStream: !isRemoteRunning, scrollToBottom: true, }); } @@ -5092,9 +3193,9 @@ export default function GatewayApp() { const activeConversationId = conversationIdRef.current.trim(); if ( !api || - chatBusyRef.current || !activeConversationId || - isLocalDraftConversationId(activeConversationId) + isLocalDraftConversationId(activeConversationId) || + isConversationBusy(activeConversationId) ) { return; } @@ -5102,155 +3203,30 @@ export default function GatewayApp() { if (!normalized && uploadedFiles.length === 0) { return; } - const optimisticEditUserEntryId = `edit-user-${crypto.randomUUID()}`; - const optimisticEditUserEntry: ChatEntry = { - id: optimisticEditUserEntryId, - kind: "user", - text: normalized, - attachments: uploadedFiles, - }; - const messageRefMatches = (candidate: HistoryMessageRef | undefined) => - Boolean(candidate) && - candidate?.segmentIndex === messageRef.segmentIndex && - candidate?.messageIndex === messageRef.messageIndex && - candidate?.segmentId === messageRef.segmentId && - candidate?.messageId === messageRef.messageId && - candidate?.role === messageRef.role && - candidate?.contentHash === messageRef.contentHash; - const buildPreviewMessages = (messages: ChatEntry[]) => { - const targetIndex = messages.findIndex( - (entry) => entry.kind === "user" && messageRefMatches(entry.messageRef), - ); - const prefix = targetIndex >= 0 ? messages.slice(0, targetIndex) : messages; - return [...prefix, optimisticEditUserEntry]; - }; - const randomPart = - typeof globalThis.crypto?.randomUUID === "function" - ? globalThis.crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - const clientRequestId = `webui-chat.edit_resend-${activeConversationId}-${randomPart}`; - const prevGen = activeEditResendTransactionsRef.current.get(activeConversationId)?.generation ?? 0; - const generation = prevGen + 1; - activeEditResendTransactionsRef.current.set(activeConversationId, { - generation, - clientRequestId, - }); - const isCurrentEditTransaction = () => { - const current = activeEditResendTransactionsRef.current.get(activeConversationId); - return current?.generation === generation && current.clientRequestId === clientRequestId; - }; setHistoryError(null); setChatError(null); composerRef.current?.clear(); setPendingUploadsForConversation(activeConversationId, []); - blockedHistoryHydrationConversationIdsRef.current.add(activeConversationId); - invalidateHistoryLoad(); - markVisibleConversationRevision(); - clearConversationLiveStream(activeConversationId); - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: null, - toolStatus: null, - toolStatusIsCompaction: false, - messages: buildPreviewMessages(current.messages), - })); - - const hydrateEditPrefix = async () => { - const detail = await api.getHistoryPrefix(activeConversationId, messageRef, { - maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES, - }); - if (!isCurrentEditTransaction()) { - return; - } - const entries = await parseHistoryMessagesJsonAsync(detail.messages_json); - if (!isCurrentEditTransaction()) { - return; - } - - setSelectedHistory(detail); - setSelectedHistoryEntries(entries); - updateConversationRuntimeEntry(activeConversationId, (current) => { - if ( - !current.isSending && - !localRunningConversationIdsRef.current.has(activeConversationId) - ) { - return current; - } - return { - ...current, - messages: [...entries, optimisticEditUserEntry], - }; - }); - - const truncatedConversation = detail.conversation; - if (truncatedConversation) { - updateHistoryItems((current) => - upsertConversationSummary(current, truncatedConversation), - ); - } - }; + // Same pipeline path as a normal send, carrying the base message ref. + // The stream's seeded `rebased` event truncates the committed + // transcript at the edited message; the seeded `user_message` adopts + // the optimistic echo by client_request_id. try { - const resendPromise = - sendChatRef.current?.(normalized, { - conversationId: activeConversationId, - uploadedFiles, - editMessageRef: messageRef, - clientRequestId, - optimisticUserEntryId: optimisticEditUserEntryId, - skipOptimisticUserEntry: true, - }) ?? Promise.resolve(); - // Start prefix hydration AFTER sendChat so the chat.command WebSocket - // message is queued before history.prefix. The desktop agent processes - // inbound gRPC envelopes sequentially — sending history.prefix first - // would block the lightweight chat.edit_resend dispatch behind a heavy - // DB read, adding unnecessary "Preparing request…" latency. - void hydrateEditPrefix().catch((error) => { - if (isCurrentEditTransaction()) { - console.warn("edit resend history prefix hydration failed", error); - } + await sendChatRef.current?.(normalized, { + conversationId: activeConversationId, + uploadedFiles, + editMessageRef: messageRef, }); - await resendPromise; - if (isCurrentEditTransaction()) { - await refreshVisibleConversationHistorySnapshot(activeConversationId, api, { - allowIdle: true, - allowDuringEditTransaction: true, - }); - } } catch (error) { - const message = asErrorMessage(error, "编辑后重发失败"); - setChatError(message); - updateConversationRuntimeEntry(activeConversationId, (current) => ({ - ...current, - error: message, - toolStatus: null, - toolStatusIsCompaction: false, - isSending: false, - messages: current.messages.filter((entry) => entry.id !== optimisticEditUserEntryId), - })); - void refreshVisibleConversationHistorySnapshot(activeConversationId, api, { - allowIdle: true, - allowDuringEditTransaction: true, - }); - } finally { - if (isCurrentEditTransaction()) { - activeEditResendTransactionsRef.current.delete(activeConversationId); - blockedHistoryHydrationConversationIdsRef.current.delete(activeConversationId); - } + setChatError(asErrorMessage(error, "编辑后重发失败")); } }, - [ - api, - clearConversationLiveStream, - invalidateHistoryLoad, - markVisibleConversationRevision, - refreshVisibleConversationHistorySnapshot, - updateHistoryItems, - updateConversationRuntimeEntry, - ], + [api, isConversationBusy, setPendingUploadsForConversation], ); + const handleLoadUploadedImagePreview = useCallback( async (workspaceRoot: string, absolutePath: string) => { if (!api) { @@ -5289,26 +3265,14 @@ export default function GatewayApp() { invalidateHistoryLoad(); markVisibleConversationRevision(); clearSession(); - for (const controller of conversationAbortControllersRef.current.values()) { - controller.abort(); - } - conversationAbortControllersRef.current.clear(); - stopAllConversationEventStreamSubscriptions(); - clearAllCompletedLiveStreamMarkers(); - blockedHistoryHydrationConversationIdsRef.current.clear(); - conversationRuntimeCacheRef.current.clear(); - localRunningConversationIdsRef.current = new Set(); - remoteRunningConversationIdsRef.current = new Set(); - remoteRunningConversationRuntimeRef.current = new Map(); + transcriptStoreRegistry.clear(); + activityStore.clear(); + draftClientRequestsRef.current.clear(); + conversationWorkdirsRef.current.clear(); composerDraftCacheRef.current.clear(); composerRef.current?.clear(); conversationIdRef.current = ""; selectedHistoryIdRef.current = ""; - chatMessagesRef.current = []; - chatBusyRef.current = false; - chatErrorRef.current = null; - chatToolStatusRef.current = null; - chatToolStatusIsCompactionRef.current = false; selectedHistoryRef.current = null; historyItemsRef.current = []; historyTotalRef.current = 0; @@ -5320,9 +3284,7 @@ export default function GatewayApp() { clearPendingUploads(); draftConversationPinnedRef.current = false; protectedConversationRef.current = ""; - chatStartLocksRef.current.clear(); submitInFlightRef.current = false; - pendingDraftConversationMigrationRef.current = null; setUserMenuOpen(false); setSettingsOpen(false); setOverlay("closed"); @@ -5330,10 +3292,7 @@ export default function GatewayApp() { setStatus(null); setStatusError(null); setConversationId(""); - setChatBusy(false); - setChatMessages([]); setChatError(null); - applyChatToolStatus(null); optimisticTitleConversationIdsRef.current.clear(); clearHistoryTitlePositionLocks(); historyItemsRef.current = []; @@ -5352,27 +3311,20 @@ export default function GatewayApp() { queuedChatEditSessionRef.current = null; setQueuedChatTurns([]); setChatQueueRevision(0); - setLocalRunningConversationIds(new Set()); - setRemoteRunningConversationIds(new Set()); - setRemoteRunningConversationRuntime(new Map()); setProjectActivityUpdatedAtOverrides(new Map()); resetProjectToolsRuntimeRef.current(); - clearAllConversationLiveStreams(); setSelectedHistoryId(""); setSelectedHistory(null); - setSelectedHistoryEntries([]); setRenamingId(null); setRenameDraft(""); }, [ - applyChatToolStatus, - clearAllConversationLiveStreams, - clearAllCompletedLiveStreamMarkers, + activityStore, clearHistoryTitlePositionLocks, clearPendingUploads, clearSession, invalidateHistoryLoad, markVisibleConversationRevision, - stopAllConversationEventStreamSubscriptions, + transcriptStoreRegistry, ]); const userMenuLabel = (status?.agent_id || "当前用户").trim() || "当前用户"; @@ -5490,52 +3442,43 @@ export default function GatewayApp() { settings.remote.gatewayUrl.trim() && settings.remote.token.trim(), ); + // Sidebar running dots come from the activity store only. const sidebarRunningConversationIds = useMemo(() => { - const next = new Set(remoteRunningConversationIds); - for (const conversationIdValue of localRunningConversationIds) { - next.add(conversationIdValue); - } - return next; - }, [localRunningConversationIds, remoteRunningConversationIds]); + return new Set(activitySnapshot.activities.keys()); + }, [activitySnapshot]); const runningProjectPathKeys = useMemo(() => { const next = new Set(); - for (const conversationIdValue of sidebarRunningConversationIds) { + for (const [conversationIdValue, activity] of activitySnapshot.activities) { const conversationId = conversationIdValue.trim(); if (!conversationId) { continue; } - const runtimeWorkdir = - conversationRuntimeCacheRef.current.get(conversationId)?.workdir?.trim() || ""; - const remoteWorkdir = - remoteRunningConversationRuntime.get(conversationId)?.workdir?.trim() || ""; + const activityWorkdir = activity.workdir?.trim() || ""; + const runtimeWorkdir = conversationWorkdirsRef.current.get(conversationId)?.trim() || ""; const persistedWorkdir = historyItems.find((item) => item.id === conversationId)?.cwd?.trim() || ""; - const resolvedWorkdir = runtimeWorkdir || remoteWorkdir || persistedWorkdir; + const resolvedWorkdir = activityWorkdir || runtimeWorkdir || persistedWorkdir; if (resolvedWorkdir) { next.add(workspaceProjectPathKey(resolvedWorkdir)); } } return next; - }, [historyItems, remoteRunningConversationRuntime, sidebarRunningConversationIds]); + }, [activitySnapshot, historyItems]); const projectActivityUpdatedAts = useMemo(() => { const updatedAts = buildWorkspaceProjectActivityUpdatedAts([ ...historyWorkdirs, - ...Array.from(localRunningConversationIds).map((conversationId) => { - const runtimeWorkdir = - conversationRuntimeCacheRef.current.get(conversationId)?.workdir?.trim() || ""; + ...Array.from(activitySnapshot.activities.entries()).map(([conversationId, activity]) => { + const activityWorkdir = activity.workdir?.trim() || ""; + const runtimeWorkdir = conversationWorkdirsRef.current.get(conversationId)?.trim() || ""; const persistedWorkdir = historyItems.find((item) => item.id === conversationId)?.cwd?.trim() || ""; return { - cwd: runtimeWorkdir || persistedWorkdir, - updatedAt: Date.now(), + cwd: activityWorkdir || runtimeWorkdir || persistedWorkdir, + updatedAt: activity.updatedAt > 0 ? activity.updatedAt : Date.now(), }; }), - ...Array.from(remoteRunningConversationRuntime.values()).map((item) => ({ - cwd: item.workdir, - updatedAt: item.updatedAt, - })), ]); for (const [pathKey, updatedAt] of projectActivityUpdatedAtOverrides) { if (updatedAt > (updatedAts.get(pathKey) ?? 0)) { @@ -5544,17 +3487,15 @@ export default function GatewayApp() { } return updatedAts; }, [ + activitySnapshot, historyItems, historyWorkdirs, - localRunningConversationIds, projectActivityUpdatedAtOverrides, - remoteRunningConversationRuntime, ]); - const displayedConversationId = resolveVisibleConversationId(selectedHistoryId, conversationId); const currentConversationPersistedCwd = historyItems.find((item) => item.id === displayedConversationId)?.cwd?.trim() || ""; const currentConversationRuntimeWorkdir = - conversationRuntimeCacheRef.current.get(displayedConversationId)?.workdir?.trim() || ""; + conversationWorkdirsRef.current.get(displayedConversationId)?.trim() || ""; const displayedConversationWorkdir = currentConversationPersistedCwd || currentConversationRuntimeWorkdir || @@ -5745,8 +3686,6 @@ export default function GatewayApp() { } return displayedConversationTitle || DEFAULT_BROWSER_TITLE; }, [activeView, displayedConversationTitle, historyShareToken, token]); - const hasDetachedSelection = hasDetachedHistorySelection(selectedHistoryId, conversationId); - const visibleTranscriptEntries = hasDetachedSelection ? selectedHistoryEntries : chatMessages; const historyDetailLoadingTitle = useMemo(() => { const selectedId = selectedHistoryId.trim(); if (!selectedId) { @@ -5755,88 +3694,45 @@ export default function GatewayApp() { const item = historyItems.find((candidate) => candidate.id === selectedId); return item ? resolveConversationTitle(item, item.id) : ""; }, [historyItems, selectedHistoryId]); + const visibleTranscriptEntries = displayedTranscript.committed; + const transcriptTailEntries = displayedTranscript.tail; + const displayedTranscriptEntryCount = + visibleTranscriptEntries.length + transcriptTailEntries.length; const transcriptHistoryLoading = - historyDetailLoading && hasDetachedSelection && selectedHistoryEntries.length === 0; + historyDetailLoading && displayedTranscriptEntryCount === 0; const selectedHistoryHasMore = selectedHistory?.conversation_id === displayedConversationId && selectedHistory.has_more === true; const loadingOlderHistory = historyDetailLoading && selectedHistory?.conversation_id === displayedConversationId && - selectedHistoryEntries.length > 0; + displayedTranscriptEntryCount > 0; const handleLoadFullHistory = useCallback(() => { if (!api || !displayedConversationId) { return; } void selectHistory(displayedConversationId, api, { - syncChat: !hasDetachedSelection, fullHistory: true, }); - }, [api, displayedConversationId, hasDetachedSelection]); - const liveTranscriptStore = - displayedConversationId !== "" ? getConversationLiveStreamStore(displayedConversationId) : null; - const liveTranscriptSnapshot = liveTranscriptStore?.getSnapshot(); - const liveTranscriptHasStream = (liveTranscriptSnapshot?.entries.length ?? 0) > 0; - const isLocallyStreamingDisplayedConversation = - chatBusy && conversationId.trim() !== "" && displayedConversationId === conversationId.trim(); - const isObservingRemoteLiveConversation = Boolean( - !isLocallyStreamingDisplayedConversation && - displayedConversationId !== "" && - remoteRunningConversationIds.has(displayedConversationId), - ); - const observedRemoteRunKey = isObservingRemoteLiveConversation - ? resolveRunningConversationRunKey(remoteRunningConversationRuntime.get(displayedConversationId)) - : ""; - useEffect(() => { - const nextDisplayedConversationId = displayedConversationId.trim(); - for (const conversationIdValue of [ - ...conversationEventStreamsRef.current.keys(), - ]) { - if (conversationIdValue !== nextDisplayedConversationId) { - stopConversationEventStreamSubscription(conversationIdValue); - } - } - for (const conversationIdValue of [...liveConversationStreamStoresRef.current.keys()]) { - if ( - conversationIdValue !== nextDisplayedConversationId && - !remoteRunningConversationIdsRef.current.has(conversationIdValue) && - !localRunningConversationIdsRef.current.has(conversationIdValue) - ) { - clearConversationLiveStream(conversationIdValue); - } - } - - if (api && isObservingRemoteLiveConversation && nextDisplayedConversationId !== "") { - subscribeVisibleConversationEventStream(nextDisplayedConversationId, api); - } - }, [ - api, - subscribeVisibleConversationEventStream, - clearConversationLiveStream, - displayedConversationId, - isObservingRemoteLiveConversation, - observedRemoteRunKey, - stopConversationEventStreamSubscription, - ]); + }, [api, displayedConversationId]); + const transcriptTailHasEntries = transcriptTailEntries.length > 0; useEffect(() => { if (typeof document === "undefined") { return; } document.title = browserTitle; }, [browserTitle]); - const transcriptToolStatus = isObservingRemoteLiveConversation - ? (liveTranscriptSnapshot?.toolStatus ?? null) - : hasDetachedSelection - ? null - : chatToolStatus; - const transcriptToolStatusIsCompaction = isObservingRemoteLiveConversation - ? (liveTranscriptSnapshot?.toolStatusIsCompaction ?? false) - : hasDetachedSelection - ? false - : chatToolStatusIsCompaction; - const transcriptBusy = (!hasDetachedSelection && chatBusy) || isObservingRemoteLiveConversation; - const composerIsSending = chatBusy || isObservingRemoteLiveConversation; - const transcriptError = hasDetachedSelection || chatMessages.length === 0 ? null : chatError; + const transcriptBusy = displayedConversationBusy; + // Pipeline pending (pre-first-token) shows the preparing status until the + // stream's own tool_status takes over. + const displayedHasPendingCommand = + displayedConversationId !== "" && chatCommandPipeline.hasPending(displayedConversationId); + const transcriptToolStatus = + displayedTranscript.toolStatus ?? + (displayedHasPendingCommand ? CHAT_RUNTIME_PREPARING_STATUS : null); + const transcriptToolStatusIsCompaction = displayedTranscript.toolStatusIsCompaction; + const composerIsSending = transcriptBusy; + const transcriptError = displayedTranscriptEntryCount === 0 ? null : chatError; const composerCompactionBlocked = transcriptToolStatusIsCompaction; const composerInputDisabled = !status?.online || historyDetailLoading || composerCompactionBlocked; @@ -5897,8 +3793,10 @@ export default function GatewayApp() { ) { return; } + // Switching away folds the settled tail so revisits start clean. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTail(); pendingDisplayedConversationAutoBottomRef.current = nextDisplayedConversationId; - }, [displayedConversationId]); + }, [displayedConversationId, transcriptStoreRegistry]); useLayoutEffect(() => { const targetConversationId = pendingDisplayedConversationAutoBottomRef.current?.trim() ?? ""; @@ -5906,7 +3804,7 @@ export default function GatewayApp() { !targetConversationId || historyDetailLoading || displayedConversationId.trim() !== targetConversationId || - (visibleTranscriptEntries.length === 0 && !liveTranscriptHasStream) + displayedTranscriptEntryCount === 0 ) { return; } @@ -5916,11 +3814,10 @@ export default function GatewayApp() { pendingDisplayedConversationAutoBottomRef.current = null; }, [ displayedConversationId, + displayedTranscriptEntryCount, historyDetailLoading, - liveTranscriptHasStream, refreshTranscriptScrollState, stickTranscriptToBottom, - visibleTranscriptEntries.length, ]); useEffect(() => { @@ -5978,16 +3875,17 @@ export default function GatewayApp() { ]); useLayoutEffect(() => { - if (transcriptBusy || liveTranscriptHasStream) { + if (transcriptBusy || transcriptTailHasEntries) { syncTranscriptAutoScroll(); } refreshTranscriptScrollState(); }, [ chatError, - liveTranscriptHasStream, refreshTranscriptScrollState, syncTranscriptAutoScroll, transcriptBusy, + transcriptTailEntries, + transcriptTailHasEntries, visibleTranscriptEntries, transcriptToolStatus, ]); @@ -6326,7 +4224,7 @@ export default function GatewayApp() { {settingsSyncError ? (
{settingsSyncError}
) : null} - {chatError && chatMessages.length === 0 && !hasDetachedSelection ? ( + {chatError && displayedTranscriptEntryCount === 0 ? (
{chatError}
) : null} @@ -6336,8 +4234,7 @@ export default function GatewayApp() { {historySwitchOverlay ? ( @@ -6417,8 +4314,7 @@ export default function GatewayApp() { return; } if ( - chatBusyRef.current || - isObservingRemoteLiveConversation || + displayedConversationBusyRef.current || queuedChatTurnsForDisplayedConversation.length > 0 ) { submitInFlightRef.current = true; @@ -6471,23 +4367,6 @@ export default function GatewayApp() { return; } const uploadConversationId = getDisplayedConversationId(); - const pendingDraft = getPendingDraftId(); - if ( - pendingDraft && - pendingDraft !== uploadConversationId - ) { - const message = - "上一条新会话仍在创建,请等待它出现在历史记录后再发送新会话。"; - if (uploadConversationId) { - updateConversationRuntimeEntry(uploadConversationId, (current) => ({ - ...current, - error: message, - })); - } else { - setChatError(message); - } - return; - } composerRef.current?.clear(); setPendingUploadsForConversation(uploadConversationId, []); void sendChat(text, { @@ -6504,9 +4383,7 @@ export default function GatewayApp() { })(); }} onStop={() => { - void cancelChat( - isObservingRemoteLiveConversation ? displayedConversationId : undefined, - ); + void cancelChat(displayedConversationId); }} onPrepareChatRuntime={() => { if (!api || historyShareToken) { diff --git a/crates/agent-gateway/web/src/app/chatEventUtils.ts b/crates/agent-gateway/web/src/app/chatEventUtils.ts index 818f9319..53cba593 100644 --- a/crates/agent-gateway/web/src/app/chatEventUtils.ts +++ b/crates/agent-gateway/web/src/app/chatEventUtils.ts @@ -1,16 +1,7 @@ -import type { - AgentStatus, - ChatControlEvent, - ChatEvent, - GatewaySelectedModel, -} from "@/lib/gatewayTypes"; +import type { ChatEvent, GatewaySelectedModel } from "@/lib/gatewayTypes"; import type { AppSettings, SelectedModel } from "@/lib/settings"; -import { - CHAT_RUNTIME_READY_STATUS_TTL_MS, - HISTORY_LIST_MIN_LOADING_MS, - SECONDS_TIMESTAMP_MAX, -} from "./constants"; +import { HISTORY_LIST_MIN_LOADING_MS } from "./constants"; import type { ModelProviderSource, TunnelManagerToolChange } from "./types"; export function asErrorMessage(error: unknown, fallback: string) { @@ -33,45 +24,6 @@ export async function waitForMinimumHistoryListLoading(startedAt: number) { } } -export function normalizeOptionalStatus(value: string | null | undefined) { - const text = typeof value === "string" ? value.trim() : ""; - return text || null; -} - -export function normalizeGatewayTimestampMs(value: number | null | undefined) { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return 0; - } - return value < SECONDS_TIMESTAMP_MAX ? value * 1000 : value; -} - -export function isChatRuntimeReadyStatus( - status: AgentStatus | null | undefined, - now = Date.now(), -) { - if (status?.online !== true) { - return false; - } - - const runtimeState = normalizeOptionalStatus(status.runtime_state)?.toLowerCase() ?? ""; - if (runtimeState === "suspended") { - return false; - } - - const hasReadyState = - runtimeState === "ready" || runtimeState === "draining" || runtimeState === "busy"; - if (status.chat_runtime_ready !== true && !hasReadyState) { - return false; - } - - const runtimeHeartbeatAt = normalizeGatewayTimestampMs(status.runtime_last_heartbeat); - if (runtimeHeartbeatAt <= 0) { - return status.chat_runtime_ready === true; - } - - return now - runtimeHeartbeatAt <= CHAT_RUNTIME_READY_STATUS_TTL_MS; -} - export function isAbortError(error: unknown) { if ( (error instanceof DOMException && error.name === "AbortError") || @@ -101,64 +53,6 @@ export function isChatEventTitleFinal(event: ChatEvent) { return event.type === "done" || ("titleFinal" in event && event.titleFinal === true); } -export function isTerminalChatEvent(event: ChatEvent) { - return event.type === "done" || event.type === "error" || isTerminalChatControlEvent(event); -} - -export function isChatControlEvent(event: ChatEvent): event is ChatControlEvent { - switch (event.type) { - case "accepted": - case "user_message": - case "rebased": - case "projection_updated": - case "delivered": - case "claimed": - case "starting": - case "queued_in_gui": - case "started": - case "progress": - case "completed": - case "failed": - case "cancelled": - return true; - default: - return false; - } -} - -export function isTerminalChatControlEvent(event: ChatEvent) { - return ( - isChatControlEvent(event) && - (event.state === "completed" || event.state === "failed" || event.state === "cancelled") - ); -} - -export function isPreparingChatControlEvent(event: ChatEvent) { - return ( - isChatControlEvent(event) && - (event.state === "queued" || - event.state === "delivered" || - event.state === "claimed" || - event.state === "starting" || - event.state === "desktop_queued" || - event.type === "accepted" || - event.type === "rebased" || - event.type === "projection_updated" || - event.type === "delivered" || - event.type === "claimed" || - event.type === "starting" || - event.type === "queued_in_gui" || - event.type === "progress") - ); -} - -export function isRuntimeStartedChatControlEvent(event: ChatEvent) { - return ( - isChatControlEvent(event) && - (event.state === "running" || event.type === "started") - ); -} - function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/crates/agent-gateway/web/src/app/constants.ts b/crates/agent-gateway/web/src/app/constants.ts index 72a5e8d5..f78d092d 100644 --- a/crates/agent-gateway/web/src/app/constants.ts +++ b/crates/agent-gateway/web/src/app/constants.ts @@ -8,17 +8,10 @@ export const HISTORY_SWITCH_OVERLAY_MIN_MS = 260; export const PROJECT_HISTORY_DELETE_PAGE_SIZE = 200; export const SHARED_HISTORY_LIST_PAGE_SIZE = 200; export const HISTORY_TITLE_POSITION_LOCK_MS = 1200; -export const SECONDS_TIMESTAMP_MAX = 10_000_000_000; -export const DRAFT_HISTORY_ADOPTION_WINDOW_MS = 30_000; -export const LIVE_STREAM_HISTORY_REFRESH_SUPPRESS_MS = 30_000; -export const PAGE_RESTORE_HISTORY_REFRESH_THROTTLE_MS = 900; export const CHAT_RUNTIME_PREPARE_TIMEOUT_MS = 2_500; export const CHAT_RUNTIME_FOREGROUND_PREPARE_TIMEOUT_MS = 1_500; export const CHAT_RUNTIME_KEEP_WARM_INTERVAL_MS = 10_000; -export const CHAT_RUNTIME_STARTING_STATUS_DELAY_MS = 1_200; -export const CHAT_RUNTIME_READY_STATUS_TTL_MS = 20_000; export const CHAT_RUNTIME_PREPARING_STATUS = "Preparing request..."; -export const CHAT_RUNTIME_STARTING_STATUS = "Starting desktop runtime..."; export const DEFAULT_BROWSER_TITLE = "LiveAgent Gateway"; export const NEW_CONVERSATION_BROWSER_TITLE = "LiveAgent"; diff --git a/crates/agent-gateway/web/src/app/historyUtils.ts b/crates/agent-gateway/web/src/app/historyUtils.ts index bf149f48..f3913ba2 100644 --- a/crates/agent-gateway/web/src/app/historyUtils.ts +++ b/crates/agent-gateway/web/src/app/historyUtils.ts @@ -17,8 +17,6 @@ import { } from "@/lib/workspaceProjects"; import { MOBILE_SIDEBAR_MEDIA_QUERY } from "./constants"; -import { normalizeOptionalStatus } from "./chatEventUtils"; -import type { ConversationRuntimeEntry } from "./types"; export function formatTranslation( template: string, @@ -131,20 +129,6 @@ export function hasLocalDraftConversation(params: { ); } -export function createConversationRuntimeEntry( - input?: Partial, -): ConversationRuntimeEntry { - const toolStatus = normalizeOptionalStatus(input?.toolStatus); - return { - messages: input?.messages ?? [], - error: input?.error ?? null, - toolStatus, - toolStatusIsCompaction: toolStatus ? input?.toolStatusIsCompaction === true : false, - isSending: input?.isSending ?? false, - workdir: input?.workdir?.trim() || undefined, - }; -} - export function resolveVisibleConversationId( selectedHistoryId: string, conversationId: string, @@ -166,12 +150,3 @@ export function isMobileSidebarLayout() { export function shouldOpenSidebarByDefault() { return !isMobileSidebarLayout(); } - -export function hasDetachedHistorySelection( - selectedHistoryId: string, - conversationId: string, -) { - const selectedId = selectedHistoryId.trim(); - const activeConversationId = conversationId.trim(); - return selectedId !== "" && selectedId !== activeConversationId; -} diff --git a/crates/agent-gateway/web/src/app/types.ts b/crates/agent-gateway/web/src/app/types.ts index a754e482..1ff0bd19 100644 --- a/crates/agent-gateway/web/src/app/types.ts +++ b/crates/agent-gateway/web/src/app/types.ts @@ -1,6 +1,6 @@ +import type { ChatCommandOutcome } from "@/lib/chat/stream/chatCommandPipeline"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; -import type { ChatEntry } from "@/lib/chatUi"; import type { ChatRuntimeControls, CustomProvider } from "@/lib/settings"; export type ReloadHistoryOptions = { @@ -8,33 +8,10 @@ export type ReloadHistoryOptions = { hydrateSelection?: boolean; skipSelectionSync?: boolean; silent?: boolean; - adoptPendingDraftConversation?: boolean; }; export type OverlayState = "closed" | "entering" | "open" | "leaving"; -export type ConversationRuntimeEntry = { - messages: ChatEntry[]; - error: string | null; - toolStatus: string | null; - toolStatusIsCompaction: boolean; - isSending: boolean; - workdir?: string; -}; - -export type RunningConversationRuntime = { - runId?: string; - workdir?: string; - firstSeq?: number; - runEpoch?: number; - updatedAt: number; -}; - -export type PendingDraftConversationMigration = { - draftConversationId: string; - startedAt: number; -}; - export type SendChatOptions = { conversationId?: string; clientRequestId?: string; @@ -42,12 +19,13 @@ export type SendChatOptions = { runtimeControls?: ChatRuntimeControls; workdir?: string; editMessageRef?: HistoryMessageRef; - optimisticUserEntryId?: string; - skipOptimisticUserEntry?: boolean; queuePolicy?: "auto" | "append" | "interrupt"; }; -export type SendChatFn = (message: string, options?: SendChatOptions) => Promise; +export type SendChatFn = ( + message: string, + options?: SendChatOptions, +) => Promise; export type ModelProviderSource = Pick; diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index 59de7b09..a9d8fe1e 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -6,7 +6,6 @@ import { useMemo, useRef, useState, - useSyncExternalStore, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Check, CheckCircle2, ChevronDown, Copy, File, FileText, Loader2, Pencil, Settings, X } from "./icons"; @@ -47,43 +46,19 @@ import { import { buildTranscriptItems, - chatEntryDedupKey, type ChatEntry, type GatewayTranscriptItem, type GatewayTranscriptRound, } from "../lib/chatUi"; -function entryMatchesForDedup(a: ChatEntry, b: ChatEntry): boolean { - if (a.kind !== b.kind) return false; - return chatEntryDedupKey(a) === chatEntryDedupKey(b); -} - -function omitEquivalentTailEntries(existing: ChatEntry[], live: ChatEntry[]) { - if (live.length === 0) return existing; - const maxOverlap = Math.min(existing.length, live.length); - for (let overlap = maxOverlap; overlap > 0; overlap--) { - const start = existing.length - overlap; - let matches = true; - for (let i = 0; i < overlap; i++) { - if (!entryMatchesForDedup(existing[start + i], live[i])) { - matches = false; - break; - } - } - if (matches) return existing.slice(0, start); - } - return existing; -} -import type { - LiveConversationStreamSnapshot, - LiveConversationStreamStore, -} from "../lib/gatewayTypes"; import type { SectionId } from "../pages/settings/types"; type GatewayTranscriptProps = { conversationId?: string; + // Committed (history-backed) entries — rendered in the virtualized region. entries: ChatEntry[]; - liveStore?: LiveConversationStreamStore | null; - hasLiveStream?: boolean; + // Settled + live tail entries — rendered in the non-virtualized live + // region. Committed and tail are disjoint by construction. + tailEntries?: ChatEntry[]; error?: string | null; toolStatus?: string | null; toolStatusIsCompaction?: boolean; @@ -110,12 +85,16 @@ type GatewayTranscriptProps = { redactToolContent?: boolean; }; -const EMPTY_LIVE_SNAPSHOT: LiveConversationStreamSnapshot = { - revision: 0, - entries: [], - toolStatus: null, - toolStatusIsCompaction: false, -}; +// Live-born entries carry a `${runId}/` prefix on their ids (the transcript +// store namespaces every entry it builds from stream events). They keep +// Streamdown's streaming render mode forever — even after they fold into the +// virtualized committed region — so the streaming→static mode flip (and its +// full re-highlight reflow) can never happen. +function isLiveBornEntryId(id: string) { + return id.includes("/"); +} + +const EMPTY_TAIL_ENTRIES: ChatEntry[] = []; const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260; const TRANSCRIPT_ROW_GAP = 18; const TRANSCRIPT_ROW_OVERSCAN_COUNT = 5; @@ -128,14 +107,6 @@ function resolveNearestScrollViewport(element: HTMLElement | null) { return element?.closest("[data-radix-scroll-area-viewport]") as HTMLDivElement | null; } -function subscribeEmptyLiveStore() { - return () => {}; -} - -function getEmptyLiveSnapshot() { - return EMPTY_LIVE_SNAPSHOT; -} - function normalizeRoundsForRender( rounds: GatewayTranscriptRound[], isLive: boolean, @@ -1056,6 +1027,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { showUsage={showUsage} usageContextWindow={usageContextWindow} isLive={false} + renderMode={isLiveBornEntryId(item.id) ? "streaming" : "static"} readOnly={readOnly} redactToolContent={redactToolContent} /> @@ -1100,7 +1072,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { }); const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(props: { - liveSnapshot: LiveConversationStreamSnapshot; + tailEntries: ChatEntry[]; lastHistoryKind?: GatewayTranscriptItem["kind"]; isStreaming: boolean; isAgentMode: boolean; @@ -1113,7 +1085,7 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop toolStatusIsCompaction: boolean; }) { const { - liveSnapshot, + tailEntries, lastHistoryKind, isStreaming, isAgentMode, @@ -1126,10 +1098,7 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop toolStatusIsCompaction, } = props; const loadCommitDetails = useGatewayCommitDetailsLoader(workspaceRoot, gitClient); - const liveItems = useMemo( - () => buildTranscriptItems(liveSnapshot.entries), - [liveSnapshot.entries], - ); + const liveItems = useMemo(() => buildTranscriptItems(tailEntries), [tailEntries]); const activeLiveAssistantIndex = useMemo(() => { const lastItem = liveItems.at(-1); if (lastItem?.kind !== "assistant") { @@ -1138,13 +1107,12 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop return liveItems.length - 1; }, [liveItems]); const displayedToolStatus = useMemo( - () => normalizeLiveToolStatus(liveSnapshot.toolStatus ?? toolStatus ?? null), - [liveSnapshot.toolStatus, toolStatus], + () => normalizeLiveToolStatus(toolStatus ?? null), + [toolStatus], ); - const displayedToolStatusIsCompaction = - liveSnapshot.toolStatus !== null - ? liveSnapshot.toolStatusIsCompaction - : toolStatusIsCompaction; + const displayedToolStatusIsCompaction = toolStatusIsCompaction; + // The pending bubble (typing dots / vibing / compacting) shows while busy + // and the tail has no assistant-ish output yet. const shouldShowPendingLiveBubble = useMemo(() => { if (!isStreaming) { return false; @@ -1205,6 +1173,7 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop usageContextWindow={usageContextWindow} isLive={isLatestLiveAssistant} isStreaming={isLatestLiveStreaming} + renderMode="streaming" /> {shouldShowLiveStatus ? ( @@ -1276,8 +1245,7 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop export function GatewayTranscript({ conversationId, entries, - liveStore, - hasLiveStream = false, + tailEntries = EMPTY_TAIL_ENTRIES, error, toolStatus, toolStatusIsCompaction = false, @@ -1300,20 +1268,11 @@ export function GatewayTranscript({ redactToolContent = false, }: GatewayTranscriptProps) { const { t } = useLocale(); - const liveSnapshot = useSyncExternalStore( - liveStore?.subscribe ?? subscribeEmptyLiveStore, - liveStore?.getSnapshot ?? getEmptyLiveSnapshot, - liveStore?.getSnapshot ?? getEmptyLiveSnapshot, - ); - const historyEntries = useMemo( - () => omitEquivalentTailEntries(entries, liveSnapshot.entries), - [entries, liveSnapshot.entries], - ); - const historyItems = useMemo(() => buildTranscriptItems(historyEntries), [historyEntries]); + const historyItems = useMemo(() => buildTranscriptItems(entries), [entries]); const transcriptListRef = useRef(null); const [transcriptScrollViewport, setTranscriptScrollViewport] = useState(null); - const hasLiveEntries = liveSnapshot.entries.length > 0; + const hasLiveEntries = tailEntries.length > 0; const lastHistoryKind = historyItems.at(-1)?.kind; const inlineErrorText = error?.trim() ?? ""; const shouldShowInlineError = useMemo( @@ -1336,7 +1295,7 @@ export function GatewayTranscript({ return ; } - if (historyItems.length === 0 && !hasLiveEntries && !isStreaming && !hasLiveStream) { + if (historyItems.length === 0 && !hasLiveEntries && !isStreaming) { const showNoModelsState = !hasModels; return (
@@ -1415,7 +1374,7 @@ export function GatewayTranscript({ /> {!readOnly ? ( , }} - {...(useStreamingMode ? {} : { shikiTheme: ["github-light", "github-dark"] as const })} + shikiTheme={["github-light", "github-dark"] as const} controls={{ code: { copy: !readOnly, download: false }, mermaid: { copy: !readOnly, download: false, fullscreen: !readOnly, panZoom: !readOnly }, diff --git a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts new file mode 100644 index 00000000..201805de --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; +import type { ActivityStore } from "./activityStore"; +import type { ConversationStreamEvent, ConversationSubscribeResult } from "./streamTypes"; +import type { TranscriptSnapshot, TranscriptStore } from "./transcriptStore"; +import { createTranscriptStore } from "./transcriptStore"; + +// Registry of transcript stores, one per conversation. Stores persist across +// conversation switches so revisiting a conversation keeps its tail state; +// they are dropped when the conversation is deleted or re-keyed. +export type TranscriptStoreRegistry = { + get(conversationId: string): TranscriptStore; + peek(conversationId: string): TranscriptStore | null; + move(fromConversationId: string, toConversationId: string): void; + remove(conversationId: string): void; + clear(): void; +}; + +export function createTranscriptStoreRegistry(): TranscriptStoreRegistry { + const stores = new Map(); + return { + get(conversationId) { + let store = stores.get(conversationId); + if (!store) { + store = createTranscriptStore(); + stores.set(conversationId, store); + } + return store; + }, + peek(conversationId) { + return stores.get(conversationId) ?? null; + }, + move(fromConversationId, toConversationId) { + const store = stores.get(fromConversationId); + if (!store) { + return; + } + stores.delete(fromConversationId); + stores.set(toConversationId, store); + }, + remove(conversationId) { + stores.delete(conversationId); + }, + clear() { + stores.clear(); + }, + }; +} + +const EMPTY_TRANSCRIPT: TranscriptSnapshot = { + committed: [], + tail: [], + activeRun: null, + toolStatus: null, + toolStatusIsCompaction: false, + foldRevision: 0, + revision: 0, +}; + +export type ConversationChatBinding = { + transcript: TranscriptSnapshot; + // The conversation has an active run (from the transcript's own stream + // state — activityStore covers non-visible conversations). + busy: boolean; +}; + +// Binds the visible conversation to its transcript store and a persistent +// stream subscription. Subscribing eagerly — before any run exists — is what +// makes queue auto-sends race-free: the events just flow in. +export function useConversationChat(params: { + api: GatewayWebSocketClientLike | null; + conversationId: string | null; + registry: TranscriptStoreRegistry; + activityStore: ActivityStore; + isLocalDraft: (conversationId: string) => boolean; + // Extra chances for the app layer to observe stream traffic (titles, + // pending-command settlement, tunnel events, queue refreshes). + onStreamEvent?: (conversationId: string, event: ConversationStreamEvent) => void; + onStreamSync?: (conversationId: string, result: ConversationSubscribeResult) => void; + hasPendingCommand: (conversationId: string) => boolean; + pendingRevision: number; +}): ConversationChatBinding { + const { + api, + conversationId, + registry, + activityStore, + isLocalDraft, + onStreamEvent, + onStreamSync, + hasPendingCommand, + pendingRevision, + } = params; + + const onStreamEventRef = useRef(onStreamEvent); + onStreamEventRef.current = onStreamEvent; + const onStreamSyncRef = useRef(onStreamSync); + onStreamSyncRef.current = onStreamSync; + + const store = useMemo( + () => (conversationId ? registry.get(conversationId) : null), + [conversationId, registry], + ); + + useEffect(() => { + if (!api || !conversationId || !store || isLocalDraft(conversationId)) { + return; + } + const cleanup = api.subscribeConversationStream(conversationId, { + onSync: (result) => { + store.applySync(result); + onStreamSyncRef.current?.(conversationId, result); + }, + onEvent: (event) => { + store.applyEvent(event); + onStreamEventRef.current?.(conversationId, event); + }, + }); + return cleanup; + }, [api, conversationId, store, isLocalDraft]); + + const subscribeTranscript = useCallback( + (listener: () => void) => (store ? store.subscribe(listener) : () => {}), + [store], + ); + const getTranscript = useCallback( + () => (store ? store.getSnapshot() : EMPTY_TRANSCRIPT), + [store], + ); + const transcript = useSyncExternalStore(subscribeTranscript, getTranscript, getTranscript); + + const subscribeActivity = useCallback( + (listener: () => void) => activityStore.subscribe(listener), + [activityStore], + ); + const getActivityRevision = useCallback( + () => activityStore.getSnapshot().revision, + [activityStore], + ); + useSyncExternalStore(subscribeActivity, getActivityRevision, getActivityRevision); + + const busy = Boolean( + conversationId && + (transcript.activeRun !== null || + hasPendingCommand(conversationId) || + activityStore.isRunning(conversationId)), + ); + void pendingRevision; + + return { transcript, busy }; +} diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index 47466d2c..48c7cd35 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -1,4 +1,3 @@ -import type { ChatEntry } from "@/lib/chatUi"; import type { CodexRequestFormat, ChatRuntimeControls, @@ -251,12 +250,13 @@ export type HistoryList = { running_conversations?: RunningConversationSummary[]; }; +// history.list `running_conversations` items — the gateway's activity +// registry snapshot at response time. export type RunningConversationSummary = { conversation_id: string; run_id?: string; + state?: string; cwd?: string; - first_seq?: number; - run_epoch?: number; updated_at?: number; }; @@ -315,34 +315,4 @@ export type GatewayHistoryEvent = kind: "delete"; conversation_id: string; conversation?: undefined; - } - | { - kind: "running" | "idle"; - conversation_id: string; - conversation?: ConversationSummary; - run_id?: string; - first_seq?: number; - run_epoch?: number; - updated_at?: number; }; - -export type LiveConversationStreamSnapshot = { - revision: number; - entries: ChatEntry[]; - toolStatus: string | null; - toolStatusIsCompaction: boolean; -}; - -export type LiveConversationStreamStore = { - getSnapshot: () => LiveConversationStreamSnapshot; - subscribe: (listener: () => void) => () => void; - applySnapshot: (event: ChatRuntimeSnapshotEvent, options?: { flush?: boolean }) => void; - appendEvent: (event: ChatEvent, options?: { flush?: boolean }) => void; - setToolStatus: ( - toolStatus: string | null | undefined, - isCompaction?: boolean, - options?: { flush?: boolean }, - ) => void; - reset: () => void; - flush: () => void; -}; diff --git a/crates/agent-gateway/web/src/lib/historySync.ts b/crates/agent-gateway/web/src/lib/historySync.ts index ee45c68f..6f2803f2 100644 --- a/crates/agent-gateway/web/src/lib/historySync.ts +++ b/crates/agent-gateway/web/src/lib/historySync.ts @@ -1,8 +1,4 @@ -import type { - ConversationSummary, - GatewayHistoryEvent, - RunningConversationSummary, -} from "./gatewayTypes"; +import type { ConversationSummary, GatewayHistoryEvent } from "./gatewayTypes"; type ReconcileConversationSummariesOptions = { retainConversationIds?: Iterable; @@ -145,78 +141,6 @@ export function sortConversationSummaries( }); } -function normalizeRunningConversationSummary( - value: unknown, -): RunningConversationSummary | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - const source = value as Record; - const conversationId = - typeof source.conversation_id === "string" ? source.conversation_id.trim() : ""; - if (!conversationId) { - return null; - } - const cwd = typeof source.cwd === "string" ? source.cwd.trim() : ""; - const runId = typeof source.run_id === "string" ? source.run_id.trim() : ""; - if (!runId) { - return null; - } - const firstSeq = - typeof source.first_seq === "number" && - Number.isFinite(source.first_seq) && - source.first_seq > 0 - ? Math.floor(source.first_seq) - : undefined; - const runEpoch = - typeof source.run_epoch === "number" && - Number.isFinite(source.run_epoch) && - source.run_epoch > 0 - ? Math.floor(source.run_epoch) - : undefined; - const updatedAt = - typeof source.updated_at === "number" && Number.isFinite(source.updated_at) - ? source.updated_at - : undefined; - return { - conversation_id: conversationId, - run_id: runId || undefined, - cwd: cwd || undefined, - first_seq: firstSeq, - run_epoch: runEpoch, - updated_at: updatedAt, - }; -} - -export function normalizeRunningConversations( - conversations: readonly unknown[] | undefined, -) { - const seen = new Set(); - const normalized: RunningConversationSummary[] = []; - for (const value of conversations ?? []) { - const summary = normalizeRunningConversationSummary(value); - if (!summary || seen.has(summary.conversation_id)) { - continue; - } - seen.add(summary.conversation_id); - normalized.push(summary); - } - return normalized; -} - -export function resolveRunningConversationStreamAfterSeq( - firstSeq: unknown, - options?: { runId?: unknown }, -) { - if (typeof options?.runId === "string" && options.runId.trim()) { - return 0; - } - if (typeof firstSeq !== "number" || !Number.isFinite(firstSeq) || firstSeq <= 1) { - return 0; - } - return Math.floor(firstSeq) - 1; -} - export function upsertConversationSummary( conversations: ConversationSummary[], nextConversation: ConversationSummary, @@ -290,9 +214,6 @@ export function applyGatewayHistoryEvent( switch (event.kind) { case "delete": return conversations.filter((item) => item.id !== event.conversation_id); - case "running": - case "idle": - return conversations; case "upsert": { const conversationId = event.conversation_id.trim(); const preserveTitleConversationIds = new Set( diff --git a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx index 84c68855..04a05647 100644 --- a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx +++ b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx @@ -306,27 +306,6 @@ function updateHistoryListWithEvent(history: HistoryList | null, event: any): Hi }; } - if (event.kind === "running" || event.kind === "idle") { - const runId = typeof event.run_id === "string" ? event.run_id.trim() : ""; - const runningConversations = (history.running_conversations ?? []).filter( - (item) => item.conversation_id !== conversationId, - ); - if (event.kind === "running" && runId) { - runningConversations.push({ - conversation_id: conversationId, - run_id: runId, - cwd: typeof event.conversation?.cwd === "string" ? event.conversation.cwd : undefined, - first_seq: typeof event.first_seq === "number" ? event.first_seq : undefined, - run_epoch: typeof event.run_epoch === "number" ? event.run_epoch : undefined, - updated_at: typeof event.updated_at === "number" ? event.updated_at : undefined, - }); - } - return { - ...history, - running_conversations: runningConversations, - }; - } - const conversation = event.conversation as ConversationSummary | undefined; if (event.kind !== "upsert" || !conversation?.id) { return history; diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index 9570b19e..10e1be17 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -3186,6 +3186,7 @@ function RoundContent(props: { toolStatusVariant?: "default" | "compaction"; runningToolCallIds?: string[]; thinkingOpen?: boolean; + renderMode?: "streaming" | "static"; readOnly?: boolean; redactToolContent?: boolean; }) { @@ -3201,6 +3202,7 @@ function RoundContent(props: { toolStatusVariant, runningToolCallIds, thinkingOpen, + renderMode, readOnly = false, redactToolContent = false, } = @@ -3328,6 +3330,7 @@ function RoundContent(props: { content={block.text} className="font-openai-chat" isAnimating={Boolean(isLive && isActive)} + renderMode={renderMode} showCaret={Boolean(isLive && isActive && isStreaming)} readOnly={readOnly} /> @@ -3354,6 +3357,10 @@ export function AssistantBubble(props: { // tool indicators, streaming mode) stays intact and the article does not // re-render in static mode. isStreaming?: boolean; + // Fixed Streamdown render mode for every round in this bubble: live-born + // entries keep "streaming" forever (even after they fold into committed + // history), history-born entries render "static". Never flips per entry. + renderMode?: "streaming" | "static"; toolStatus?: string | null; toolStatusVariant?: "default" | "compaction"; readOnly?: boolean; @@ -3365,6 +3372,7 @@ export function AssistantBubble(props: { usageContextWindow, isLive, isStreaming = isLive, + renderMode, toolStatus, toolStatusVariant, readOnly = false, @@ -3386,6 +3394,7 @@ export function AssistantBubble(props: { isLive={isLive} isStreaming={isStreaming} isActive={isLive && idx === rounds.length - 1} + renderMode={renderMode} toolStatus={idx === rounds.length - 1 ? toolStatus : null} toolStatusVariant={idx === rounds.length - 1 ? toolStatusVariant : "default"} runningToolCallIds={round.runningToolCallIds ?? []} diff --git a/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts b/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts index 8b388848..381064ac 100644 --- a/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts +++ b/crates/agent-gateway/web/src/pages/chat/queue/chatTurnQueue.ts @@ -1,42 +1,5 @@ import type { MentionComposerDraft } from "@/components/chat/MentionComposer"; import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; -import type { ChatRuntimeControls } from "@/lib/settings"; -import { fileMentionDisplayName } from "@/lib/chat/mentionReferences"; - -export type QueuedChatTurn = { - id: string; - conversationId: string; - draft: MentionComposerDraft; - uploadedFiles: PendingUploadedFile[]; - workdir: string; - runtimeControls: ChatRuntimeControls; - createdAt: number; -}; - -export type QueuedChatTurnInput = Omit & { - createdAt?: number; - id?: string; -}; - -export type QueuedChatTurnEditSlot = { - conversationId: string; - previousId: string | null; - nextId: string | null; - index?: number; -}; - -export function createQueuedChatTurn(input: QueuedChatTurnInput): QueuedChatTurn { - const createdAt = input.createdAt ?? Date.now(); - return { - id: input.id?.trim() || `queued-chat-${createdAt}-${Math.random().toString(36).slice(2, 8)}`, - conversationId: input.conversationId.trim(), - draft: input.draft, - uploadedFiles: input.uploadedFiles.slice(), - workdir: input.workdir.trim(), - runtimeControls: { ...input.runtimeControls }, - createdAt, - }; -} export function queuedChatTurnHasContent( draft: MentionComposerDraft | null | undefined, @@ -44,149 +7,3 @@ export function queuedChatTurnHasContent( ): draft is MentionComposerDraft { return Boolean(draft && (!draft.isEmpty || draft.text.trim() || uploadedFiles.length > 0)); } - -export function buildQueuedChatTurnPreview(draft: MentionComposerDraft) { - const parts = draft.segments.map((segment) => { - switch (segment.type) { - case "largePaste": - return segment.paste.label; - case "fileMention": - return fileMentionDisplayName(segment.reference); - case "skillMention": - return `$${segment.skill.name}`; - case "commitMention": - return segment.commit.subject || segment.commit.shortSha || segment.commit.sha; - case "gitFileMention": - return segment.file.path; - case "text": - return segment.text; - } - return ""; - }); - return parts.join("").replace(/\s+/g, " ").trim() || draft.text.replace(/\s+/g, " ").trim(); -} - -function withoutTurn(queue: readonly QueuedChatTurn[], id: string) { - const key = id.trim(); - return queue.filter((item) => item.id !== key); -} - -export function appendQueuedChatTurn( - queue: readonly QueuedChatTurn[], - item: QueuedChatTurn, -): QueuedChatTurn[] { - return [...withoutTurn(queue, item.id), item]; -} - -export function prependQueuedChatTurn( - queue: readonly QueuedChatTurn[], - item: QueuedChatTurn, -): QueuedChatTurn[] { - return [item, ...withoutTurn(queue, item.id)]; -} - -export function resolveQueuedChatTurnSlotIndex( - queue: readonly QueuedChatTurn[], - slot: QueuedChatTurnEditSlot, -) { - const compactQueue = queue.slice(); - const nextIndex = slot.nextId ? compactQueue.findIndex((item) => item.id === slot.nextId) : -1; - if (nextIndex >= 0) return nextIndex; - - const previousIndex = slot.previousId - ? compactQueue.findIndex((item) => item.id === slot.previousId) - : -1; - if (previousIndex >= 0) return previousIndex + 1; - - if (Number.isInteger(slot.index) && slot.index !== undefined && slot.index >= 0) { - let scopedIndex = 0; - let lastConversationIndex = -1; - for (let index = 0; index < compactQueue.length; index += 1) { - if (compactQueue[index]?.conversationId !== slot.conversationId) continue; - if (scopedIndex >= slot.index) return index; - scopedIndex += 1; - lastConversationIndex = index; - } - if (lastConversationIndex >= 0) return lastConversationIndex + 1; - } - - const firstConversationIndex = compactQueue.findIndex( - (item) => item.conversationId === slot.conversationId, - ); - if (firstConversationIndex >= 0) return firstConversationIndex; - return compactQueue.length; -} - -export function insertQueuedChatTurnAtSlot( - queue: readonly QueuedChatTurn[], - item: QueuedChatTurn, - slot: QueuedChatTurnEditSlot, -): QueuedChatTurn[] { - const next = withoutTurn(queue, item.id); - const index = resolveQueuedChatTurnSlotIndex(next, slot); - return [...next.slice(0, index), item, ...next.slice(index)]; -} - -export function removeQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, -): QueuedChatTurn[] { - return withoutTurn(queue, id); -} - -export function removeQueuedChatTurnsForConversation( - queue: readonly QueuedChatTurn[], - conversationId: string, -): QueuedChatTurn[] { - const key = conversationId.trim(); - if (!key) return queue.slice(); - return queue.filter((item) => item.conversationId !== key); -} - -export function moveQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, - direction: "up" | "down", -): QueuedChatTurn[] { - const key = id.trim(); - const index = queue.findIndex((item) => item.id === key); - if (index < 0) return queue.slice(); - const item = queue[index]; - let swapIndex = index; - while (true) { - swapIndex = direction === "up" ? swapIndex - 1 : swapIndex + 1; - if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); - if (queue[swapIndex]?.conversationId === item?.conversationId) break; - } - if (swapIndex < 0 || swapIndex >= queue.length) return queue.slice(); - const next = queue.slice(); - [next[index], next[swapIndex]] = [next[swapIndex], next[index]]; - return next; -} - -export function promoteQueuedChatTurn( - queue: readonly QueuedChatTurn[], - id: string, -): QueuedChatTurn[] { - const key = id.trim(); - const item = queue.find((candidate) => candidate.id === key); - if (!item) return queue.slice(); - return prependQueuedChatTurn(queue, item); -} - -export function takeNextQueuedChatTurn( - queue: readonly QueuedChatTurn[], - conversationId: string, -): { item: QueuedChatTurn | null; queue: QueuedChatTurn[] } { - const key = conversationId.trim(); - if (!key) return { item: null, queue: queue.slice() }; - const index = queue.findIndex((item) => item.conversationId === key); - if (index < 0) return { item: null, queue: queue.slice() }; - const next = queue.slice(); - const [item] = next.splice(index, 1); - return { item: item ?? null, queue: next }; -} - -export function getQueuedConversationIds(queue: readonly QueuedChatTurn[]) { - return Array.from(new Set(queue.map((item) => item.conversationId).filter(Boolean))); -} From 872d208236c5124afc45a78605c1fec527b65d7a Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 13:10:17 +0800 Subject: [PATCH 19/64] chore(chat): sweep dead control-event vocabulary Remove chatwire.ControlPayload/IsTerminalControl (the ingress no longer forwards raw controls), collapse the webui ChatEvent union's control and runtime_snapshot variants into the two data events that still exist on the wire (user_message, rebased), and drop the dead "failed" branch from pushChatEvent. Co-Authored-By: Claude Fable 5 --- .../internal/chatwire/payloads.go | 42 --------------- crates/agent-gateway/web/src/lib/chatUi.ts | 9 ++-- .../agent-gateway/web/src/lib/gatewayTypes.ts | 54 +++---------------- 3 files changed, 11 insertions(+), 94 deletions(-) diff --git a/crates/agent-gateway/internal/chatwire/payloads.go b/crates/agent-gateway/internal/chatwire/payloads.go index 291e37cd..0f928783 100644 --- a/crates/agent-gateway/internal/chatwire/payloads.go +++ b/crates/agent-gateway/internal/chatwire/payloads.go @@ -10,48 +10,6 @@ import ( gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -// IsTerminalControl reports whether a control event carries a terminal run state. -func IsTerminalControl(control *gatewayv1.ChatControlEvent) bool { - switch strings.TrimSpace(control.GetState()) { - case "completed", "failed", "cancelled": - return true - default: - return false - } -} - -// ControlPayload shapes a ChatControlEvent into a wire payload. -func ControlPayload( - control *gatewayv1.ChatControlEvent, - seq int64, - workdirInput ...string, -) map[string]any { - payload := map[string]any{ - "type": strings.TrimSpace(control.GetType()), - "client_request_id": strings.TrimSpace(control.GetClientRequestId()), - "conversation_id": strings.TrimSpace(control.GetConversationId()), - "run_epoch": control.GetRunEpoch(), - "state": strings.TrimSpace(control.GetState()), - } - if seq > 0 { - payload["seq"] = seq - } else if control.GetSeq() > 0 { - payload["seq"] = control.GetSeq() - } - if errorCode := strings.TrimSpace(control.GetErrorCode()); errorCode != "" { - payload["error_code"] = errorCode - } - if message := strings.TrimSpace(control.GetMessage()); message != "" { - payload["message"] = message - } - if len(workdirInput) > 0 { - if workdir := strings.TrimSpace(workdirInput[0]); workdir != "" { - payload["workdir"] = workdir - } - } - return payload -} - // EventPayload shapes a ChatEvent into a wire payload, decoding the JSON data // blob and trimming oversized tool content. func EventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { diff --git a/crates/agent-gateway/web/src/lib/chatUi.ts b/crates/agent-gateway/web/src/lib/chatUi.ts index 8cf2545c..675d9254 100644 --- a/crates/agent-gateway/web/src/lib/chatUi.ts +++ b/crates/agent-gateway/web/src/lib/chatUi.ts @@ -1586,13 +1586,10 @@ export function pushChatEvent( return enrichTailHostedSearchEntriesWithText(next); } - if (event.type === "error" || event.type === "failed") { - const round = event.type === "error" ? readRound(event.round) : undefined; + if (event.type === "error") { + const round = readRound(event.round); const rawMessage = event.message ?? ""; - const message = formatLiveErrorMessage( - rawMessage.trim() || "Request failed", - event.type === "failed", - ); + const message = formatLiveErrorMessage(rawMessage.trim() || "Request failed", false); if (isAbortLikeError(message)) { return entries; } diff --git a/crates/agent-gateway/web/src/lib/gatewayTypes.ts b/crates/agent-gateway/web/src/lib/gatewayTypes.ts index 48c7cd35..3210d3c8 100644 --- a/crates/agent-gateway/web/src/lib/gatewayTypes.ts +++ b/crates/agent-gateway/web/src/lib/gatewayTypes.ts @@ -58,59 +58,21 @@ export type ChatCheckpointPayload = { }; }; -export type ChatRunControlState = - | "queued" - | "delivered" - | "claimed" - | "starting" - | "desktop_queued" - | "running" - | "completed" - | "failed" - | "cancelled"; - -export type ChatControlEvent = { - type: - | "accepted" - | "user_message" - | "rebased" - | "projection_updated" - | "delivered" - | "claimed" - | "starting" - | "queued_in_gui" - | "started" - | "progress" - | "completed" - | "failed" - | "cancelled"; +export type ChatUserMessageEvent = { + type: "user_message"; client_request_id?: string; conversation_id?: string; - run_epoch?: number; - state?: ChatRunControlState; - error_code?: string; message?: string; uploaded_files?: unknown; base_message_ref?: unknown; reason?: string; - seq?: number; - workdir?: string; }; -export type ChatRuntimeSnapshotEvent = { - type: "runtime_snapshot"; +export type ChatRebasedEvent = { + type: "rebased"; conversation_id?: string; - run_id?: string; - client_request_id?: string; - worker_id?: string; - state?: ChatRunControlState; - updated_at?: number; - revision?: number; - entries_json?: string; - tool_status?: string | null; - tool_status_is_compaction?: boolean; - seq?: number; - workdir?: string; + base_message_ref?: unknown; + reason?: string; }; export type ChatEvent = ( @@ -180,8 +142,8 @@ export type ChatEvent = ( conversation_id?: string; } | { type: "error"; message: string; round?: number; conversation_id?: string } - | ChatRuntimeSnapshotEvent - | ChatControlEvent + | ChatUserMessageEvent + | ChatRebasedEvent ) & { seq?: number; workdir?: string }; export type CronManagePayload = { From 0c4d28c951c9f039e588b8e254e2e7a939746fae Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 14:18:36 +0800 Subject: [PATCH 20/64] fix(chat): harden stream pipeline against adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gateway: - runtime snapshots carry as_of_seq (the log seq they represent through) so clients rebuilding from a snapshot drop the overlapping buffered replay instead of double-applying it - chat.activity events carry client_request_id, letting the command pipeline settle pendings from the always-on activity channel (no spurious startup timeout after switching conversations away) - token-recreated streams after a gateway restart mark runNeedsSnapshot so late joiners hydrate instead of getting a silently incomplete replay - the reaper spares silent runs while the runtime heartbeat reports busy (a 12-minute quiet tool call is no longer force-failed) and can now also reap orphaned queued activities; the runtime-idle heuristic that could kill GUI-local runs is gone - chat.subscribe/unsubscribe execute inline on the read loop so a re-subscribe's frame order can never let the stale unsubscribe cancel the fresh subscription (StrictMode double-effect) - WatchChatCommand cleanup closes the channel (one goroutine no longer leaks per submitted command); activity replay channels are sized to the replay (blocking send under both store mutexes could deadlock all chat ingest) - supersession-started runs keep their seeded user_message inside the eviction-protected window WebUI: - transcript stores gain an idempotency seq cursor: re-subscribe replays and snapshot+replay overlaps no longer duplicate bubbles or reply text; resets zero the cursor and fold (not drop) the settled tail - run_finished respects run ownership: stray terminals for non-active runs never settle the streaming tail, and the settle sweep leaves other runs' seeded/optimistic entries live - pipeline settlement is strict-identity (runId or own client_request_id) — foreign runs can no longer disarm the startup watchdog - activityStore hydration is an ordered merge and re-hydrates after an offline/online transition (no phantom running dots) - second send while pending parks into the GUI queue instead of erroring; quiet refresh can no longer truncate long transcripts; history merges upgrade tail entries in place so the latest exchange stays edit-resendable; parked/failed edit_resends restore the optimistically truncated transcript Co-Authored-By: Claude Fable 5 --- .../internal/server/websocket.go | 10 + .../internal/server/websocket_payloads.go | 4 + .../internal/session/activity_hub.go | 19 +- .../internal/session/conversation_ingress.go | 11 + .../internal/session/conversation_stream.go | 109 +++++--- .../session/conversation_stream_test.go | 73 +++++ .../test/webui/chat-command-pipeline.test.mjs | 67 +++++ .../webui/conversation-stream-client.test.mjs | 30 ++ .../test/webui/gateway-socket-client.test.mjs | 2 + .../agent-gateway/web/src/app/GatewayApp.tsx | 134 ++++++++- .../web/src/lib/chat/stream/activityStore.ts | 57 +++- .../lib/chat/stream/chatCommandPipeline.ts | 32 ++- .../chat/stream/conversationStreamClient.ts | 4 + .../web/src/lib/chat/stream/streamTypes.ts | 9 + .../src/lib/chat/stream/transcriptStore.ts | 138 +++++++-- .../web/test/activity-store.test.mjs | 61 +++- .../web/test/transcript-store.test.mjs | 263 ++++++++++++++++++ 17 files changed, 926 insertions(+), 97 deletions(-) diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index ced53a09..1e922b31 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -213,6 +213,16 @@ func (c *websocketConnection) serve() { continue } + // Subscription lifecycle must keep the client's frame order: a + // re-subscribe emits [unsubscribe, subscribe] back to back, and + // concurrent dispatch could let the stale unsubscribe cancel the fresh + // subscription. Both handlers are lock-only and non-blocking, so they + // run inline on the read loop. + if req.Type == "chat.subscribe" || req.Type == "chat.unsubscribe" { + c.dispatch(req) + continue + } + go c.dispatch(req) } } diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index 340d999c..982431ca 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -129,6 +129,9 @@ func websocketChatActivityPayload(event session.ConversationActivityEvent) map[s if event.RunID != "" { payload["run_id"] = event.RunID } + if event.ClientRequestID != "" { + payload["client_request_id"] = event.ClientRequestID + } if event.State != "" { payload["state"] = event.State } @@ -168,6 +171,7 @@ func websocketRunSnapshotPayload(snapshot *session.RunSnapshot) map[string]any { "entries_json": snapshot.EntriesJSON, "tool_status": snapshot.ToolStatus, "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, + "as_of_seq": snapshot.AsOfSeq, } } diff --git a/crates/agent-gateway/internal/session/activity_hub.go b/crates/agent-gateway/internal/session/activity_hub.go index 80f826c3..1750cbaa 100644 --- a/crates/agent-gateway/internal/session/activity_hub.go +++ b/crates/agent-gateway/internal/session/activity_hub.go @@ -24,10 +24,11 @@ func newChatActivityHub() *chatActivityHub { // no separate hydration round-trip. func (m *Manager) SubscribeChatActivity() (<-chan ConversationActivityEvent, func()) { hub := m.convStreams.activityHub - ch := make(chan ConversationActivityEvent, 64) // Replay current activities before registering so a concurrent transition - // is delivered after its predecessor state, never before. + // is delivered after its predecessor state, never before. The channel is + // sized to hold the whole replay: nothing reads it until this returns, so + // a blocking send here would wedge both mutexes. m.convStreams.mu.Lock() replay := make([]ConversationActivityEvent, 0, len(m.convStreams.streams)) for _, stream := range m.convStreams.streams { @@ -35,18 +36,20 @@ func (m *Manager) SubscribeChatActivity() (<-chan ConversationActivityEvent, fun continue } event := ConversationActivityEvent{ - ConversationID: stream.conversationID, - RunID: stream.activity.RunID, - Running: true, - State: stream.activity.State, - Workdir: stream.activity.Workdir, - UpdatedAt: stream.activity.UpdatedAt, + ConversationID: stream.conversationID, + RunID: stream.activity.RunID, + ClientRequestID: stream.activity.ClientRequestID, + Running: true, + State: stream.activity.State, + Workdir: stream.activity.Workdir, + UpdatedAt: stream.activity.UpdatedAt, } if event.Workdir == "" { event.Workdir = stream.workdir } replay = append(replay, event) } + ch := make(chan ConversationActivityEvent, len(replay)+64) hub.mu.Lock() subID := hub.nextSubID hub.nextSubID++ diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go index 43dde295..0b9405e9 100644 --- a/crates/agent-gateway/internal/session/conversation_ingress.go +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -32,6 +32,9 @@ func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) if conversationID == "" { return } + existingStream := s.streams[conversationID] + streamWasUnknown := existingStream == nil || + (existingStream.lastSeq == 0 && existingStream.activity == nil) stream := s.streamLocked(conversationID, now) s.noteAgentEpochLocked(stream, epoch) @@ -74,6 +77,12 @@ func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) // supersession bookkeeping); do not attribute events to another run. return } + if streamWasUnknown && event.GetType() != gatewayv1.ChatEvent_USER_MESSAGE { + // A mid-run delta recreated this stream (gateway restarted while the + // run was streaming): the run's earlier events are unrecoverable from + // the log, so late joiners must hydrate from the runtime snapshot. + stream.runNeedsSnapshot = true + } if event.GetType() == gatewayv1.ChatEvent_TOOL_STATUS { status, _ := payload["status"].(string) @@ -209,6 +218,7 @@ func (m *Manager) ingestRuntimeSnapshot(snapshot *gatewayv1.ChatRuntimeSnapshot) ToolStatus: strings.TrimSpace(snapshot.GetToolStatus()), ToolStatusIsCompaction: snapshot.GetToolStatusIsCompaction(), Workdir: strings.TrimSpace(snapshot.GetCwd()), + AsOfSeq: stream.lastSeq, UpdatedAt: now, } if current := stream.latestSnapshot; current != nil && @@ -261,6 +271,7 @@ func (s *conversationStreamStore) publishSnapshotLocked( "entries_json": snapshot.EntriesJSON, "tool_status": snapshot.ToolStatus, "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, + "as_of_seq": snapshot.AsOfSeq, } event := &ConversationEvent{ ConversationID: stream.conversationID, diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go index 3b6dedd7..f0c03a55 100644 --- a/crates/agent-gateway/internal/session/conversation_stream.go +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -29,7 +29,6 @@ const ( conversationMaxEventBytes = 8 << 20 conversationIdleRetention = 30 * time.Minute conversationStaleRunTimeout = 10 * time.Minute - conversationRuntimeIdleGrace = 30 * time.Second conversationReaperInterval = time.Minute conversationFinishedRunMemory = 8 conversationSubscriberBuffer = 256 @@ -74,7 +73,12 @@ type RunSnapshot struct { ToolStatus string ToolStatusIsCompaction bool Workdir string - UpdatedAt time.Time + // AsOfSeq is the conversation's last log seq when this snapshot was + // ingested: the snapshot already represents every event up to and + // including it, so clients rebuilding from the snapshot must only apply + // replayed events with a higher seq. + AsOfSeq int64 + UpdatedAt time.Time } // ConversationEvent is one entry of a conversation log. Payload is the final @@ -93,12 +97,13 @@ type ConversationEvent struct { // ConversationActivityEvent is the broadcast shape for the chat.activity hub. type ConversationActivityEvent struct { - ConversationID string - RunID string - Running bool - State string - Workdir string - UpdatedAt time.Time + ConversationID string + RunID string + ClientRequestID string + Running bool + State string + Workdir string + UpdatedAt time.Time } // ChatCommandUpdate notifies the connection that issued a chat command about @@ -148,6 +153,10 @@ type chatRunRecord struct { // accept time; the agent's later USER_MESSAGE echo is swallowed so the // message appears exactly once. userMessageSeeded bool + // firstSeededSeq is the seq of the run's first gateway-seeded event so a + // run started via supersession still protects its seeded user_message + // from retention eviction. + firstSeededSeq int64 // queuedInGUI marks commands the desktop app parked in its prompt queue; // the startup watchdog must leave them alone. queuedInGUI bool @@ -171,19 +180,20 @@ type conversationStreamStore struct { activityHub *chatActivityHub - runtimeIdleSince time.Time + // lastRuntimeBusyAt is the last time the agent reported a non-zero + // active-run count; while it stays fresh, silent runs are never reaped. + lastRuntimeBusyAt time.Time reaperOnce sync.Once isOnline func() bool // tunable in tests - eventRetention time.Duration - maxEvents int - maxEventBytes int - idleRetention time.Duration - staleRunTimeout time.Duration - runtimeIdleGrace time.Duration - reaperInterval time.Duration + eventRetention time.Duration + maxEvents int + maxEventBytes int + idleRetention time.Duration + staleRunTimeout time.Duration + reaperInterval time.Duration } func newConversationStreamStore(isOnline func() bool) *conversationStreamStore { @@ -192,15 +202,14 @@ func newConversationStreamStore(isOnline func() bool) *conversationStreamStore { pendingRuns: make(map[string]*pendingChatRun), runs: make(map[string]*chatRunRecord), commandWatchers: make(map[string][]chan ChatCommandUpdate), - activityHub: newChatActivityHub(), - isOnline: isOnline, - eventRetention: conversationEventRetention, - maxEvents: conversationMaxEvents, - maxEventBytes: conversationMaxEventBytes, - idleRetention: conversationIdleRetention, - staleRunTimeout: conversationStaleRunTimeout, - runtimeIdleGrace: conversationRuntimeIdleGrace, - reaperInterval: conversationReaperInterval, + activityHub: newChatActivityHub(), + isOnline: isOnline, + eventRetention: conversationEventRetention, + maxEvents: conversationMaxEvents, + maxEventBytes: conversationMaxEventBytes, + idleRetention: conversationIdleRetention, + staleRunTimeout: conversationStaleRunTimeout, + reaperInterval: conversationReaperInterval, } } @@ -536,12 +545,19 @@ func (s *conversationStreamStore) runStartedLocked( payload["workdir"] = stream.workdir } startEvent := s.appendEventLocked(stream, runID, StreamEventRunStarted, payload, now) + startedSeq := startEvent.Seq + if record.firstSeededSeq > 0 && record.firstSeededSeq < startedSeq { + // The run's user_message was seeded before it started (e.g. it + // started through supersession while another run was active); the + // eviction guard must cover the seed too. + startedSeq = record.firstSeededSeq + } stream.activity = &RunActivity{ ConversationID: stream.conversationID, RunID: runID, ClientRequestID: record.clientRequestID, State: RunActivityRunning, - StartedSeq: startEvent.Seq, + StartedSeq: startedSeq, Workdir: stream.workdir, UpdatedAt: now, } @@ -651,6 +667,7 @@ func (s *conversationStreamStore) publishActivityLocked(stream *conversationStre } if stream.activity != nil { event.RunID = stream.activity.RunID + event.ClientRequestID = stream.activity.ClientRequestID event.Running = true event.State = stream.activity.State if stream.activity.Workdir != "" { @@ -680,6 +697,9 @@ func (m *Manager) WatchChatCommand(runID string) (<-chan ChatCommandUpdate, func for i, watcher := range watchers { if watcher == ch { s.commandWatchers[runID] = append(watchers[:i], watchers[i+1:]...) + // All sends happen under s.mu after a registration check, so + // closing here is safe and releases the forwarder goroutine. + close(ch) break } } @@ -782,6 +802,9 @@ func (s *conversationStreamStore) appendSeededPayloadsLocked( } event := s.appendEventLocked(stream, runID, eventType, cloned, now) acceptedSeq = event.Seq + if record := s.runs[runID]; record != nil && record.firstSeededSeq == 0 { + record.firstSeededSeq = event.Seq + } } return acceptedSeq } @@ -905,17 +928,13 @@ func (m *Manager) ForceFinishRun(runID string, status string, errorCode string, // --- maintenance ----------------------------------------------------------- // onRuntimeStatus feeds the agent's reported active-run count into staleness -// tracking: a runtime that keeps reporting zero active runs while a stream -// still shows one means the run died without a terminal signal. +// tracking: while the runtime keeps reporting busy, silent runs (a long tool +// call producing no events) must never be reaped. func (s *conversationStreamStore) onRuntimeStatus(activeRunCount uint32, now time.Time) { s.mu.Lock() defer s.mu.Unlock() if activeRunCount > 0 { - s.runtimeIdleSince = time.Time{} - return - } - if s.runtimeIdleSince.IsZero() { - s.runtimeIdleSince = now + s.lastRuntimeBusyAt = now } } @@ -940,23 +959,23 @@ func (s *conversationStreamStore) reap(now time.Time) { defer s.mu.Unlock() online := s.isOnline != nil && s.isOnline() - runtimeIdleLongEnough := online && - !s.runtimeIdleSince.IsZero() && - now.Sub(s.runtimeIdleSince) > s.runtimeIdleGrace for conversationID, stream := range s.streams { s.evictStreamLocked(stream, now) if stream.activity != nil && online { - staleBySilence := !stream.lastEventAt.IsZero() && - now.Sub(stream.lastEventAt) > s.staleRunTimeout - activityIsLive := stream.activity.State == RunActivityRunning || - stream.activity.State == RunActivityCancelling - staleByRuntimeIdle := runtimeIdleLongEnough && - activityIsLive && - stream.activity.UpdatedAt.Before(s.runtimeIdleSince) && - stream.lastEventAt.Before(s.runtimeIdleSince) - if staleBySilence || staleByRuntimeIdle { + // A run is stale only when NOTHING vouches for it: no stream + // events, no runtime-busy heartbeat, and no activity transition + // within the timeout. A silent long tool call keeps the runtime + // busy heartbeat fresh and is never reaped. + lastAlive := stream.lastEventAt + if s.lastRuntimeBusyAt.After(lastAlive) { + lastAlive = s.lastRuntimeBusyAt + } + if stream.activity.UpdatedAt.After(lastAlive) { + lastAlive = stream.activity.UpdatedAt + } + if !lastAlive.IsZero() && now.Sub(lastAlive) > s.staleRunTimeout { s.runFinishedLocked(stream, stream.activity.RunID, "failed", "stale_run", "The desktop runtime stopped reporting this run.", nil, now) } diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go index 8ce7f60f..0438a02a 100644 --- a/crates/agent-gateway/internal/session/conversation_stream_test.go +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -611,3 +611,76 @@ func TestSubscriberOverflowClosesAndResumes(t *testing.T) { t.Fatalf("lost events across overflow: saw %d", total) } } + +func TestReaperSparesSilentRunsWhileRuntimeReportsBusy(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = 10 * time.Millisecond + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(20 * time.Millisecond) + + // A silent long tool call: no events, but the runtime heartbeat says busy. + m.convStreams.onRuntimeStatus(1, time.Now()) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("busy runtime must spare the silent run, activities=%d", len(activities)) + } + + // Once the runtime stops vouching for it, the run is reaped after the + // timeout elapses again. + time.Sleep(20 * time.Millisecond) + m.convStreams.reap(time.Now()) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("stale run must be reaped once the runtime goes idle, activities=%d", len(activities)) + } +} + +func TestSupersessionKeepsSeededUserMessageReplayable(t *testing.T) { + m := NewManager() + m.convStreams.eventRetention = time.Millisecond + + // Run A streams while a webui command for the same conversation is + // accepted and seeded; B later starts via supersession. + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "seeded prompt"}, + }) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "working")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + + // Age everything past retention, then trigger eviction: run B's activity + // window must still protect the seeded user_message. + time.Sleep(5 * time.Millisecond) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + hasSeed := false + for _, event := range sub.Events { + if event.Type == "user_message" && event.RunID == "run-b" { + hasSeed = true + } + } + if !hasSeed { + t.Fatalf("seeded user_message evicted despite active run: %v", eventTypes(sub.Events)) + } +} + +func TestWatchChatCommandCleanupClosesChannel(t *testing.T) { + m := NewManager() + updates, cleanup := m.WatchChatCommand("run-1") + cleanup() + select { + case _, ok := <-updates: + if ok { + t.Fatalf("expected closed channel, got value") + } + case <-time.After(time.Second): + t.Fatalf("watch channel not closed by cleanup") + } +} diff --git a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs index 485d07e2..069d22f4 100644 --- a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs +++ b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs @@ -145,3 +145,70 @@ test("failed update surfaces the gateway error", async () => { const texts = tailTexts(stores.get("conv-1")); assert.equal(texts.some((text) => /did not start/.test(text)), true); }); + +test("run signals settle only on strict identity (runId or own clientRequestId)", async () => { + const { pipeline } = createHarness(); + let releaseAccept; + const acceptGate = new Promise((resolve) => { + releaseAccept = resolve; + }); + const submitPromise = pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => { + await acceptGate; + return { runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }; + }, + }); + + // Accept response still in flight (pending.runId === null): a foreign run + // signal without our client_request_id must NOT settle the pending — + // otherwise a GUI queue auto-send would disarm the startup watchdog. + pipeline.handleRunSignal("conv-1", "run-foreign"); + assert.equal(pipeline.hasPending("conv-1"), true, "foreign signal ignored"); + pipeline.handleRunSignal("conv-1", "run-foreign", "client-other"); + assert.equal(pipeline.hasPending("conv-1"), true, "foreign clientRequestId ignored"); + + // Our own run signal, matched by client_request_id, settles before the + // accept response lands. + pipeline.handleRunSignal("conv-1", "run-1", "client-1"); + assert.equal(pipeline.hasPending("conv-1"), false, "own clientRequestId settles"); + + releaseAccept(); + const outcome = await submitPromise; + assert.equal(outcome.kind, "accepted"); + // The late accept response must not resurrect a byRunId registration for + // the already-settled pending: a later command_update for that run id is a + // no-op instead of firing hooks against a dead pending. + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-other", + phase: "bound", + errorCode: null, + message: null, + }); + assert.equal(pipeline.hasPending("conv-other"), false); +}); + +test("run signals with a known runId settle regardless of clientRequestId", async () => { + const { pipeline } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "hello", + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 2 }), + }); + assert.equal(pipeline.hasPending("conv-1"), true); + + // Foreign run id: ignored even though the conversation matches. + pipeline.handleRunSignal("conv-1", "run-9"); + assert.equal(pipeline.hasPending("conv-1"), true); + + // Matching run id (e.g. an activity event while the conversation is not + // displayed) settles without any clientRequestId. + pipeline.handleRunSignal("conv-1", "run-1"); + assert.equal(pipeline.hasPending("conv-1"), false); +}); + diff --git a/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs index 5c289b3f..4c7d516f 100644 --- a/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs +++ b/crates/agent-gateway/test/webui/conversation-stream-client.test.mjs @@ -191,3 +191,33 @@ test("subscription_reset resumes from the cursor; cleanup unsubscribes", async ( 1, ); }); + +test("disconnect clears events buffered before the subscribe response", async () => { + const transport = createTransport(); + const client = new ConversationStreamClient(transport); + const { seen, handlers } = collectHandlers(); + + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + transport.setResponder(() => gate.then(() => subscribeResponse({ latest_seq: 1 }))); + client.subscribe("conv-1", handlers); + client.handleConnected(); + + // Events buffered while the subscribe response is in flight belong to the + // dying connection; after a disconnect the resume protocol re-fetches + // everything, so draining them later would corrupt the transcript. + client.handleChatEvent({ type: "token", conversation_id: "conv-1", seq: 2, text: "stale" }); + client.handleDisconnected(); + + transport.setResponder(() => subscribeResponse({ latest_seq: 3 })); + client.handleConnected(); + release(); + await flushMicrotasks(); + + assert.equal(seen.events.length, 0, "stale buffered events were dropped"); + assert.ok(seen.syncs.length >= 1); + assert.equal(seen.syncs.at(-1).latestSeq, 3); +}); + diff --git a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs index b017533c..00498785 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -1239,6 +1239,7 @@ test("GatewayWebSocketClient fans chat.activity and chat.command_update out to l running: true, state: "running", workdir: "/workspace/project", + client_request_id: "req-42", updated_at: 1234, }, }); @@ -1253,6 +1254,7 @@ test("GatewayWebSocketClient fans chat.activity and chat.command_update out to l running: true, state: "running", workdir: "/workspace/project", + clientRequestId: "req-42", updatedAt: 1234, }); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 92e57e46..f4cf80fe 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -798,7 +798,11 @@ export default function GatewayApp() { // Only runs while the conversation is idle; a run started mid-fetch aborts // the merge so a stale snapshot can never truncate freshly folded entries. const refreshDisplayedConversationHistorySnapshot = useCallback( - async (targetConversationId: string, currentApi = api) => { + async ( + targetConversationId: string, + currentApi = api, + options?: { forceFull?: boolean }, + ) => { const conversationIdValue = targetConversationId.trim(); if (!currentApi || !conversationIdValue || isLocalDraftConversationId(conversationIdValue)) { return; @@ -814,8 +818,9 @@ export default function GatewayApp() { // If the full history is already loaded, refresh the full transcript so // the merge cannot truncate it back to the most recent page. const hasFullHistoryLoaded = - selectedHistoryRef.current?.conversation_id === conversationIdValue && - selectedHistoryRef.current.has_more === false; + options?.forceFull === true || + (selectedHistoryRef.current?.conversation_id === conversationIdValue && + selectedHistoryRef.current.has_more === false); let detail: HistoryDetail; let entries: ChatEntry[]; @@ -825,6 +830,15 @@ export default function GatewayApp() { hasFullHistoryLoaded ? undefined : { maxMessages: HISTORY_DETAIL_INITIAL_MAX_MESSAGES }, ); entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + if ( + detail.has_more === true && + entries.length < getConversationTranscriptEntryCount(conversationIdValue) + ) { + // Partial window smaller than what is currently rendered: merging + // it would truncate the top of a longer transcript. Refetch full. + detail = await currentApi.getHistory(conversationIdValue); + entries = await parseHistoryMessagesJsonAsync(detail.messages_json); + } } catch { return; } @@ -842,7 +856,7 @@ export default function GatewayApp() { } transcriptStoreRegistry.get(conversationIdValue).applyHistorySnapshot(entries); }, - [api, isConversationBusy, transcriptStoreRegistry], + [api, getConversationTranscriptEntryCount, isConversationBusy, transcriptStoreRegistry], ); @@ -1382,10 +1396,25 @@ export default function GatewayApp() { pipelineOnQueuedInGuiRef.current = (update, pending) => { draftClientRequestsRef.current.delete(pending.clientRequestId); refreshChatQueueSnapshot(update.conversationId?.trim() || pending.conversationId); + if (pending.isEditResend) { + // The seeded `rebased` already truncated committed optimistically, but + // the command was parked — server-side history is unchanged; a full + // quiet refresh restores the truncated suffix. + void refreshDisplayedConversationHistorySnapshot( + update.conversationId?.trim() || pending.conversationId, + api, + { forceFull: true }, + ); + } }; pipelineOnFailedRef.current = (pending, _errorCode, message) => { draftClientRequestsRef.current.delete(pending.clientRequestId); const conversationIdValue = pending.conversationId.trim(); + if (pending.isEditResend) { + void refreshDisplayedConversationHistorySnapshot(conversationIdValue, api, { + forceFull: true, + }); + } if (isLocalDraftConversationId(conversationIdValue)) { // The draft never materialized: drop its optimistic sidebar row. The // transcript keeps the pipeline's error entry. @@ -1408,6 +1437,18 @@ export default function GatewayApp() { const unsubscribe = api.subscribeChatActivity((event: ConversationActivityEvent) => { const previous = activityStore.get(event.conversationId); activityStore.applyActivityEvent(event); + // Settle pending commands from the always-on hub too: the run may + // start (or finish) while its conversation is not the displayed one, + // and without this the 60s startup watchdog would fire spuriously. + // The `queued` state stays armed — the gateway watchdog plus + // command_update failed cover that phase. + if (event.runId && (event.running ? event.state !== "queued" : true)) { + chatCommandPipeline.handleRunSignal( + event.conversationId, + event.runId, + event.clientRequestId ?? undefined, + ); + } const workdir = event.workdir?.trim() || previous?.workdir?.trim() || @@ -1419,7 +1460,7 @@ export default function GatewayApp() { } }); return unsubscribe; - }, [activityStore, api, recordProjectActivity, refreshHistoryWorkdirs]); + }, [activityStore, api, chatCommandPipeline, recordProjectActivity, refreshHistoryWorkdirs]); useEffect(() => { if (!api) { @@ -1450,9 +1491,17 @@ export default function GatewayApp() { options?: { replay?: boolean }, ) => { const isReplay = options?.replay === true; + const eventClientRequestId = + typeof (event as { client_request_id?: unknown }).client_request_id === "string" + ? ((event as { client_request_id: string }).client_request_id ?? "").trim() + : ""; switch (event.type) { case "run_started": { - chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); if (!isReplay && isDisplayedConversation(targetConversationId)) { // The transcript store folded the settled tail into committed // when it applied this event. Commit that fold to the DOM in one @@ -1477,7 +1526,11 @@ export default function GatewayApp() { return; } case "run_finished": { - chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); const finishedTitle = typeof (event as { title?: unknown }).title === "string" ? ((event as { title: string }).title ?? "").trim() @@ -1488,7 +1541,11 @@ export default function GatewayApp() { return; } case "run_queued": { - chatCommandPipeline.handleRunSignal(targetConversationId, readEventRunId(event)); + chatCommandPipeline.handleRunSignal( + targetConversationId, + readEventRunId(event), + eventClientRequestId || undefined, + ); if (!isReplay) { refreshChatQueueSnapshot(targetConversationId); } @@ -1521,7 +1578,11 @@ export default function GatewayApp() { const handleConversationStreamSync = useCallback( (targetConversationId: string, result: ConversationSubscribeResult) => { if (result.activity) { - chatCommandPipeline.handleRunSignal(targetConversationId, result.activity.runId); + chatCommandPipeline.handleRunSignal( + targetConversationId, + result.activity.runId, + result.activity.clientRequestId, + ); } for (const event of result.events) { observeConversationStreamEvent(targetConversationId, event, { replay: true }); @@ -2036,6 +2097,20 @@ export default function GatewayApp() { }); }, [api, historyScopeKey, status?.online]); + // Reconnect reconciliation: a run that finished while the socket was down + // never produced an idle activity event; re-hydrate the activity registry + // from history.list so no phantom running dot survives an outage. + const previousOnlineRef = useRef(null); + useEffect(() => { + const wasOnline = previousOnlineRef.current; + const isOnline = status?.online === true; + previousOnlineRef.current = isOnline; + if (!api || !isOnline || wasOnline !== false) { + return; + } + void reloadHistory(api, { silent: true, skipSelectionSync: true }); + }, [api, status?.online]); + // Foreground nudge: waking the page just pings the runtime keep-warm; the // socket's own wakeup/reconnect plus per-conversation subscription resume // replaces the old page-restore recovery machinery. @@ -2203,6 +2278,7 @@ export default function GatewayApp() { clientRequestId, message, attachments: uploadedFiles, + isEditResend: Boolean(options?.editMessageRef), submit: () => api.chatCommand(commandInput), }); @@ -2326,6 +2402,27 @@ export default function GatewayApp() { } clearCurrentComposerDraftForQueuedTurn(conversationIdValue); clearedComposer = true; + if (chatCommandPipeline.hasPending(conversationIdValue)) { + // A command is already in flight for this conversation: park this one + // straight into the GUI queue. The pipeline slot (pre-first-token + // spinner + watchdog) belongs to the first command; the queue panel + // updates via command_update/run_queued and chat_queue events. + await api.chatCommand({ + type: "chat.submit", + message: materialized.text, + conversationId: isLocalDraftConversationId(conversationIdValue) + ? undefined + : conversationIdValue, + selectedModel: buildGatewaySelectedModel(settings.selectedModel, activeProviders), + systemSettings: buildGatewaySystemSettings(settings, workdirForTurn), + uploadedFiles: materialized.uploadedFiles, + clientRequestId: crypto.randomUUID(), + runtimeControls: chatRuntimeControlsForCurrentProvider, + queuePolicy, + }); + refreshChatQueueSnapshot(conversationIdValue); + return true; + } // Same pipeline path as a normal send: `command_update queued_in_gui` // (or the stream's run_queued event) refreshes the queue snapshot; a // direct start settles through run_started. @@ -2336,10 +2433,21 @@ export default function GatewayApp() { workdir: workdirForTurn, queuePolicy, }); - if (!outcome || outcome.kind === "failed") { - throw new Error( - outcome && outcome.kind === "failed" ? outcome.message : "queue request failed", - ); + if (!outcome) { + // Benign no-op (client not ready): restore the composer without + // surfacing an error. + if (getDisplayedConversationId() === conversationIdValue) { + if (!composerRef.current?.hasContent()) { + composerRef.current?.setDraft(draft); + } + if ((pendingUploadsByConversationRef.current.get(conversationIdValue) ?? []).length === 0) { + setPendingUploadsForConversation(conversationIdValue, uploadedFiles); + } + } + return false; + } + if (outcome.kind === "failed") { + throw new Error(outcome.message); } return true; } catch (error) { diff --git a/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts index 6ba420d1..53439013 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts @@ -102,25 +102,68 @@ export function createActivityStore(): ActivityStore { }, hydrate: (items) => { - const next = new Map(); + const incoming = new Map(); + let newestBatchUpdatedAt = 0; for (const item of items) { const conversationId = item.conversationId.trim(); const runId = item.runId.trim(); if (!conversationId || !runId) { continue; } - next.set(conversationId, { + const updatedAt = item.updatedAt ?? 0; + newestBatchUpdatedAt = Math.max(newestBatchUpdatedAt, updatedAt); + incoming.set(conversationId, { runId, state: normalizeState(item.state), workdir: item.workdir?.trim() || null, - updatedAt: item.updatedAt ?? 0, + updatedAt, }); } - let changed = next.size !== activities.size; + + // An empty authoritative snapshot means idle everywhere. + if (incoming.size === 0) { + if (activities.size === 0) { + return; + } + activities = new Map(); + emit(); + return; + } + + // The snapshot races the chat.activity pushes: merge per entry with + // newer-wins (all timestamps come from the gateway clock) so a stale + // list response cannot resurrect a run we already saw finish, and only + // drop absent entries that are older than the batch itself. + const merged = new Map(); + for (const [conversationId, activity] of incoming) { + const current = activities.get(conversationId); + merged.set( + conversationId, + current && current.updatedAt > activity.updatedAt ? current : activity, + ); + } + for (const [conversationId, current] of activities) { + if (merged.has(conversationId)) { + continue; + } + if (current.updatedAt >= newestBatchUpdatedAt) { + // Newer than the snapshot: a push that arrived after the list + // response was built. Keep it. + merged.set(conversationId, current); + } + } + + let changed = merged.size !== activities.size; if (!changed) { - for (const [conversationId, activity] of next) { + for (const [conversationId, activity] of merged) { const current = activities.get(conversationId); - if (!current || current.runId !== activity.runId || current.state !== activity.state) { + if ( + !current || + current.runId !== activity.runId || + current.state !== activity.state || + current.workdir !== activity.workdir || + current.updatedAt !== activity.updatedAt + ) { changed = true; break; } @@ -129,7 +172,7 @@ export function createActivityStore(): ActivityStore { if (!changed) { return; } - activities = next; + activities = merged; emit(); }, diff --git a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts index 33f1108d..ab18e98a 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts @@ -14,6 +14,9 @@ export type ChatCommandRequest = { Parameters[0]["attachments"], unknown >; + // edit_resend commands seed an optimistic `rebased` truncation; compensation + // paths (queued_in_gui / failed) need to know to restore the transcript. + isEditResend?: boolean; submit: () => Promise; }; @@ -21,6 +24,7 @@ export type PendingChatCommand = { runId: string | null; clientRequestId: string; conversationId: string; + isEditResend: boolean; submittedAt: number; }; @@ -66,6 +70,7 @@ export class ChatCommandPipeline { runId: null, clientRequestId: request.clientRequestId, conversationId: request.conversationId, + isEditResend: request.isEditResend === true, submittedAt: Date.now(), }; this.setPending(request.conversationId, pending); @@ -73,7 +78,12 @@ export class ChatCommandPipeline { try { const accepted = await request.submit(); pending.runId = accepted.runId; - this.byRunId.set(accepted.runId, pending); + if (this.pending.get(pending.conversationId) === pending) { + // Only register while the pending is still live: an own run signal + // (matched by client_request_id) may have settled it before the + // accept response landed, and a dead registration would leak. + this.byRunId.set(accepted.runId, pending); + } return { kind: "accepted", accepted }; } catch (error) { const message = error instanceof Error ? error.message : "chat command failed"; @@ -119,11 +129,23 @@ export class ChatCommandPipeline { } } - // Stream signals that settle the pending command: the run started (tokens - // will flow), finished (failed fast), or was queued. - handleRunSignal(conversationId: string, runId: string): void { + // Stream/activity signals that settle the pending command: the run started + // (tokens will flow), finished (failed fast), or was queued. Identity is + // strict — a null-runId pending (accept response still in flight) settles + // only on a signal carrying its own client_request_id; foreign runs (GUI + // queue auto-sends, another client's commands) never disarm the watchdog. + handleRunSignal(conversationId: string, runId: string, clientRequestId?: string): void { const pending = this.pending.get(conversationId); - if (pending && (pending.runId === runId || pending.runId === null)) { + if (!pending) { + return; + } + const matchesRunId = runId !== "" && pending.runId === runId; + const matchesClientRequest = + pending.runId === null && + typeof clientRequestId === "string" && + clientRequestId !== "" && + clientRequestId === pending.clientRequestId; + if (matchesRunId || matchesClientRequest) { this.clearPending(pending); } } diff --git a/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts index 02493cea..73f838f8 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts @@ -96,6 +96,10 @@ export class ConversationStreamClient { this.connected = false; for (const registration of this.registrations.values()) { registration.synced = false; + // Events buffered on the dead connection belong to its stream epoch; + // the resume protocol re-fetches everything after lastSeq on + // reconnect, so draining them later could only corrupt the transcript. + registration.pendingEvents = []; } } diff --git a/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts b/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts index fa251eb0..ab37fd13 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/streamTypes.ts @@ -45,6 +45,9 @@ export type RunLifecycleEvent = entries_json?: string; tool_status?: string | null; tool_status_is_compaction?: boolean; + // The conversation log seq this snapshot represents through: events + // with seq <= as_of_seq are already folded into entries_json. + as_of_seq?: number; }; export type ConversationStreamEvent = (ChatEvent | RunLifecycleEvent) & { @@ -71,6 +74,9 @@ export type StreamRunSnapshot = { entriesJson: string; toolStatus: string | null; toolStatusIsCompaction: boolean; + // The log seq the snapshot content covers through (dedup barrier for the + // overlapping event replay). + asOfSeq: number; }; // Parsed chat.subscribe response. @@ -92,6 +98,7 @@ export type ConversationActivityEvent = { running: boolean; state: RunActivityState | null; workdir: string | null; + clientRequestId: string | null; updatedAt: number; }; @@ -165,6 +172,7 @@ export function normalizeRunSnapshot(raw: unknown): StreamRunSnapshot | null { entriesJson: readString(value.entries_json), toolStatus: readString(value.tool_status).trim() || null, toolStatusIsCompaction: value.tool_status_is_compaction === true, + asOfSeq: readNumber(value.as_of_seq), }; } @@ -205,6 +213,7 @@ export function normalizeActivityEvent(raw: unknown): ConversationActivityEvent running: value.running === true, state: state === "queued" || state === "running" || state === "cancelling" ? state : null, workdir: readString(value.workdir).trim() || null, + clientRequestId: readString(value.client_request_id).trim() || null, updatedAt: readNumber(value.updated_at), }; } diff --git a/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts index a047f098..27a9d3f0 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts @@ -90,6 +90,11 @@ export function createTranscriptStore(): TranscriptStore { let toolStatus: string | null = null; let toolStatusIsCompaction = false; let foldRevision = 0; + // Idempotency cursor: the highest log seq already applied. Re-subscribes + // replay the buffered log into a store that persists across conversation + // switches; without the cursor every replayed event would duplicate its + // entry (or double-append token text). + let lastSeq = 0; let snapshot = EMPTY_SNAPSHOT; let dirty = false; @@ -201,7 +206,42 @@ export function createTranscriptStore(): TranscriptStore { } }; + // True when the entry belongs to another run than `runId`: prefixed with a + // foreign run id, adopted by a foreign run, or a not-yet-adopted optimistic + // echo. Such entries must survive the finishing run's settle sweep. + const isForeignOwnedEntry = (entry: ChatEntry, runId: string): boolean => { + const prefix = runIdPrefix(runId); + if (prefix !== "" && entry.id.startsWith(prefix)) { + return false; + } + if (adoptedEntryIds.get(runId)?.includes(entry.id)) { + return false; + } + if (pendingOptimistic.size > 0) { + for (const optimisticId of pendingOptimistic.values()) { + if (optimisticId === entry.id) { + return true; + } + } + } + for (const [otherRunId, ownedIds] of adoptedEntryIds) { + if (otherRunId !== runId && ownedIds.includes(entry.id)) { + return true; + } + } + const slashIndex = entry.id.indexOf("/"); + return slashIndex > 0 && !entry.id.startsWith("local/"); + }; + const applyRunFinished = (event: ConversationStreamEvent) => { + const runId = readEventRunId(event); + if (activeRun && runId !== "" && runId !== activeRun.runId) { + // Stray terminal for a non-active run (the gateway appends these + // deliberately, e.g. failing a superseded queued run). Never settle the + // active segment; just drop the stray run's provisional entries. + removeRunEntries(runId); + return; + } const payload = event as { status?: string; message?: string; @@ -211,11 +251,17 @@ export function createTranscriptStore(): TranscriptStore { live = pushChatEvent( live, { type: "error", message: payload.message } as ChatEvent, - { entryIdPrefix: runIdPrefix(readEventRunId(event)) }, + { entryIdPrefix: runIdPrefix(runId) }, ); } - settled = live.length === 0 ? settled : [...settled, ...live]; - live = []; + // Settle only entries owned by the finished run (or unowned, e.g. local/ + // errors); foreign-owned entries — seeded user_messages and optimistic + // echoes of not-yet-started runs — stay live for their own run. + const settling = live.filter((entry) => !isForeignOwnedEntry(entry, runId)); + const remaining = live.filter((entry) => isForeignOwnedEntry(entry, runId)); + adoptedEntryIds.delete(runId); + settled = settling.length === 0 ? settled : [...settled, ...settling]; + live = remaining; activeRun = null; setToolStatus(null, false); schedule(true); @@ -260,18 +306,28 @@ export function createTranscriptStore(): TranscriptStore { applySync: (result) => { if (result.reset) { // Seq continuity broke (gateway restart / buffer gap). Committed - // history is still valid; rebuild the tail from scratch. - settled = []; + // history stays valid; the settled tail is finished-run content with + // stable ids, so fold it into committed instead of dropping the last + // reply, then rebuild the live segment from scratch. The replay after + // a reset only carries events the tail was not built from (gap + // resets replay past the evicted prefix; epoch resets replay + // new-epoch runs with disjoint id prefixes), so zero the cursor. + lastSeq = 0; + foldSettled(false); live = []; activeRun = null; toolStatus = null; toolStatusIsCompaction = false; if (result.snapshot) { rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); } } else if (result.snapshot && live.length === 0) { - // Late join mid-run where the buffer cannot cover the run start. + // Late join mid-run where the buffer cannot cover the run start. The + // snapshot folds every event through asOfSeq into its entries; + // advancing the cursor drops the overlapping replay below. rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); } activeRun = result.activity; if (result.activity) { @@ -282,6 +338,7 @@ export function createTranscriptStore(): TranscriptStore { for (const event of result.events) { applyOne(event); } + lastSeq = Math.max(lastSeq, result.latestSeq); schedule(true); }, @@ -333,20 +390,49 @@ export function createTranscriptStore(): TranscriptStore { for (const entry of committed) { existingByKey.set(chatEntryDedupKey(entry), entry); } - const tailKeys = new Set(); - for (const entry of settled) { - tailKeys.add(chatEntryDedupKey(entry)); - } - for (const entry of live) { - tailKeys.add(chatEntryDedupKey(entry)); - } + const settledIndexByKey = new Map(); + settled.forEach((entry, index) => { + settledIndexByKey.set(chatEntryDedupKey(entry), index); + }); + const liveIndexByKey = new Map(); + live.forEach((entry, index) => { + liveIndexByKey.set(chatEntryDedupKey(entry), index); + }); const nextCommitted: ChatEntry[] = []; + let nextSettled = settled; + let nextLive = live; let changed = entries.length !== committed.length; + let tailChanged = false; for (const entry of entries) { const key = chatEntryDedupKey(entry); - if (tailKeys.has(key)) { - // Already rendered in the tail region; folds in at run_started. + const settledIndex = settledIndexByKey.get(key); + const liveIndex = settledIndex === undefined ? liveIndexByKey.get(key) : undefined; + if (settledIndex !== undefined || liveIndex !== undefined) { + // Already rendered in the tail region (folds in at run_started). + // Upgrade the tail entry in place — same id, same position — with + // the history payload: this is what gives the just-settled user + // bubble its messageRef (edit-resend affordance) and tool cards + // their full, untrimmed content. + if (settledIndex !== undefined) { + const existing = nextSettled[settledIndex]; + if (existing) { + if (nextSettled === settled) { + nextSettled = settled.slice(); + } + nextSettled[settledIndex] = { ...entry, id: existing.id }; + tailChanged = true; + } + } else if (liveIndex !== undefined) { + const existing = nextLive[liveIndex]; + if (existing) { + if (nextLive === live) { + nextLive = live.slice(); + } + nextLive[liveIndex] = { ...entry, id: existing.id }; + tailChanged = true; + } + } changed = true; continue; } @@ -364,7 +450,11 @@ export function createTranscriptStore(): TranscriptStore { changed = true; } } - if (!changed && nextCommitted.length === committed.length) { + if (tailChanged) { + settled = nextSettled; + live = nextLive; + } + if (!tailChanged && !changed && nextCommitted.length === committed.length) { return; } committed = nextCommitted; @@ -382,6 +472,7 @@ export function createTranscriptStore(): TranscriptStore { activeRun = null; toolStatus = null; toolStatusIsCompaction = false; + lastSeq = 0; pendingOptimistic.clear(); adoptedEntryIds.clear(); schedule(true); @@ -427,6 +518,14 @@ export function createTranscriptStore(): TranscriptStore { } function applyOne(event: ConversationStreamEvent) { + const seq = readEventSeq(event); + if (seq > 0) { + if (seq <= lastSeq) { + // Already applied (resubscribe replay / snapshot overlap). + return; + } + lastSeq = seq; + } const runId = readEventRunId(event); switch (event.type) { case "run_started": { @@ -467,8 +566,13 @@ export function createTranscriptStore(): TranscriptStore { return; } case "snapshot": { - const payload = event as { entries_json?: string }; + const payload = event as { entries_json?: string; as_of_seq?: number }; rebuildLiveFromSnapshot(payload.entries_json ?? "", runId); + if (typeof payload.as_of_seq === "number" && Number.isFinite(payload.as_of_seq)) { + // The snapshot content covers the log through as_of_seq; drop the + // overlapping tail of any concurrent replay. + lastSeq = Math.max(lastSeq, Math.floor(payload.as_of_seq)); + } const status = (event as { tool_status?: string | null }).tool_status ?? null; setToolStatus( typeof status === "string" ? status : null, diff --git a/crates/agent-gateway/web/test/activity-store.test.mjs b/crates/agent-gateway/web/test/activity-store.test.mjs index 9aedc95d..29edf514 100644 --- a/crates/agent-gateway/web/test/activity-store.test.mjs +++ b/crates/agent-gateway/web/test/activity-store.test.mjs @@ -63,7 +63,7 @@ test("activity events drive the running map with run identity", () => { assert.equal(notifications, 2); }); -test("hydration replaces the map from history.list", () => { +test("hydration drops stale entries and adopts the snapshot", () => { const store = createActivityStore(); store.applyActivityEvent({ conversationId: "conv-stale", @@ -71,6 +71,7 @@ test("hydration replaces the map from history.list", () => { running: true, state: "running", workdir: null, + clientRequestId: null, updatedAt: 1, }); @@ -79,7 +80,63 @@ test("hydration replaces the map from history.list", () => { { conversationId: "conv-2", runId: "run-2", state: "cancelling", updatedAt: 3 }, ]); - assert.equal(store.isRunning("conv-stale"), false, "hydration is authoritative"); + assert.equal( + store.isRunning("conv-stale"), + false, + "entry older than the snapshot batch is dropped", + ); assert.equal(store.get("conv-1")?.runId, "run-1"); assert.equal(store.get("conv-2")?.state, "cancelling"); }); + +test("hydration is an ordered merge: pushes newer than the snapshot win", () => { + const store = createActivityStore(); + // A chat.activity push arrived after the history.list response was built. + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-2", + running: true, + state: "running", + workdir: "/w", + clientRequestId: null, + updatedAt: 30, + }); + store.applyActivityEvent({ + conversationId: "conv-live", + runId: "run-live", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 50, + }); + + store.hydrate([ + // Stale row for conv-1 (the snapshot predates the run-2 handoff). + { conversationId: "conv-1", runId: "run-1", state: "running", workdir: "/w", updatedAt: 10 }, + { conversationId: "conv-other", runId: "run-9", state: "running", updatedAt: 40 }, + ]); + + assert.equal(store.get("conv-1")?.runId, "run-2", "newer push beats the stale snapshot row"); + assert.equal( + store.isRunning("conv-live"), + true, + "entry newer than the whole batch survives despite being absent from it", + ); + assert.equal(store.get("conv-other")?.runId, "run-9"); +}); + +test("an empty hydration snapshot means idle everywhere", () => { + const store = createActivityStore(); + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-1", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 100, + }); + store.hydrate([]); + assert.equal(store.isRunning("conv-1"), false); +}); diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index 6cee586d..e9dfbb14 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -278,3 +278,266 @@ test("tool status mirrors into the snapshot and clears on run end", () => { store.flush(); assert.equal(store.getSnapshot().toolStatus, null); }); + +test("replay idempotency: a resubscribe replaying applied events changes nothing", () => { + const store = createTranscriptStore(); + const events = [ + userMessage("run-1", 1, "hello", { client_request_id: "client-1" }), + runStarted("run-1", 2), + token("run-1", 3, "answer "), + token("run-1", 4, "text"), + ]; + for (const event of events) { + store.applyEvent(event); + } + store.flush(); + const before = store.getSnapshot(); + assert.equal(before.tail.length, 2); + assert.equal(before.tail[1].text, "answer text"); + + // Conversation switch-back: the transport re-subscribes from after_seq=0 + // and the gateway replays the whole buffered log plus one new event. + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-1", + latestSeq: 5, + reset: false, + activity: { + runId: "run-1", + state: "running", + startedSeq: 2, + toolStatus: null, + toolStatusIsCompaction: false, + updatedAt: 1, + }, + snapshot: null, + events: [...events, token("run-1", 5, "!")], + }); + store.flush(); + + const snapshot = store.getSnapshot(); + const userBubbles = snapshot.tail.filter((entry) => entry.kind === "user"); + const assistants = snapshot.tail.filter((entry) => entry.kind === "assistant"); + assert.equal(userBubbles.length, 1, "exactly one user bubble after replay"); + assert.equal(assistants.length, 1, "exactly one assistant entry after replay"); + assert.equal(assistants[0].text, "answer text!", "only the new token applied"); + const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); +}); + +test("snapshot as_of_seq is a replay barrier: overlapping events are dropped", () => { + const store = createTranscriptStore(); + // Late join mid-run: the subscribe response carries a runtime snapshot + // covering the log through seq 4, plus a replay that overlaps it. + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-1", + latestSeq: 5, + reset: false, + activity: { + runId: "run-1", + state: "running", + startedSeq: 2, + toolStatus: null, + toolStatusIsCompaction: false, + updatedAt: 1, + }, + snapshot: { + runId: "run-1", + revision: 3, + entriesJson: JSON.stringify([ + { id: "snap-assistant", kind: "assistant", text: "answer text", round: 0 }, + ]), + toolStatus: null, + toolStatusIsCompaction: false, + asOfSeq: 4, + }, + events: [ + token("run-1", 3, "answer "), + token("run-1", 4, "text"), + token("run-1", 5, "!"), + ], + }); + store.flush(); + const snapshot = store.getSnapshot(); + const text = snapshot.tail.map((entry) => entry.text ?? "").join(""); + assert.equal(text, "answer text!", "snapshot content is not double-applied"); +}); + +test("inline snapshot events carry as_of_seq and drop the overlapping tail", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent({ + type: "snapshot", + conversation_id: "conv-1", + run_id: "run-1", + revision: 7, + entries_json: JSON.stringify([ + { id: "snap-assistant", kind: "assistant", text: "full text so far", round: 0 }, + ]), + as_of_seq: 6, + }); + // Events at or below the snapshot's coverage must be ignored; newer ones apply. + store.applyEvent(token("run-1", 5, "stale ")); + store.applyEvent(token("run-1", 6, "stale")); + store.applyEvent(token("run-1", 7, " and more")); + store.flush(); + const text = store.getSnapshot().tail.map((entry) => entry.text ?? "").join(""); + assert.equal(text, "full text so far and more"); +}); + +test("stray run_finished for a non-active run never settles the streaming tail", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-b", 1)); + store.applyEvent(token("run-b", 2, "streaming")); + store.applyEvent({ + type: "tool_status", + conversation_id: "conv-1", + run_id: "run-b", + seq: 3, + status: "Vibing", + }); + store.flush(); + + // The gateway deliberately appends terminals for non-active runs (e.g. + // failing a superseded queued run). The active segment must not settle. + store.applyEvent(runFinished("run-a", 4, "failed", { message: "queued run failed" })); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun?.runId, "run-b", "active run unchanged"); + assert.equal(snapshot.toolStatus, "Vibing", "tool status unchanged"); + assert.equal( + snapshot.tail.some((entry) => entry.text === "streaming"), + true, + "streaming entry still live", + ); + + // The active run's own terminal still settles normally afterwards. + store.applyEvent(runFinished("run-b", 5)); + store.flush(); + assert.equal(store.getSnapshot().activeRun, null); +}); + +test("run_finished settles only entries owned by the finished run", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-a", 1)); + store.applyEvent(token("run-a", 2, "reply a")); + // A queued command's seeded user message (run-b) plus a not-yet-adopted + // optimistic echo arrive while run-a is still streaming. + store.applyEvent(userMessage("run-b", 3, "queued prompt")); + store.addOptimisticUserEntry({ clientRequestId: "client-c", text: "pending echo" }); + store.applyEvent(runFinished("run-a", 4)); + store.flush(); + + let snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun, null); + // run-a's reply settled; run-b's seeded user message and the optimistic + // echo stay live for their own runs. + store.applyEvent(runStarted("run-b", 5)); + store.applyEvent(token("run-b", 6, "reply b")); + store.flush(); + snapshot = store.getSnapshot(); + assert.deepEqual( + snapshot.committed.map((entry) => entry.text), + ["reply a"], + "fold took only run-a's settled entries", + ); + assert.deepEqual( + snapshot.tail.map((entry) => entry.text), + ["queued prompt", "pending echo", "reply b"], + "foreign entries stayed in the live region", + ); + const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length); +}); + +test("interleaved run_queued compensation still removes the adopted bubble", () => { + const store = createTranscriptStore(); + store.applyEvent(runStarted("run-a", 1)); + store.addOptimisticUserEntry({ clientRequestId: "client-b", text: "park me" }); + store.applyEvent(userMessage("run-b", 2, "park me", { client_request_id: "client-b" })); + store.applyEvent(runFinished("run-a", 3)); + store.applyEvent(runStarted("run-x", 4)); + store.applyEvent({ + type: "run_queued", + conversation_id: "conv-1", + run_id: "run-b", + seq: 5, + client_request_id: "client-b", + }); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal( + [...snapshot.committed, ...snapshot.tail].some((entry) => entry.text === "park me"), + false, + "queued prompt left the transcript entirely", + ); +}); + +test("reset folds the settled tail into committed instead of dropping the last reply", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "question")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "the reply")); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + const settledIds = store.getSnapshot().tail.map((entry) => entry.id); + assert.equal(settledIds.length, 2); + + // Gateway restart while idle: epoch reset with nothing to replay. + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-2", + latestSeq: 0, + reset: true, + activity: null, + snapshot: null, + events: [], + }); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + snapshot.committed.map((entry) => entry.id), + settledIds, + "settled reply folded into committed with stable ids", + ); + assert.equal(snapshot.tail.length, 0); +}); + +test("history snapshot upgrades matching tail entries in place (messageRef arrives)", () => { + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "edit me" }); + store.applyEvent(userMessage("run-1", 1, "edit me", { client_request_id: "client-1" })); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "reply")); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + const before = store.getSnapshot(); + const userId = before.tail.find((entry) => entry.kind === "user")?.id; + assert.ok(userId); + assert.equal(before.tail.find((entry) => entry.kind === "user")?.messageRef, undefined); + + const messageRef = { + segmentIndex: 0, + messageIndex: 0, + segmentId: "segment-1", + messageId: "message-1", + role: "user", + contentHash: "hash-1", + }; + store.applyHistorySnapshot([ + { id: "hist-user", kind: "user", text: "edit me", attachments: [], messageRef }, + { id: "hist-assistant", kind: "assistant", text: "reply", round: 0 }, + ]); + store.flush(); + const snapshot = store.getSnapshot(); + const tailUser = snapshot.tail.find((entry) => entry.kind === "user"); + assert.equal(tailUser?.id, userId, "tail entry keeps its rendered id"); + assert.deepEqual(tailUser?.messageRef, messageRef, "history payload upgraded in place"); + assert.equal( + snapshot.committed.some((entry) => entry.kind === "user" && entry.text === "edit me"), + false, + "still excluded from committed", + ); +}); + From 9b6b31ee1936760897694709d05e6c38cdaa9228 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 15:02:31 +0800 Subject: [PATCH 21/64] fix(chat): stop queue-bound prompt flash and virtualizer row corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue-bound prompt flash: sending while a run is streaming used to show the user bubble for an instant before the prompt landed in the GUI queue, on every viewer. Two sources, both removed: - the gateway now defers seeded payloads for commands accepted while another run is active — they reach the log only when the run actually starts (or fails, for error context) and are dropped when the desktop parks the prompt, making the agent's echo authoritative for queued items - the webui command pipeline gains optimistic:false, used by the queue-destined composer path so no local echo is inserted at all Transcript render misalignment: applyHistorySnapshot matched entries with a dedup-key -> single-entry map, so a history snapshot containing repeated keys (identical re-sent prompts, id-less tool calls sharing name+round across runs) let several entries adopt the same rendered id — duplicate React keys corrupt the row virtualizer's measurement cache and rows collapse onto each other (and extra occurrences were silently dropped). The merge is now occurrence-aware: per-key queues consumed in transcript order (committed copies before tail copies), with merged ids guaranteed unique. The transcript renderer additionally suffixes any duplicate row key as a defensive backstop for the virtualizer. Co-Authored-By: Claude Fable 5 --- .../internal/session/conversation_ingress.go | 4 + .../internal/session/conversation_stream.go | 44 ++++++ .../session/conversation_stream_test.go | 85 ++++++++++++ .../test/webui/chat-command-pipeline.test.mjs | 29 ++++ .../agent-gateway/web/src/app/GatewayApp.tsx | 10 +- crates/agent-gateway/web/src/app/types.ts | 3 + .../web/src/components/GatewayTranscript.tsx | 16 ++- .../lib/chat/stream/chatCommandPipeline.ts | 17 ++- .../src/lib/chat/stream/transcriptStore.ts | 125 ++++++++++++------ .../web/test/transcript-store.test.mjs | 87 ++++++++++++ 10 files changed, 370 insertions(+), 50 deletions(-) diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go index 0b9405e9..a5c4cb2e 100644 --- a/crates/agent-gateway/internal/session/conversation_ingress.go +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -159,6 +159,10 @@ func (s *conversationStreamStore) markRunQueuedInGUILocked( record.queuedInGUI = true seeded := record.userMessageSeeded record.userMessageSeeded = false + // Seeds deferred at accept time never reached the log: drop them. The + // prompt now lives in the desktop queue (editable there), and the agent's + // echo is the authoritative text when the item eventually runs. + record.deferredSeeds = nil if seeded { payload := map[string]any{} diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go index f0c03a55..3b5df8f3 100644 --- a/crates/agent-gateway/internal/session/conversation_stream.go +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -157,6 +157,11 @@ type chatRunRecord struct { // run started via supersession still protects its seeded user_message // from retention eviction. firstSeededSeq int64 + // deferredSeeds holds seeded payloads of a command accepted while another + // run was active: appended only when this run actually starts (or fails), + // dropped when it parks in the desktop prompt queue — so a queue-bound + // prompt never flashes a transcript bubble. + deferredSeeds []map[string]any // queuedInGUI marks commands the desktop app parked in its prompt queue; // the startup watchdog must leave them alone. queuedInGUI bool @@ -509,6 +514,7 @@ func (s *conversationStreamStore) runStartedLocked( // The gateway-accepted command actually started: append the // run_started log event now. StartedSeq keeps covering the seeded // user_message so the whole run stays replayable. + s.flushDeferredSeedsLocked(stream, runID, s.runRecordLocked(runID, stream.conversationID), now) payload := map[string]any{} if stream.activity.ClientRequestID != "" { payload["client_request_id"] = stream.activity.ClientRequestID @@ -537,6 +543,7 @@ func (s *conversationStreamStore) runStartedLocked( stream.workdir = workdir } record := s.runRecordLocked(runID, stream.conversationID) + s.flushDeferredSeedsLocked(stream, runID, record, now) payload := map[string]any{} if record.clientRequestID != "" { payload["client_request_id"] = record.clientRequestID @@ -765,6 +772,22 @@ func (m *Manager) StartChatCommand( } record := s.runRecordLocked(runID, conversationID) record.clientRequestID = clientRequestID + + if stream.activity != nil { + // A run is already active: this command is almost certainly headed + // for the desktop prompt queue. Seeding the user_message into the log + // now would flash a bubble on every viewer until the queued_in_gui + // compensation removes it — defer the seeds until the run actually + // starts (or fails); if it parks in the GUI queue they are dropped + // and the agent's own echo becomes authoritative. + record.deferredSeeds = seededPayloads + return ChatCommandStart{ + RunID: runID, + ConversationID: conversationID, + AcceptedSeq: stream.lastSeq, + } + } + // Mark queued before seeding so the activity's StartedSeq covers the // seeded user_message — the whole run replays from one cursor. s.markRunQueuedLocked(stream, runID, clientRequestID, now) @@ -777,6 +800,24 @@ func (m *Manager) StartChatCommand( } } +// flushDeferredSeedsLocked appends seeds that were deferred because another +// run was active at accept time. Called right before the run's run_started +// event so the log keeps the normal [user_message, run_started, ...] shape. +func (s *conversationStreamStore) flushDeferredSeedsLocked( + stream *conversationStream, + runID string, + record *chatRunRecord, + now time.Time, +) { + if len(record.deferredSeeds) == 0 { + return + } + seeds := record.deferredSeeds + record.deferredSeeds = nil + s.appendSeededPayloadsLocked(stream, runID, record.clientRequestID, seeds, now) + record.userMessageSeeded = seededPayloadsIncludeUserMessage(seeds) +} + func (s *conversationStreamStore) appendSeededPayloadsLocked( stream *conversationStream, runID string, @@ -848,6 +889,9 @@ func (m *Manager) FailChatCommand(runID string, errorCode string, message string if stream == nil { return } + // Seeds deferred at accept time surface now so the failure has its user + // message for context; runFinishedLocked follows with the error. + s.flushDeferredSeedsLocked(stream, runID, record, now) s.runFinishedLocked(stream, runID, "failed", errorCode, message, nil, now) } diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go index 0438a02a..2de48416 100644 --- a/crates/agent-gateway/internal/session/conversation_stream_test.go +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -684,3 +684,88 @@ func TestWatchChatCommandCleanupClosesChannel(t *testing.T) { t.Fatalf("watch channel not closed by cleanup") } } + +func TestSeedsDeferredWhileAnotherRunIsActive(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "streaming")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + + // Command accepted mid-run: nothing may reach the log yet — a seeded + // user_message here would flash a bubble until queued_in_gui compensates. + start := m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "queued while busy"}, + }) + if start.AcceptedSeq != sub.LatestSeq { + t.Fatalf("deferred accept must not append events: acceptedSeq=%d latest=%d", start.AcceptedSeq, sub.LatestSeq) + } + + // The desktop parks it: still nothing in the log (no run_queued needed). + m.ingestChatControl("run-b", &gatewayv1.ChatControlEvent{ + RequestId: "run-b", ConversationId: "conv-1", Type: "queued_in_gui", + }) + m.ingestChatEvent("run-a", tokenEvent("conv-1", "more")) + live := drainEvents(t, sub.EventCh, 1) + if live[0].Type != "token" { + t.Fatalf("expected only run-a token after deferred accept + park, got %v", eventTypes(live)) + } + + // The queued item eventually auto-sends: the agent echo is authoritative. + m.ingestChatEvent("run-a", doneEvent("conv-1")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + echo, _ := json.Marshal(map[string]any{"message": "queued while busy"}) + m.ingestChatEvent("run-b", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, ConversationId: "conv-1", Data: string(echo), + }) + tail := drainEvents(t, sub.EventCh, 3) + types := eventTypes(tail) + if types[0] != StreamEventRunFinished || types[1] != StreamEventRunStarted || types[2] != "user_message" { + t.Fatalf("auto-send tail = %v, want [run_finished run_started user_message]", types) + } +} + +func TestDeferredSeedsFlushWhenRunStartsDirectly(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-1")) + + m.StartChatCommand("run-b", "conv-1", "", "client-b", []map[string]any{ + {"type": "user_message", "message": "interrupt prompt"}, + }) + + // The desktop runs the command immediately (interrupt policy): the + // deferred seeds surface right before run_started, and the agent's echo + // is swallowed as usual. + m.ingestChatControl("run-b", startedControl("run-b", "conv-1")) + echo, _ := json.Marshal(map[string]any{"message": "interrupt prompt"}) + m.ingestChatEvent("run-b", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, ConversationId: "conv-1", Data: string(echo), + }) + m.ingestChatEvent("run-b", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{"run_started", "run_finished", "user_message", "run_started", "token"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } + userMessages := 0 + for _, event := range sub.Events { + if event.Type == "user_message" { + userMessages++ + if event.Payload["client_request_id"] != "client-b" { + t.Fatalf("seeded user_message missing client_request_id: %#v", event.Payload) + } + } + } + if userMessages != 1 { + t.Fatalf("user_message count = %d, want 1 (echo swallowed)", userMessages) + } +} diff --git a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs index 069d22f4..003ac2c2 100644 --- a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs +++ b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs @@ -212,3 +212,32 @@ test("run signals with a known runId settle regardless of clientRequestId", asyn assert.equal(pipeline.hasPending("conv-1"), false); }); + +test("optimistic:false suppresses the transcript echo for queue-destined sends", async () => { + const { pipeline, stores } = createHarness(); + await pipeline.submit({ + conversationId: "conv-1", + clientRequestId: "client-1", + message: "park me quietly", + optimistic: false, + submit: async () => ({ runId: "run-1", conversationId: "conv-1", acceptedSeq: 0 }), + }); + // No store is touched at submit time — the transcript never sees the prompt. + const storeAfterSubmit = stores.get("conv-1"); + if (storeAfterSubmit) { + assert.deepEqual(tailTexts(storeAfterSubmit), [], "no bubble flash"); + } + assert.equal(pipeline.hasPending("conv-1"), true, "watchdog still armed"); + + // queued_in_gui settles it without ever having shown a bubble. + pipeline.handleCommandUpdate({ + runId: "run-1", + clientRequestId: "client-1", + conversationId: "conv-1", + phase: "queued_in_gui", + errorCode: null, + message: null, + }); + assert.equal(pipeline.hasPending("conv-1"), false); + assert.deepEqual(tailTexts(stores.get("conv-1")), []); +}); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index f4cf80fe..4edfe95e 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -2279,6 +2279,7 @@ export default function GatewayApp() { message, attachments: uploadedFiles, isEditResend: Boolean(options?.editMessageRef), + optimistic: options?.optimisticEcho !== false, submit: () => api.chatCommand(commandInput), }); @@ -2423,15 +2424,18 @@ export default function GatewayApp() { refreshChatQueueSnapshot(conversationIdValue); return true; } - // Same pipeline path as a normal send: `command_update queued_in_gui` - // (or the stream's run_queued event) refreshes the queue snapshot; a - // direct start settles through run_started. + // Same pipeline path as a normal send, minus the optimistic transcript + // echo — the prompt is queue-destined and must not flash a bubble. + // `command_update queued_in_gui` (or the stream's run_queued event) + // refreshes the queue snapshot; a direct start settles through + // run_started (whose deferred seeds then render the user message). const outcome = await sendChat(materialized.text, { conversationId: conversationIdValue, uploadedFiles: materialized.uploadedFiles, runtimeControls: chatRuntimeControlsForCurrentProvider, workdir: workdirForTurn, queuePolicy, + optimisticEcho: false, }); if (!outcome) { // Benign no-op (client not ready): restore the composer without diff --git a/crates/agent-gateway/web/src/app/types.ts b/crates/agent-gateway/web/src/app/types.ts index 1ff0bd19..1b45ba73 100644 --- a/crates/agent-gateway/web/src/app/types.ts +++ b/crates/agent-gateway/web/src/app/types.ts @@ -20,6 +20,9 @@ export type SendChatOptions = { workdir?: string; editMessageRef?: HistoryMessageRef; queuePolicy?: "auto" | "append" | "interrupt"; + // false for queue-destined sends: no transcript echo, the queue panel owns + // the prompt until it actually runs. + optimisticEcho?: boolean; }; export type SendChatFn = ( diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index a9d8fe1e..c8120159 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -869,8 +869,22 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { if (!readOnly && hasMoreHistory) { next.push({ key: "load-remote-history", kind: "loadRemoteHistory" }); } + // Row keys feed both React reconciliation and the virtualizer's + // measurement cache; a duplicate key collapses row positions onto each + // other. The store guarantees unique entry ids, but keep the renderer + // safe against any upstream regression by suffixing repeats. + const seenKeys = new Set(); for (const item of items) { - next.push({ key: item.id, kind: "history", item }); + let key = item.id; + if (seenKeys.has(key)) { + let suffix = 2; + while (seenKeys.has(`${key}#${suffix}`)) { + suffix += 1; + } + key = `${key}#${suffix}`; + } + seenKeys.add(key); + next.push({ key, kind: "history", item }); } return next; }, [hasMoreHistory, items, readOnly]); diff --git a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts index ab18e98a..7eb7aa37 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts @@ -17,6 +17,10 @@ export type ChatCommandRequest = { // edit_resend commands seed an optimistic `rebased` truncation; compensation // paths (queued_in_gui / failed) need to know to restore the transcript. isEditResend?: boolean; + // Queue-destined submissions (a run is already streaming) skip the local + // echo: the prompt belongs in the queue panel, and a transcript bubble + // would just flash until the queued_in_gui compensation removed it. + optimistic?: boolean; submit: () => Promise; }; @@ -59,12 +63,13 @@ export class ChatCommandPipeline { } async submit(request: ChatCommandRequest): Promise { - const store = this.hooks.getTranscriptStore(request.conversationId); - store.addOptimisticUserEntry({ - clientRequestId: request.clientRequestId, - text: request.message, - attachments: request.attachments as never, - }); + if (request.optimistic !== false) { + this.hooks.getTranscriptStore(request.conversationId).addOptimisticUserEntry({ + clientRequestId: request.clientRequestId, + text: request.message, + attachments: request.attachments as never, + }); + } const pending: PendingChatCommand = { runId: null, diff --git a/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts index 27a9d3f0..69d1a360 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts @@ -386,75 +386,120 @@ export function createTranscriptStore(): TranscriptStore { // tool entries match by tool-call identity, immune to live-path // trimming) and keep tail entries where they are: committed only takes // what the tail is not showing. - const existingByKey = new Map(); + // + // Dedup keys are NOT unique across the transcript (a repeated user + // prompt, id-less tool calls sharing name + round across runs), so + // matching is occurrence-aware: each rendered occurrence can be + // adopted at most once, consumed in transcript order — committed + // copies (older) before tail copies (newest). Two history entries can + // therefore never adopt the same id; duplicate ids corrupt both React + // keying and the virtualizer's measurement cache. + const committedQueueByKey = new Map(); for (const entry of committed) { - existingByKey.set(chatEntryDedupKey(entry), entry); + const key = chatEntryDedupKey(entry); + const queue = committedQueueByKey.get(key); + if (queue) { + queue.push(entry); + } else { + committedQueueByKey.set(key, [entry]); + } } - const settledIndexByKey = new Map(); + const settledIndexQueueByKey = new Map(); settled.forEach((entry, index) => { - settledIndexByKey.set(chatEntryDedupKey(entry), index); + const key = chatEntryDedupKey(entry); + const queue = settledIndexQueueByKey.get(key); + if (queue) { + queue.push(index); + } else { + settledIndexQueueByKey.set(key, [index]); + } }); - const liveIndexByKey = new Map(); + const liveIndexQueueByKey = new Map(); live.forEach((entry, index) => { - liveIndexByKey.set(chatEntryDedupKey(entry), index); + const key = chatEntryDedupKey(entry); + const queue = liveIndexQueueByKey.get(key); + if (queue) { + queue.push(index); + } else { + liveIndexQueueByKey.set(key, [index]); + } }); + // Tail ids stay as-is and must never be re-issued to a committed entry. + const usedIds = new Set(); + for (const entry of settled) { + usedIds.add(entry.id); + } + for (const entry of live) { + usedIds.add(entry.id); + } + const uniqueId = (candidate: string) => { + if (!usedIds.has(candidate)) { + return candidate; + } + let suffix = 2; + while (usedIds.has(`${candidate}#${suffix}`)) { + suffix += 1; + } + return `${candidate}#${suffix}`; + }; + const nextCommitted: ChatEntry[] = []; let nextSettled = settled; let nextLive = live; - let changed = entries.length !== committed.length; let tailChanged = false; for (const entry of entries) { const key = chatEntryDedupKey(entry); - const settledIndex = settledIndexByKey.get(key); - const liveIndex = settledIndex === undefined ? liveIndexByKey.get(key) : undefined; - if (settledIndex !== undefined || liveIndex !== undefined) { + const committedMatch = committedQueueByKey.get(key)?.shift(); + if (committedMatch) { + // Same logical entry: keep the rendered id, upgrade the payload + // (history carries full, untrimmed tool content). + const id = uniqueId(committedMatch.id); + nextCommitted.push(entry.id === id ? entry : { ...entry, id }); + usedIds.add(id); + continue; + } + const settledIndex = settledIndexQueueByKey.get(key)?.shift(); + if (settledIndex !== undefined) { // Already rendered in the tail region (folds in at run_started). // Upgrade the tail entry in place — same id, same position — with // the history payload: this is what gives the just-settled user // bubble its messageRef (edit-resend affordance) and tool cards // their full, untrimmed content. - if (settledIndex !== undefined) { - const existing = nextSettled[settledIndex]; - if (existing) { - if (nextSettled === settled) { - nextSettled = settled.slice(); - } - nextSettled[settledIndex] = { ...entry, id: existing.id }; - tailChanged = true; - } - } else if (liveIndex !== undefined) { - const existing = nextLive[liveIndex]; - if (existing) { - if (nextLive === live) { - nextLive = live.slice(); - } - nextLive[liveIndex] = { ...entry, id: existing.id }; - tailChanged = true; + const existing = nextSettled[settledIndex]; + if (existing) { + if (nextSettled === settled) { + nextSettled = settled.slice(); } + nextSettled[settledIndex] = { ...entry, id: existing.id }; + tailChanged = true; } - changed = true; continue; } - const existing = existingByKey.get(key); - if (existing) { - // Same logical entry: keep the rendered id, upgrade the payload - // (history carries full, untrimmed tool content). - const upgraded = entry.id === existing.id ? entry : { ...entry, id: existing.id }; - nextCommitted.push(upgraded); - if (existing !== upgraded) { - changed = true; + const liveIndex = liveIndexQueueByKey.get(key)?.shift(); + if (liveIndex !== undefined) { + const existing = nextLive[liveIndex]; + if (existing) { + if (nextLive === live) { + nextLive = live.slice(); + } + nextLive[liveIndex] = { ...entry, id: existing.id }; + tailChanged = true; } - } else { - nextCommitted.push(entry); - changed = true; + continue; } + const id = uniqueId(entry.id); + nextCommitted.push(entry.id === id ? entry : { ...entry, id }); + usedIds.add(id); } if (tailChanged) { settled = nextSettled; live = nextLive; } - if (!tailChanged && !changed && nextCommitted.length === committed.length) { + const committedUnchanged = + nextCommitted.length === committed.length && + nextCommitted.every((entry, index) => entry === committed[index]); + if (!tailChanged && committedUnchanged) { return; } committed = nextCommitted; diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index e9dfbb14..dc3e0468 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -541,3 +541,90 @@ test("history snapshot upgrades matching tail entries in place (messageRef arriv ); }); + +test("history merge is occurrence-aware: repeated dedup keys never share an id", () => { + const store = createTranscriptStore(); + // Two identical user prompts and two id-less same-name tool calls in the + // same round across runs — every dedup key below collides. + store.applyHistorySnapshot([ + { id: "hist-1", kind: "user", text: "继续", attachments: [] }, + { id: "hist-2", kind: "assistant", text: "第一次回复", round: 0 }, + { id: "hist-3", kind: "user", text: "继续", attachments: [] }, + { id: "hist-4", kind: "assistant", text: "第二次回复", round: 0 }, + ]); + store.flush(); + let snapshot = store.getSnapshot(); + assert.equal(snapshot.committed.length, 4); + let ids = snapshot.committed.map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); + + // Re-merging the same snapshot keeps every occurrence, ids stay unique and + // stable (each occurrence adopts its own previous id, in order). + const before = snapshot.committed.map((entry) => entry.id); + store.applyHistorySnapshot([ + { id: "hist-1b", kind: "user", text: "继续", attachments: [] }, + { id: "hist-2b", kind: "assistant", text: "第一次回复", round: 0 }, + { id: "hist-3b", kind: "user", text: "继续", attachments: [] }, + { id: "hist-4b", kind: "assistant", text: "第二次回复", round: 0 }, + ]); + store.flush(); + snapshot = store.getSnapshot(); + assert.deepEqual( + snapshot.committed.map((entry) => entry.id), + before, + "occurrences adopt their own previous ids in order", + ); + assert.equal(snapshot.committed.length, 4, "no occurrence dropped"); +}); + +test("history merge with a committed copy and an identical tail copy keeps both", () => { + const store = createTranscriptStore(); + // Older identical prompt already in committed… + store.applyHistorySnapshot([ + { id: "hist-1", kind: "user", text: "ok", attachments: [] }, + { id: "hist-2", kind: "assistant", text: "先前的回复", round: 0 }, + ]); + // …and the same text sent again in the current run (tail). + store.applyEvent(userMessage("run-2", 10, "ok")); + store.applyEvent(runStarted("run-2", 11)); + store.applyEvent(runFinished("run-2", 12)); + store.flush(); + + const tailUserId = store.getSnapshot().tail.find((entry) => entry.kind === "user")?.id; + assert.ok(tailUserId, "tail keeps the new occurrence"); + + // History now holds both occurrences: the older one matches the committed + // copy (id preserved), the newer one upgrades the tail copy in place with + // its messageRef — neither is dropped, no id is shared. + store.applyHistorySnapshot([ + { id: "hist-1", kind: "user", text: "ok", attachments: [] }, + { id: "hist-2", kind: "assistant", text: "先前的回复", round: 0 }, + { + id: "hist-3", + kind: "user", + text: "ok", + attachments: [], + messageRef: { + segmentIndex: 0, + messageIndex: 2, + segmentId: "segment-1", + messageId: "message-3", + role: "user", + contentHash: "hash-3", + }, + }, + { id: "hist-4", kind: "assistant", text: "新的回复", round: 0 }, + ]); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal( + snapshot.committed.filter((entry) => entry.kind === "user" && entry.text === "ok").length, + 1, + "committed keeps the older occurrence", + ); + const tailUser = snapshot.tail.find((entry) => entry.kind === "user"); + assert.equal(tailUser?.id, tailUserId, "tail occurrence keeps its id"); + assert.equal(tailUser?.messageRef?.messageId, "message-3", "tail occurrence gains its messageRef"); + const allIds = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); + assert.equal(new Set(allIds).size, allIds.length, `duplicate ids: ${allIds.join(", ")}`); +}); From 33c5403f6b46af97a98157dcaac42569df881933 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 2 Jul 2026 21:05:00 +0800 Subject: [PATCH 22/64] fix(chat): stabilize transcript history and live rendering --- .../test/webui/chat-command-pipeline.test.mjs | 16 +- .../test/webui/history-chat-ui.test.mjs | 571 ++++---- .../agent-gateway/web/src/app/GatewayApp.tsx | 68 +- .../web/src/components/GatewayTranscript.tsx | 265 ++-- .../lib/chat/stream/chatCommandPipeline.ts | 2 +- .../src/lib/chat/stream/transcriptStore.ts | 684 ---------- .../lib/chat/stream/useConversationChat.ts | 10 +- .../lib/chat/transcript/historyAlignment.ts | 395 ++++++ .../web/src/lib/chat/transcript/rows.ts | 466 +++++++ .../lib/chat/transcript/transcriptStore.ts | 826 +++++++++++ .../src/lib/chat/transcript/turnReducer.ts | 651 +++++++++ .../web/src/lib/chat/transcript/types.ts | 94 ++ crates/agent-gateway/web/src/lib/chatUi.ts | 1202 +---------------- .../web/src/pages/SharedHistoryPage.tsx | 10 +- .../web/test/chatUi-agent.test.mjs | 339 ++--- .../web/test/transcript-rows.test.mjs | 383 ++++++ .../web/test/transcript-store.test.mjs | 889 ++++++++---- 17 files changed, 4225 insertions(+), 2646 deletions(-) delete mode 100644 crates/agent-gateway/web/src/lib/chat/stream/transcriptStore.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/transcript/historyAlignment.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/transcript/rows.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts create mode 100644 crates/agent-gateway/web/src/lib/chat/transcript/types.ts create mode 100644 crates/agent-gateway/web/test/transcript-rows.test.mjs diff --git a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs index 003ac2c2..7288710a 100644 --- a/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs +++ b/crates/agent-gateway/test/webui/chat-command-pipeline.test.mjs @@ -4,7 +4,7 @@ import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; const loader = createWebModuleLoader(); const { ChatCommandPipeline } = loader.loadModule("src/lib/chat/stream/chatCommandPipeline.ts"); -const { createTranscriptStore } = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); +const { createTranscriptStore } = loader.loadModule("src/lib/chat/transcript/transcriptStore.ts"); function createHarness() { const stores = new Map(); @@ -31,9 +31,21 @@ function createHarness() { return { pipeline, stores, outcomes }; } +function rowText(row) { + if (row.kind === "assistant") { + return row.rounds + .map((round) => + round.blocks.flatMap((block) => (block.kind === "text" ? [block.text] : [])).join(""), + ) + .join("\n"); + } + return row.text ?? ""; +} + +// Live-flow texts (the region the old snapshot exposed as `tail`). function tailTexts(store) { store.flush(); - return store.getSnapshot().tail.map((entry) => entry.text ?? ""); + return store.getSnapshot().liveRows.map((row) => rowText(row)); } test("submit inserts the optimistic bubble and resolves the accepted run", async () => { diff --git a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs index 842c1ecc..d436b191 100644 --- a/crates/agent-gateway/test/webui/history-chat-ui.test.mjs +++ b/crates/agent-gateway/test/webui/history-chat-ui.test.mjs @@ -5,11 +5,29 @@ import { createWebModuleLoader } from "../helpers/load-web-module.mjs"; const loader = createWebModuleLoader(); const historySync = loader.loadModule("src/lib/historySync.ts"); const chatUi = loader.loadModule("src/lib/chatUi.ts"); -const transcriptStoreModule = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); +const transcriptStoreModule = loader.loadModule("src/lib/chat/transcript/transcriptStore.ts"); +const { createTurn, applyEventToTurn } = loader.loadModule( + "src/lib/chat/transcript/turnReducer.ts", +); +const { buildRowsFromEntries } = loader.loadModule("src/lib/chat/transcript/rows.ts"); const historyShare = loader.loadModule("src/lib/historyShare.ts"); const requestContextSanitizer = loader.loadModule("src/lib/chat/requestContextSanitizer.ts"); const conversationState = loader.loadModule("src/lib/chat/conversationState.ts"); +// Live-stream reducer harness: the createTurn/applyEventToTurn pair replaces +// the old flat pushChatEvent pipeline — one Turn holds a single run's entries. +function reduceTurnEvents(events) { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + for (const event of events) { + turn = applyEventToTurn(turn, event); + } + return turn; +} + +function findAssistantRow(rows) { + return rows.find((row) => row.kind === "assistant"); +} + test("history share helpers parse and build share URLs", () => { assert.equal(historyShare.parseHistoryShareToken("/share/abc123"), "abc123"); assert.equal(historyShare.parseHistoryShareToken("/share/abc%20123"), "abc 123"); @@ -533,8 +551,7 @@ test("parseHistoryMessagesJson preserves provider tool_use input arguments", () }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -654,8 +671,7 @@ test("WebUI transcript strips leaked DSML tool call markup from text and thinkin ], }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const round = assistant.rounds[0]; const allText = JSON.stringify(round.blocks); @@ -701,8 +717,7 @@ test("WebUI transcript hides provider-native web_search tool traces when hosted }, ])); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "history")); const round = assistant.rounds[0]; assert.equal(round.blocks.some((block) => block.kind === "tool"), false); @@ -710,8 +725,8 @@ test("WebUI transcript hides provider-native web_search tool traces when hosted }); test("WebUI live transcript removes provider-native web_search when hosted search arrives later", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + turn = applyEventToTurn(turn, { type: "tool_call", id: "call_00_webui_search", name: "web_search", @@ -719,10 +734,10 @@ test("WebUI live transcript removes provider-native web_search when hosted searc round: 1, }); - let assistant = chatUi.buildTranscriptItems(entries).find((entry) => entry.kind === "assistant"); + let assistant = findAssistantRow(buildRowsFromEntries(turn.entries, "stream")); assert.equal(assistant.rounds[0].blocks.some((block) => block.kind === "tool"), true); - entries = chatUi.pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "hosted-search-live", provider: "claude_code", @@ -732,7 +747,7 @@ test("WebUI live transcript removes provider-native web_search when hosted searc round: 1, }); - assistant = chatUi.buildTranscriptItems(entries).find((entry) => entry.kind === "assistant"); + assistant = findAssistantRow(buildRowsFromEntries(turn.entries, "stream")); const round = assistant.rounds[0]; assert.equal(round.blocks.some((block) => block.kind === "tool"), false); @@ -741,53 +756,54 @@ test("WebUI live transcript removes provider-native web_search when hosted searc }); test("WebUI live transcript hides recovered provider-native web_search results without hosted search", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "call_00_webui_recovered_search", - name: "WebSearch", - arguments: { query: "LiveAgent recovered search" }, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "call_00_webui_recovered_search", - name: "WebSearch", - content: [{ type: "text", text: "Recovered provider-native web search." }], - details: { recoveredProviderNativeWebSearch: true }, - isError: false, - round: 1, - }); - - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); - const round = assistant.rounds[0]; + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "call_00_webui_recovered_search", + name: "WebSearch", + arguments: { query: "LiveAgent recovered search" }, + round: 1, + }, + { + type: "tool_result", + id: "call_00_webui_recovered_search", + name: "WebSearch", + content: [{ type: "text", text: "Recovered provider-native web search." }], + details: { recoveredProviderNativeWebSearch: true }, + isError: false, + round: 1, + }, + ]); - assert.equal(round.blocks.some((block) => block.kind === "tool"), false); - assert.deepEqual(round.runningToolCallIds, []); + // The recovered result hides the whole trace; with nothing else in the + // round the content gate drops it entirely — no avatar-only assistant row + // (stronger than the old "row without tool blocks" rendering). + const rows = buildRowsFromEntries(turn.entries, "stream"); + assert.equal(findAssistantRow(rows), undefined, "fully hidden trace renders no assistant row"); + assert.equal(rows.length, 0); }); test("WebUI live transcript hides recovered DSML provider-native web_search calls immediately", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "dsml-tool-call-webui-live-search", - name: "builtin_web_search", - arguments: { query: "LiveAgent DSML hidden search" }, - round: 1, - }); - - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); - const round = assistant.rounds[0]; + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "dsml-tool-call-webui-live-search", + name: "builtin_web_search", + arguments: { query: "LiveAgent DSML hidden search" }, + round: 1, + }, + ]); - assert.equal(round.blocks.some((block) => block.kind === "tool"), false); - assert.deepEqual(round.runningToolCallIds, []); + // The DSML-recovered call is hidden immediately; the content-less round is + // dropped so no assistant row (avatar) can appear for it. + const rows = buildRowsFromEntries(turn.entries, "stream"); + assert.equal(findAssistantRow(rows), undefined, "fully hidden call renders no assistant row"); + assert.equal(rows.length, 0); }); -test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes compaction checkpoints", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { +test("turn reducer appends streaming text, dedupes tool cards, and dedupes compaction checkpoints", () => { + let turn = createTurn({ key: "req:test", runId: "run-test" }); + turn = applyEventToTurn(turn, { type: "token", text: "hello ", round: 1, @@ -795,16 +811,16 @@ test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes comp model: "gpt-test", usage: { totalTokens: 12 }, }); - entries = chatUi.pushChatEvent(entries, { type: "token", text: "world", round: 1 }); - assert.equal(entries.length, 1); - assert.equal(entries[0].kind, "assistant"); - assert.equal(entries[0].text, "hello world"); - assert.equal(entries[0].meta.usageTotalTokens, 12); + turn = applyEventToTurn(turn, { type: "token", text: "world", round: 1 }); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].kind, "assistant"); + assert.equal(turn.entries[0].text, "hello world"); + assert.equal(turn.entries[0].meta.usageTotalTokens, 12); const toolCall = { type: "tool_call", id: "call-1", name: "Read", arguments: { path: "README.md" }, round: 1 }; - entries = chatUi.pushChatEvent(entries, toolCall); - entries = chatUi.pushChatEvent(entries, toolCall); - assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); + turn = applyEventToTurn(turn, toolCall); + turn = applyEventToTurn(turn, toolCall); + assert.equal(turn.entries.filter((entry) => entry.kind === "tool_call").length, 1); const checkpoint = { type: "token", @@ -815,53 +831,54 @@ test("pushChatEvent appends streaming text, dedupes tool cards, and dedupes comp generatedBy: { providerId: "liveagent", model: "summary" }, }, }; - entries = chatUi.pushChatEvent(entries, checkpoint); - entries = chatUi.pushChatEvent(entries, checkpoint); - assert.equal(entries.filter((entry) => entry.kind === "checkpoint").length, 1); + turn = applyEventToTurn(turn, checkpoint); + turn = applyEventToTurn(turn, checkpoint); + assert.equal(turn.entries.filter((entry) => entry.kind === "checkpoint").length, 1); }); -test("pushChatEvent preserves tool call arguments from JSON string and input aliases", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-call", - name: "Bash", - arguments: JSON.stringify({ - command: "echo gateway", - cwd: "crates/agent-gateway", - root: "workspace", - }), - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "read-call", - name: "Read", - input: { - path: "README.md", - root: "workspace", +test("turn reducer preserves tool call arguments from JSON string and input aliases", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-call", + name: "Bash", + arguments: JSON.stringify({ + command: "echo gateway", + cwd: "crates/agent-gateway", + root: "workspace", + }), + round: 1, }, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - data: JSON.stringify({ - id: "glob-call", - name: "Glob", - args: { - pattern: "**/*.ts", - path: "src", + { + type: "tool_call", + id: "read-call", + name: "Read", + input: { + path: "README.md", root: "workspace", }, - }), - round: 1, - }); + round: 1, + }, + { + type: "tool_call", + data: JSON.stringify({ + id: "glob-call", + name: "Glob", + args: { + pattern: "**/*.ts", + path: "src", + root: "workspace", + }, + }), + round: 1, + }, + ]); + const entries = turn.entries; const bashCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "bash-call"); const readCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "read-call"); const globCall = entries.find((entry) => entry.kind === "tool_call" && entry.toolCall.id === "glob-call"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlocks = assistant.rounds[0].blocks.filter((block) => block.kind === "tool"); assert.ok(bashCall); @@ -876,29 +893,30 @@ test("pushChatEvent preserves tool call arguments from JSON string and input ali assert.equal(toolBlocks[2].item.toolCall.arguments.pattern, "**/*.ts"); }); -test("pushChatEvent reconstructs a parameterized tool card from tool_result arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-result-only", - name: "Bash", - arguments: { - command: "printf live", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer reconstructs a parameterized tool card from tool_result arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_result", + id: "bash-result-only", + name: "Bash", + arguments: { + command: "printf live", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "live" }], + isError: false, + round: 1, }, - content: [{ type: "text", text: "live" }], - isError: false, - round: 1, - }); + ]); + const entries = turn.entries; assert.equal(entries.length, 2); assert.equal(entries[0].kind, "tool_call"); assert.equal(entries[0].toolCall.arguments.command, "printf live"); assert.equal(entries[1].kind, "tool_result"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -907,36 +925,36 @@ test("pushChatEvent reconstructs a parameterized tool card from tool_result argu assert.equal(toolBlock.item.toolResult.content[0].text, "live"); }); -test("pushChatEvent does not duplicate tool cards when tool_call precedes parameterized tool_result", () => { - let entries = []; +test("turn reducer does not duplicate tool cards when tool_call precedes parameterized tool_result", () => { const toolArguments = { command: "printf once", cwd: "crates/agent-gateway", root: "workspace", }; - - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-no-duplicate", - name: "Bash", - arguments: toolArguments, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-no-duplicate", - name: "Bash", - arguments: toolArguments, - content: [{ type: "text", text: "once" }], - isError: false, - round: 1, - }); + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-no-duplicate", + name: "Bash", + arguments: toolArguments, + round: 1, + }, + { + type: "tool_result", + id: "bash-no-duplicate", + name: "Bash", + arguments: toolArguments, + content: [{ type: "text", text: "once" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlocks = assistant.rounds[0].blocks.filter((block) => block.kind === "tool"); assert.equal(toolBlocks.length, 1); @@ -944,59 +962,62 @@ test("pushChatEvent does not duplicate tool cards when tool_call precedes parame assert.equal(toolBlocks[0].item.toolResult.content[0].text, "once"); }); -test("pushChatEvent upgrades an existing live tool card when execution start carries arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-late-args", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-late-args", - name: "Bash", - arguments: { - command: "printf from-start", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer upgrades an existing live tool card when execution start carries arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-late-args", + name: "Bash", + round: 1, }, - round: 1, - }); + { + type: "tool_call", + id: "bash-late-args", + name: "Bash", + arguments: { + command: "printf from-start", + cwd: "crates/agent-gateway", + root: "workspace", + }, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries[0].toolCall.arguments.command, "printf from-start"); assert.match(entries[0].summary, /command=printf from-start/); }); -test("pushChatEvent upgrades an existing live tool card when tool_result carries arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-result-args", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-result-args", - name: "Bash", - arguments: { - command: "printf from-result", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer upgrades an existing live tool card when tool_result carries arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-result-args", + name: "Bash", + round: 1, }, - content: [{ type: "text", text: "from-result" }], - isError: false, - round: 1, - }); + { + type: "tool_result", + id: "bash-result-args", + name: "Bash", + arguments: { + command: "printf from-result", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "from-result" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); assert.equal(entries[0].toolCall.arguments.command, "printf from-result"); - const transcript = chatUi.buildTranscriptItems(entries); - const assistant = transcript.find((entry) => entry.kind === "assistant"); + const assistant = findAssistantRow(buildRowsFromEntries(entries, "stream")); const toolBlock = assistant.rounds[0].blocks.find((block) => block.kind === "tool"); assert.ok(toolBlock); @@ -1004,35 +1025,37 @@ test("pushChatEvent upgrades an existing live tool card when tool_result carries assert.equal(toolBlock.item.toolResult.content[0].text, "from-result"); }); -test("pushChatEvent keeps a deduped result while applying late result arguments", () => { - let entries = []; - entries = chatUi.pushChatEvent(entries, { - type: "tool_call", - id: "bash-duplicate-result", - name: "Bash", - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-duplicate-result", - name: "Bash", - content: [{ type: "text", text: "duplicate" }], - isError: false, - round: 1, - }); - entries = chatUi.pushChatEvent(entries, { - type: "tool_result", - id: "bash-duplicate-result", - name: "Bash", - arguments: { - command: "printf duplicate", - cwd: "crates/agent-gateway", - root: "workspace", +test("turn reducer keeps a deduped result while applying late result arguments", () => { + const turn = reduceTurnEvents([ + { + type: "tool_call", + id: "bash-duplicate-result", + name: "Bash", + round: 1, }, - content: [{ type: "text", text: "duplicate" }], - isError: false, - round: 1, - }); + { + type: "tool_result", + id: "bash-duplicate-result", + name: "Bash", + content: [{ type: "text", text: "duplicate" }], + isError: false, + round: 1, + }, + { + type: "tool_result", + id: "bash-duplicate-result", + name: "Bash", + arguments: { + command: "printf duplicate", + cwd: "crates/agent-gateway", + root: "workspace", + }, + content: [{ type: "text", text: "duplicate" }], + isError: false, + round: 1, + }, + ]); + const entries = turn.entries; assert.equal(entries.filter((entry) => entry.kind === "tool_call").length, 1); assert.equal(entries.filter((entry) => entry.kind === "tool_result").length, 1); @@ -1115,7 +1138,7 @@ test("buildOptimisticConversationTitle uses the first ten characters of the firs assert.equal(chatUi.buildOptimisticConversationTitle(" \n\n "), "新对话"); }); -test("GatewayTranscript renders committed and tail regions from disjoint inputs", () => { +test("GatewayTranscript renders folded and live regions as slices of one row list", () => { const fakeReact = { createContext(defaultValue) { return { defaultValue }; @@ -1188,34 +1211,84 @@ test("GatewayTranscript renders committed and tail regions from disjoint inputs" globalThis.window = undefined; globalThis.document = { visibilityState: "visible" }; - // Tail entries carry a run-id prefix (live-born ids from the transcript - // store); committed entries came from parsed history. - const committed = [ - { id: "user-1", kind: "user", text: "earlier question", attachments: [] }, - { id: "assistant-1", kind: "assistant", text: "earlier answer", round: 1 }, - ]; - const tail = [ - { id: "run-1/live-user-1", kind: "user", text: "queued from gui", attachments: [] }, - { id: "run-1/live-assistant-1", kind: "assistant", text: "reply", round: 1 }, - ]; + // Folded rows come from parsed history; live rows are born from the + // stream (a seeded prompt plus its streaming reply). Both regions come + // from one store assembly: foldedRows + liveRows. + const store = transcriptStoreModule.createTranscriptStore(); + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "earlier question", attachments: [] }, + { id: "ht:hu:m1>0", kind: "assistant", text: "earlier answer", round: 1 }, + ], + { mode: "replace" }, + ); + store.applyEvent({ + type: "user_message", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 1, + message: "queued from gui", + }); + store.applyEvent({ + type: "run_started", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 2, + }); + store.applyEvent({ + type: "token", + conversation_id: "conversation-1", + run_id: "run-1", + seq: 3, + text: "reply", + }); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.foldedRows.length, 2, "history rows fold; the live exchange stays below"); + assert.deepEqual( + [...snapshot.foldedRows, ...snapshot.liveRows].map((row) => row.kind), + ["user", "assistant", "user", "assistant"], + ); const transcriptTree = GatewayTranscript({ conversationId: "conversation-1", - entries: committed, - tailEntries: tail, + foldedRows: snapshot.foldedRows, + liveRows: snapshot.liveRows, + activeTurnKey: snapshot.activeTurnKey, isStreaming: true, }); - const liveStateNode = findTreeNode( + + const foldedRegionNode = findTreeNode( transcriptTree, - (node) => typeof node.type === "function" && Array.isArray(node.props?.tailEntries), + (node) => + typeof node.type === "function" && + Array.isArray(node.props?.rows) && + node.props?.conversationId === "conversation-1", + ); + assert.ok(foldedRegionNode, "virtualized region receives the folded rows"); + assert.deepEqual( + foldedRegionNode.props.rows.map((row) => row.key), + snapshot.foldedRows.map((row) => row.key), + ); + + const liveRegionNode = findTreeNode( + transcriptTree, + (node) => + typeof node.type === "function" && + Array.isArray(node.props?.rows) && + node.props?.isAgentMode !== undefined, + ); + assert.ok(liveRegionNode, "live region receives the rows past the fold boundary"); + assert.deepEqual( + liveRegionNode.props.rows.map((row) => row.key), + snapshot.liveRows.map((row) => row.key), ); - assert.ok(liveStateNode, "live region receives the tail entries"); assert.deepEqual( - liveStateNode.props.tailEntries.map((entry) => entry.id), - ["run-1/live-user-1", "run-1/live-assistant-1"], + liveRegionNode.props.rows.map((row) => row.kind), + ["user", "assistant"], ); - const liveTree = liveStateNode.type(liveStateNode.props); + const liveTree = liveRegionNode.type(liveRegionNode.props); assert.ok( findTreeNode( liveTree, @@ -1223,7 +1296,7 @@ test("GatewayTranscript renders committed and tail regions from disjoint inputs" typeof node.props?.className === "string" && node.props.className.includes("gateway-transcript-row-user"), ), - "tail user bubble renders before the live assistant output", + "live user bubble renders before the live assistant output", ); assert.ok( findTreeNode( @@ -1235,37 +1308,59 @@ test("GatewayTranscript renders committed and tail regions from disjoint inputs" liveTree, (node) => typeof node.type === "function" && node.props?.renderMode !== undefined, ); - assert.ok(assistantBubble, "tail assistant bubble rendered"); + assert.ok(assistantBubble, "live assistant bubble rendered"); assert.equal( assistantBubble.props.renderMode, "streaming", - "live-born entries keep the streaming render mode", + "live-born rows keep the streaming render mode", ); }); -test("transcript store history snapshot merge stays quiet for identical content", () => { +test("transcript store history refresh stays quiet for identical content", () => { + // The old pipeline kept a quiet refresh stable via text-hash dedup keys + // (renamed incoming ids were re-mapped onto the rendered ones). The new + // parser makes ids deterministic instead: reparsing the same persisted + // JSON yields identical ids, so an idle "enrich" refresh with unchanged + // content is a structural no-op — same snapshot, same row keys, and the + // exchange still renders exactly once. globalThis.window = undefined; globalThis.document = { visibilityState: "visible" }; const store = transcriptStoreModule.createTranscriptStore(); - store.applyHistorySnapshot([ - { id: "user-1", kind: "user", text: "hello", attachments: [] }, - { id: "assistant-1", kind: "assistant", text: "world", round: 1 }, + const messagesJson = JSON.stringify([ + { role: "user", content: "hello" }, + { role: "assistant", content: "world" }, ]); + const firstParse = chatUi.parseHistoryMessagesJson(messagesJson); + store.applyHistorySnapshot(firstParse, { mode: "replace" }); store.flush(); const first = store.getSnapshot(); - assert.equal(first.committed.length, 2); + assert.deepEqual( + first.foldedRows.map((row) => row.kind), + ["user", "assistant"], + ); + assert.equal(first.liveRows.length, 0, "history renders in the folded region"); - // The quiet upsert merge preserves rendered ids even when the incoming - // parse assigned fresh ones (dedup key identity, not id identity). - store.applyHistorySnapshot([ - { id: "user-renamed", kind: "user", text: "hello", attachments: [] }, - { id: "assistant-renamed", kind: "assistant", text: "world", round: 1 }, - ]); + const secondParse = chatUi.parseHistoryMessagesJson(messagesJson); + assert.deepEqual( + secondParse.map((entry) => entry.id), + firstParse.map((entry) => entry.id), + "reparsing the same JSON yields identical deterministic ids", + ); + + // Idle quiet refresh with identical content: nothing re-renders. + store.applyHistorySnapshot(secondParse, { mode: "enrich" }); store.flush(); const second = store.getSnapshot(); + assert.equal(second, first, "identical content leaves the snapshot untouched"); assert.deepEqual( - second.committed.map((entry) => entry.id), - ["user-1", "assistant-1"], + second.foldedRows.map((row) => row.key), + first.foldedRows.map((row) => row.key), + "row keys are stable across the refresh", + ); + assert.equal( + [...second.foldedRows, ...second.liveRows].filter((row) => row.kind === "user").length, + 1, + "the exchange renders exactly once (no duplicate prompt)", ); }); diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 4edfe95e..e1687b5a 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -734,15 +734,11 @@ export default function GatewayApp() { [lockHistoryTitlePosition, updateHistoryItems], ); - // Committed + tail entry count of a conversation's transcript store. + // Total entry count of a conversation's transcript store. const getConversationTranscriptEntryCount = useCallback( (targetConversationId: string) => { const store = transcriptStoreRegistry.peek(targetConversationId.trim()); - if (!store) { - return 0; - } - const snapshot = store.getSnapshot(); - return snapshot.committed.length + snapshot.tail.length; + return store ? store.getSnapshot().entryCount : 0; }, [transcriptStoreRegistry], ); @@ -854,7 +850,9 @@ export default function GatewayApp() { selectedHistoryRef.current = detail; setSelectedHistory(detail); } - transcriptStoreRegistry.get(conversationIdValue).applyHistorySnapshot(entries); + transcriptStoreRegistry + .get(conversationIdValue) + .applyHistorySnapshot(entries, { mode: "enrich" }); }, [api, getConversationTranscriptEntryCount, isConversationBusy, transcriptStoreRegistry], ); @@ -1716,9 +1714,9 @@ export default function GatewayApp() { pendingDisplayedConversationAutoBottomRef.current = conversationIdValue; } if (isChangingConversation && previousDisplayedConversationId) { - // Fold the previous conversation's settled tail so a revisit starts - // with a clean committed transcript. - transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTail(); + // Fold the previous conversation's settled turns so a revisit starts + // with a clean virtualized transcript. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTurns(); } draftConversationPinnedRef.current = false; @@ -1752,7 +1750,9 @@ export default function GatewayApp() { return; } setSelectedHistory(detail); - transcriptStoreRegistry.get(conversationIdValue).applyHistorySnapshot(entries); + transcriptStoreRegistry + .get(conversationIdValue) + .applyHistorySnapshot(entries, { mode: "replace" }); const detailWorkdir = detail.conversation?.cwd?.trim(); if (detailWorkdir) { conversationWorkdirsRef.current.set(conversationIdValue, detailWorkdir); @@ -2624,7 +2624,7 @@ export default function GatewayApp() { function startNewConversation(options?: { workdir?: string }) { const currentConversationId = conversationIdRef.current.trim(); if (currentConversationId) { - transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTail(); + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTurns(); optimisticTitleConversationIdsRef.current.delete(currentConversationId); clearCachedComposerDraft(currentConversationId); } @@ -2966,7 +2966,7 @@ export default function GatewayApp() { invalidateHistoryLoad(); markVisibleConversationRevision(); if (currentConversationId && currentConversationId !== targetConversationId) { - transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTail(); + transcriptStoreRegistry.peek(currentConversationId)?.foldSettledTurns(); } protectedConversationRef.current = targetConversationId; conversationIdRef.current = targetConversationId; @@ -3806,19 +3806,22 @@ export default function GatewayApp() { const item = historyItems.find((candidate) => candidate.id === selectedId); return item ? resolveConversationTitle(item, item.id) : ""; }, [historyItems, selectedHistoryId]); - const visibleTranscriptEntries = displayedTranscript.committed; - const transcriptTailEntries = displayedTranscript.tail; - const displayedTranscriptEntryCount = - visibleTranscriptEntries.length + transcriptTailEntries.length; + const transcriptFoldedRows = displayedTranscript.foldedRows; + const transcriptLiveRows = displayedTranscript.liveRows; + // Row count gates everything visual (empty state, error banner, loading + // screen): entryCount can be non-zero while nothing renders (meta-only + // entries), and hiding an error behind an invisible entry would strand it. + const displayedTranscriptRowCount = + transcriptFoldedRows.length + transcriptLiveRows.length; const transcriptHistoryLoading = - historyDetailLoading && displayedTranscriptEntryCount === 0; + historyDetailLoading && displayedTranscriptRowCount === 0; const selectedHistoryHasMore = selectedHistory?.conversation_id === displayedConversationId && selectedHistory.has_more === true; const loadingOlderHistory = historyDetailLoading && selectedHistory?.conversation_id === displayedConversationId && - displayedTranscriptEntryCount > 0; + displayedTranscriptRowCount > 0; const handleLoadFullHistory = useCallback(() => { if (!api || !displayedConversationId) { return; @@ -3827,7 +3830,7 @@ export default function GatewayApp() { fullHistory: true, }); }, [api, displayedConversationId]); - const transcriptTailHasEntries = transcriptTailEntries.length > 0; + const transcriptHasLiveRows = transcriptLiveRows.length > 0; useEffect(() => { if (typeof document === "undefined") { return; @@ -3844,7 +3847,7 @@ export default function GatewayApp() { (displayedHasPendingCommand ? CHAT_RUNTIME_PREPARING_STATUS : null); const transcriptToolStatusIsCompaction = displayedTranscript.toolStatusIsCompaction; const composerIsSending = transcriptBusy; - const transcriptError = displayedTranscriptEntryCount === 0 ? null : chatError; + const transcriptError = displayedTranscriptRowCount === 0 ? null : chatError; const composerCompactionBlocked = transcriptToolStatusIsCompaction; const composerInputDisabled = !status?.online || historyDetailLoading || composerCompactionBlocked; @@ -3905,8 +3908,8 @@ export default function GatewayApp() { ) { return; } - // Switching away folds the settled tail so revisits start clean. - transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTail(); + // Switching away folds the settled turns so revisits start clean. + transcriptStoreRegistry.peek(previousDisplayedConversationId)?.foldSettledTurns(); pendingDisplayedConversationAutoBottomRef.current = nextDisplayedConversationId; }, [displayedConversationId, transcriptStoreRegistry]); @@ -3916,7 +3919,7 @@ export default function GatewayApp() { !targetConversationId || historyDetailLoading || displayedConversationId.trim() !== targetConversationId || - displayedTranscriptEntryCount === 0 + displayedTranscriptRowCount === 0 ) { return; } @@ -3926,7 +3929,7 @@ export default function GatewayApp() { pendingDisplayedConversationAutoBottomRef.current = null; }, [ displayedConversationId, - displayedTranscriptEntryCount, + displayedTranscriptRowCount, historyDetailLoading, refreshTranscriptScrollState, stickTranscriptToBottom, @@ -3987,7 +3990,7 @@ export default function GatewayApp() { ]); useLayoutEffect(() => { - if (transcriptBusy || transcriptTailHasEntries) { + if (transcriptBusy || transcriptHasLiveRows) { syncTranscriptAutoScroll(); } refreshTranscriptScrollState(); @@ -3996,9 +3999,9 @@ export default function GatewayApp() { refreshTranscriptScrollState, syncTranscriptAutoScroll, transcriptBusy, - transcriptTailEntries, - transcriptTailHasEntries, - visibleTranscriptEntries, + transcriptHasLiveRows, + transcriptFoldedRows, + transcriptLiveRows, transcriptToolStatus, ]); @@ -4336,7 +4339,7 @@ export default function GatewayApp() { {settingsSyncError ? (
{settingsSyncError}
) : null} - {chatError && displayedTranscriptEntryCount === 0 ? ( + {chatError && displayedTranscriptRowCount === 0 ? (
{chatError}
) : null} @@ -4345,8 +4348,9 @@ export default function GatewayApp() { ) { + return row.origin === "stream" ? ("streaming" as const) : ("static" as const); } -const EMPTY_TAIL_ENTRIES: ChatEntry[] = []; const TRANSCRIPT_ROW_ESTIMATED_HEIGHT = 260; const TRANSCRIPT_ROW_GAP = 18; const TRANSCRIPT_ROW_OVERSCAN_COUNT = 5; type GatewayTranscriptVirtualItem = | { key: string; kind: "loadRemoteHistory" } - | { key: string; kind: "history"; item: GatewayTranscriptItem }; + | { key: string; kind: "row"; row: TranscriptRow }; function resolveNearestScrollViewport(element: HTMLElement | null) { return element?.closest("[data-radix-scroll-area-viewport]") as HTMLDivElement | null; @@ -207,7 +203,7 @@ function HistoryLoadingState(props: { title?: string }) { } function CheckpointCard(props: { - item: Extract[number], { kind: "checkpoint" }>; + item: Extract; readOnly?: boolean; }) { const { item, readOnly = false } = props; @@ -800,9 +796,9 @@ function useGatewayCommitDetailsLoader( ); } -const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { +const GatewayTranscriptFoldedRegion = memo(function GatewayTranscriptFoldedRegion(props: { conversationId?: string; - items: GatewayTranscriptItem[]; + rows: readonly TranscriptRow[]; scrollViewport: HTMLDivElement | null; hasMoreHistory?: boolean; isLoadingMoreHistory?: boolean; @@ -823,7 +819,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { }) { const { conversationId, - items, + rows, scrollViewport, hasMoreHistory, isLoadingMoreHistory, @@ -841,7 +837,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { const { locale, t } = useLocale(); const [copiedMessageId, setCopiedMessageId] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); - const historyIdentityKey = `${conversationId ?? ""}\n${items[0]?.id ?? ""}`; + const historyIdentityKey = `${conversationId ?? ""}\n${rows[0]?.key ?? ""}`; const loadCommitDetails = useGatewayCommitDetailsLoader( workspaceRoot, gitClient, @@ -856,40 +852,31 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { if (!editingMessageId) { return; } - const hasEditingItem = items.some( - (item) => item.kind === "user" && item.id === editingMessageId, + const hasEditingRow = rows.some( + (row) => row.kind === "user" && row.key === editingMessageId, ); - if (!hasEditingItem) { + if (!hasEditingRow) { setEditingMessageId(null); } - }, [editingMessageId, items]); + }, [editingMessageId, rows]); + // Row keys are unique by construction (the row builder's single canonical + // pass) and feed both React reconciliation and the virtualizer's + // measurement cache directly. const virtualItems = useMemo(() => { const next: GatewayTranscriptVirtualItem[] = []; if (!readOnly && hasMoreHistory) { next.push({ key: "load-remote-history", kind: "loadRemoteHistory" }); } - // Row keys feed both React reconciliation and the virtualizer's - // measurement cache; a duplicate key collapses row positions onto each - // other. The store guarantees unique entry ids, but keep the renderer - // safe against any upstream regression by suffixing repeats. - const seenKeys = new Set(); - for (const item of items) { - let key = item.id; - if (seenKeys.has(key)) { - let suffix = 2; - while (seenKeys.has(`${key}#${suffix}`)) { - suffix += 1; - } - key = `${key}#${suffix}`; - } - seenKeys.add(key); - next.push({ key, kind: "history", item }); + for (const row of rows) { + next.push({ key: row.key, kind: "row", row }); } return next; - }, [hasMoreHistory, items, readOnly]); + }, [hasMoreHistory, rows, readOnly]); const getTranscriptItemKey = useCallback( - (index: number) => virtualItems[index]?.key ?? index, + // The index branch is unreachable (count === virtualItems.length); it + // only satisfies the type. + (index: number) => virtualItems[index]?.key ?? `virtual-${index}`, [virtualItems], ); const transcriptVirtualizer = useVirtualizer({ @@ -939,11 +926,11 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { ); } - const item = virtualItem.item; - if (item.kind === "user") { - const isCopied = copiedMessageId === item.id; - const isEditing = editingMessageId === item.id; - const effectiveMessageRef = item.messageRef; + const row = virtualItem.row; + if (row.kind === "user") { + const isCopied = copiedMessageId === row.key; + const isEditing = editingMessageId === row.key; + const effectiveMessageRef = row.messageRef; const missingStableRef = !effectiveMessageRef; const editDisabled = readOnly || isStreaming || !onResendFromEdit || missingStableRef; const editTitle = missingStableRef @@ -961,8 +948,8 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { > {isEditing && effectiveMessageRef ? ( setEditingMessageId(null)} @@ -974,8 +961,8 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { ) : (
{ - void navigator.clipboard.writeText(item.text).then(() => { - setCopiedMessageId(item.id); + void navigator.clipboard.writeText(row.text).then(() => { + setCopiedMessageId(row.key); window.setTimeout(() => { setCopiedMessageId((current) => - current === item.id ? null : current, + current === row.key ? null : current, ); }, 1500); }); @@ -1012,7 +999,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { disabled={editDisabled} onClick={() => { if (effectiveMessageRef) { - setEditingMessageId(item.id); + setEditingMessageId(row.key); } }} > @@ -1026,7 +1013,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { ); } - if (item.kind === "assistant") { + if (row.kind === "assistant") { return (
@@ -1050,7 +1037,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { ); } - if (item.kind === "checkpoint") { + if (row.kind === "checkpoint") { return (
- +
); } @@ -1075,7 +1062,7 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: {
Error
-
{item.text}
+
{row.text}
@@ -1085,9 +1072,11 @@ const GatewayTranscriptHistory = memo(function GatewayTranscriptHistory(props: { ); }); -const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(props: { - tailEntries: ChatEntry[]; - lastHistoryKind?: GatewayTranscriptItem["kind"]; +const GatewayTranscriptLiveRegion = memo(function GatewayTranscriptLiveRegion(props: { + // The flow rows past the fold boundary (settled + streaming turns). + rows: readonly TranscriptRow[]; + lastFoldedRowKind?: TranscriptRow["kind"]; + activeTurnKey?: string | null; isStreaming: boolean; isAgentMode: boolean; showUsage: boolean; @@ -1099,8 +1088,9 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop toolStatusIsCompaction: boolean; }) { const { - tailEntries, - lastHistoryKind, + rows, + lastFoldedRowKind, + activeTurnKey, isStreaming, isAgentMode, showUsage, @@ -1112,21 +1102,33 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop toolStatusIsCompaction, } = props; const loadCommitDetails = useGatewayCommitDetailsLoader(workspaceRoot, gitClient); - const liveItems = useMemo(() => buildTranscriptItems(tailEntries), [tailEntries]); - const activeLiveAssistantIndex = useMemo(() => { - const lastItem = liveItems.at(-1); - if (lastItem?.kind !== "assistant") { + // The live article: the streaming turn's trailing assistant row while a + // run is active, else the trailing assistant row of the flow. It keeps its + // in-flight structural state regardless of `isStreaming`: it stays in the + // flow on purpose after the run ends (folding happens at the next + // run_started), and gating the structure on `isStreaming` would re-render + // the article in one frame (thinking collapses, tool indicators clear) and + // produce a visible flash. The caret tracks `isStreaming` separately so it + // hides cleanly once the stream actually ends. + const liveAssistantIndex = useMemo(() => { + if (activeTurnKey) { + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + if (row?.kind === "assistant" && row.turnKey === activeTurnKey) { + return index; + } + } return -1; } - return liveItems.length - 1; - }, [liveItems]); + return rows.length > 0 && rows[rows.length - 1]?.kind === "assistant" ? rows.length - 1 : -1; + }, [activeTurnKey, rows]); const displayedToolStatus = useMemo( () => normalizeLiveToolStatus(toolStatus ?? null), [toolStatus], ); const displayedToolStatusIsCompaction = toolStatusIsCompaction; // The pending bubble (typing dots / vibing / compacting) shows while busy - // and the tail has no assistant-ish output yet. + // and the transcript has no assistant output for the active exchange yet. const shouldShowPendingLiveBubble = useMemo(() => { if (!isStreaming) { return false; @@ -1134,24 +1136,24 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop if (displayedToolStatusIsCompaction) { return true; } - const lastItemKind = liveItems.at(-1)?.kind ?? lastHistoryKind; - return !lastItemKind || lastItemKind === "user" || lastItemKind === "checkpoint"; - }, [displayedToolStatusIsCompaction, isStreaming, lastHistoryKind, liveItems]); + const lastRowKind = rows[rows.length - 1]?.kind ?? lastFoldedRowKind; + return !lastRowKind || lastRowKind === "user" || lastRowKind === "checkpoint"; + }, [displayedToolStatusIsCompaction, isStreaming, lastFoldedRowKind, rows]); - if (liveItems.length === 0 && !shouldShowPendingLiveBubble) { + if (rows.length === 0 && !shouldShowPendingLiveBubble) { return null; } return ( <> - {liveItems.map((item, index) => { - if (item.kind === "user") { + {rows.map((row, index) => { + if (row.kind === "user") { return ( -
+
+
- +
+
); } - if (item.kind === "error") { - return ( -
-
-
Error
-
-
{item.text}
-
+ return ( +
+
+
Error
+
+
{row.text}
-
- ); - } - - return null; +
+
+ ); })} {shouldShowPendingLiveBubble ? (
@@ -1256,10 +1246,13 @@ const GatewayTranscriptLiveState = memo(function GatewayTranscriptLiveState(prop ); }); +const EMPTY_LIVE_ROWS: readonly TranscriptRow[] = []; + export function GatewayTranscript({ conversationId, - entries, - tailEntries = EMPTY_TAIL_ENTRIES, + foldedRows, + liveRows = EMPTY_LIVE_ROWS, + activeTurnKey = null, error, toolStatus, toolStatusIsCompaction = false, @@ -1282,21 +1275,20 @@ export function GatewayTranscript({ redactToolContent = false, }: GatewayTranscriptProps) { const { t } = useLocale(); - const historyItems = useMemo(() => buildTranscriptItems(entries), [entries]); const transcriptListRef = useRef(null); const [transcriptScrollViewport, setTranscriptScrollViewport] = useState(null); - const hasLiveEntries = tailEntries.length > 0; - const lastHistoryKind = historyItems.at(-1)?.kind; + const rowCount = foldedRows.length + liveRows.length; + const lastFoldedRowKind = foldedRows[foldedRows.length - 1]?.kind; const inlineErrorText = error?.trim() ?? ""; - const shouldShowInlineError = useMemo( - () => - inlineErrorText.length > 0 && - !historyItems.some( - (item) => item.kind === "error" && item.text.trim() === inlineErrorText, - ), - [historyItems, inlineErrorText], - ); + const shouldShowInlineError = useMemo(() => { + if (inlineErrorText.length === 0) { + return false; + } + const matches = (row: TranscriptRow) => + row.kind === "error" && row.text.trim() === inlineErrorText; + return !foldedRows.some(matches) && !liveRows.some(matches); + }, [foldedRows, liveRows, inlineErrorText]); useLayoutEffect(() => { const nextViewport = resolveNearestScrollViewport(transcriptListRef.current); @@ -1305,11 +1297,11 @@ export function GatewayTranscript({ ); }); - if (historyItems.length === 0 && !hasLiveEntries && isLoading) { + if (rowCount === 0 && isLoading) { return ; } - if (historyItems.length === 0 && !hasLiveEntries && !isStreaming) { + if (rowCount === 0 && !isStreaming) { const showNoModelsState = !hasModels; return (
@@ -1369,9 +1361,9 @@ export function GatewayTranscript({ return (
- {!readOnly ? ( - void): () => void; - // Stream plumbing. - applySync(result: ConversationSubscribeResult): void; - applyEvent(event: ConversationStreamEvent): void; - // Optimistic local echo for a command this client is submitting. The - // matching seeded user_message adopts the entry by client_request_id, - // keeping this id — the user bubble never remounts. - addOptimisticUserEntry(params: { - clientRequestId: string; - text: string; - attachments?: UserAttachments; - }): void; - removeOptimisticUserEntry(clientRequestId: string): void; - // Failure surfaced outside the stream (command never bound). - appendLocalError(message: string): void; - // History snapshot (initial load / quiet upsert merge). Preserves existing - // ids by dedup key and leaves tail entries in place — committed only takes - // history entries the tail is not already showing. - applyHistorySnapshot(entries: ChatEntry[]): void; - // Fold the settled tail into committed outside of run_started (used when - // the conversation is switched away; keeps the next mount clean). - foldSettledTail(): void; - reset(): void; - flush(): void; -}; - -type UserAttachments = Extract["attachments"]; - -const EMPTY_SNAPSHOT: TranscriptSnapshot = { - committed: [], - tail: [], - activeRun: null, - toolStatus: null, - toolStatusIsCompaction: false, - foldRevision: 0, - revision: 0, -}; - -function runIdPrefix(runId: string): string { - return runId ? `${runId}/` : ""; -} - -function optimisticEntryId(clientRequestId: string): string { - return `optimistic-user-${clientRequestId}`; -} - -export function createTranscriptStore(): TranscriptStore { - let committed: ChatEntry[] = []; - let settled: ChatEntry[] = []; - let live: ChatEntry[] = []; - let activeRun: StreamRunActivity | null = null; - let toolStatus: string | null = null; - let toolStatusIsCompaction = false; - let foldRevision = 0; - // Idempotency cursor: the highest log seq already applied. Re-subscribes - // replay the buffered log into a store that persists across conversation - // switches; without the cursor every replayed event would duplicate its - // entry (or double-append token text). - let lastSeq = 0; - - let snapshot = EMPTY_SNAPSHOT; - let dirty = false; - let rafId: number | null = null; - const listeners = new Set<() => void>(); - // clientRequestId → optimistic entry id, until adopted or removed. - const pendingOptimistic = new Map(); - // runId → ids of entries that belong to it but carry foreign ids (adopted - // optimistic entries), so run_queued compensation can remove them too. - const adoptedEntryIds = new Map(); - - const buildSnapshot = (): TranscriptSnapshot => ({ - committed, - tail: settled.length === 0 ? live : live.length === 0 ? settled : [...settled, ...live], - activeRun, - toolStatus, - toolStatusIsCompaction, - foldRevision, - revision: snapshot.revision + 1, - }); - - const emit = () => { - for (const listener of listeners) { - listener(); - } - }; - const commit = () => { - rafId = null; - if (!dirty) { - return; - } - dirty = false; - snapshot = buildSnapshot(); - emit(); - }; - const schedule = (flush?: boolean) => { - dirty = true; - if (flush) { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - commit(); - return; - } - if (rafId === null && typeof requestAnimationFrame === "function") { - rafId = requestAnimationFrame(commit); - } else if (typeof requestAnimationFrame !== "function") { - commit(); - } - }; - - const foldSettled = (flush: boolean) => { - if (settled.length === 0) { - return; - } - committed = [...committed, ...settled]; - settled = []; - foldRevision += 1; - schedule(flush); - }; - - const setToolStatus = (status: string | null, isCompaction: boolean, flush?: boolean) => { - const next = status && status.trim() ? status.trim() : null; - const nextCompaction = Boolean(next) && isCompaction; - if (toolStatus === next && toolStatusIsCompaction === nextCompaction) { - return; - } - toolStatus = next; - toolStatusIsCompaction = nextCompaction; - schedule(flush); - }; - - const adoptOptimisticEntry = (event: ConversationStreamEvent, runId: string): boolean => { - const clientRequestId = - typeof (event as { client_request_id?: unknown }).client_request_id === "string" - ? ((event as { client_request_id: string }).client_request_id ?? "").trim() - : ""; - if (!clientRequestId) { - return false; - } - const entryId = pendingOptimistic.get(clientRequestId); - if (!entryId) { - return false; - } - pendingOptimistic.delete(clientRequestId); - // The optimistic entry already shows this exact message; keep it (and its - // id) and record the ownership for run_queued compensation. - if (runId) { - const owned = adoptedEntryIds.get(runId) ?? []; - owned.push(entryId); - adoptedEntryIds.set(runId, owned); - } - return live.some((entry) => entry.id === entryId); - }; - - const removeRunEntries = (runId: string) => { - const prefix = runIdPrefix(runId); - const adopted = new Set(adoptedEntryIds.get(runId) ?? []); - adoptedEntryIds.delete(runId); - const matches = (entry: ChatEntry) => - (prefix !== "" && entry.id.startsWith(prefix)) || adopted.has(entry.id); - const nextLive = live.filter((entry) => !matches(entry)); - const nextSettled = settled.filter((entry) => !matches(entry)); - if (nextLive.length !== live.length || nextSettled.length !== settled.length) { - live = nextLive; - settled = nextSettled; - schedule(true); - } - }; - - // True when the entry belongs to another run than `runId`: prefixed with a - // foreign run id, adopted by a foreign run, or a not-yet-adopted optimistic - // echo. Such entries must survive the finishing run's settle sweep. - const isForeignOwnedEntry = (entry: ChatEntry, runId: string): boolean => { - const prefix = runIdPrefix(runId); - if (prefix !== "" && entry.id.startsWith(prefix)) { - return false; - } - if (adoptedEntryIds.get(runId)?.includes(entry.id)) { - return false; - } - if (pendingOptimistic.size > 0) { - for (const optimisticId of pendingOptimistic.values()) { - if (optimisticId === entry.id) { - return true; - } - } - } - for (const [otherRunId, ownedIds] of adoptedEntryIds) { - if (otherRunId !== runId && ownedIds.includes(entry.id)) { - return true; - } - } - const slashIndex = entry.id.indexOf("/"); - return slashIndex > 0 && !entry.id.startsWith("local/"); - }; - - const applyRunFinished = (event: ConversationStreamEvent) => { - const runId = readEventRunId(event); - if (activeRun && runId !== "" && runId !== activeRun.runId) { - // Stray terminal for a non-active run (the gateway appends these - // deliberately, e.g. failing a superseded queued run). Never settle the - // active segment; just drop the stray run's provisional entries. - removeRunEntries(runId); - return; - } - const payload = event as { - status?: string; - message?: string; - reason?: string; - }; - if (payload.status === "failed" && payload.message && payload.reason !== "superseded") { - live = pushChatEvent( - live, - { type: "error", message: payload.message } as ChatEvent, - { entryIdPrefix: runIdPrefix(runId) }, - ); - } - // Settle only entries owned by the finished run (or unowned, e.g. local/ - // errors); foreign-owned entries — seeded user_messages and optimistic - // echoes of not-yet-started runs — stay live for their own run. - const settling = live.filter((entry) => !isForeignOwnedEntry(entry, runId)); - const remaining = live.filter((entry) => isForeignOwnedEntry(entry, runId)); - adoptedEntryIds.delete(runId); - settled = settling.length === 0 ? settled : [...settled, ...settling]; - live = remaining; - activeRun = null; - setToolStatus(null, false); - schedule(true); - }; - - const applyDelta = (event: ConversationStreamEvent, runId: string) => { - if (event.type === "user_message" && adoptOptimisticEntry(event, runId)) { - schedule(true); - return; - } - const options: PushChatEventOptions = { entryIdPrefix: runIdPrefix(runId) }; - const next = pushChatEvent(live, event as ChatEvent, options); - if (next !== live) { - live = next; - schedule(event.type === "user_message"); - } - }; - - const rebuildLiveFromSnapshot = (entriesJson: string, runId: string) => { - const entries = parseSnapshotEntries(entriesJson); - if (entries.length === 0 && live.length === 0) { - return; - } - // Snapshot entries carry their own (runtime-assigned) ids; prefix them so - // they cannot collide with entries of other runs. - const prefix = runIdPrefix(runId); - live = entries.map((entry) => - prefix && !entry.id.startsWith(prefix) ? { ...entry, id: `${prefix}${entry.id}` } : entry, - ); - schedule(true); - }; - - return { - getSnapshot: () => snapshot, - subscribe: (listener) => { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - - applySync: (result) => { - if (result.reset) { - // Seq continuity broke (gateway restart / buffer gap). Committed - // history stays valid; the settled tail is finished-run content with - // stable ids, so fold it into committed instead of dropping the last - // reply, then rebuild the live segment from scratch. The replay after - // a reset only carries events the tail was not built from (gap - // resets replay past the evicted prefix; epoch resets replay - // new-epoch runs with disjoint id prefixes), so zero the cursor. - lastSeq = 0; - foldSettled(false); - live = []; - activeRun = null; - toolStatus = null; - toolStatusIsCompaction = false; - if (result.snapshot) { - rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); - lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); - } - } else if (result.snapshot && live.length === 0) { - // Late join mid-run where the buffer cannot cover the run start. The - // snapshot folds every event through asOfSeq into its entries; - // advancing the cursor drops the overlapping replay below. - rebuildLiveFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); - lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); - } - activeRun = result.activity; - if (result.activity) { - setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); - } else if (result.reset) { - setToolStatus(null, false); - } - for (const event of result.events) { - applyOne(event); - } - lastSeq = Math.max(lastSeq, result.latestSeq); - schedule(true); - }, - - applyEvent: (event) => { - applyOne(event); - }, - - addOptimisticUserEntry: ({ clientRequestId, text, attachments }) => { - const id = optimisticEntryId(clientRequestId); - if (live.some((entry) => entry.id === id)) { - return; - } - pendingOptimistic.set(clientRequestId, id); - live = [ - ...live, - { - id, - kind: "user", - text, - attachments: (attachments ?? []) as UserAttachments, - }, - ]; - schedule(true); - }, - - removeOptimisticUserEntry: (clientRequestId) => { - const id = pendingOptimistic.get(clientRequestId) ?? optimisticEntryId(clientRequestId); - pendingOptimistic.delete(clientRequestId); - const next = live.filter((entry) => entry.id !== id); - if (next.length !== live.length) { - live = next; - schedule(true); - } - }, - - appendLocalError: (message) => { - live = pushChatEvent(live, { type: "error", message } as ChatEvent, { - entryIdPrefix: "local/", - }); - schedule(true); - }, - - applyHistorySnapshot: (entries) => { - // Preserve ids of entries we already render (matched by dedup key — - // tool entries match by tool-call identity, immune to live-path - // trimming) and keep tail entries where they are: committed only takes - // what the tail is not showing. - // - // Dedup keys are NOT unique across the transcript (a repeated user - // prompt, id-less tool calls sharing name + round across runs), so - // matching is occurrence-aware: each rendered occurrence can be - // adopted at most once, consumed in transcript order — committed - // copies (older) before tail copies (newest). Two history entries can - // therefore never adopt the same id; duplicate ids corrupt both React - // keying and the virtualizer's measurement cache. - const committedQueueByKey = new Map(); - for (const entry of committed) { - const key = chatEntryDedupKey(entry); - const queue = committedQueueByKey.get(key); - if (queue) { - queue.push(entry); - } else { - committedQueueByKey.set(key, [entry]); - } - } - const settledIndexQueueByKey = new Map(); - settled.forEach((entry, index) => { - const key = chatEntryDedupKey(entry); - const queue = settledIndexQueueByKey.get(key); - if (queue) { - queue.push(index); - } else { - settledIndexQueueByKey.set(key, [index]); - } - }); - const liveIndexQueueByKey = new Map(); - live.forEach((entry, index) => { - const key = chatEntryDedupKey(entry); - const queue = liveIndexQueueByKey.get(key); - if (queue) { - queue.push(index); - } else { - liveIndexQueueByKey.set(key, [index]); - } - }); - - // Tail ids stay as-is and must never be re-issued to a committed entry. - const usedIds = new Set(); - for (const entry of settled) { - usedIds.add(entry.id); - } - for (const entry of live) { - usedIds.add(entry.id); - } - const uniqueId = (candidate: string) => { - if (!usedIds.has(candidate)) { - return candidate; - } - let suffix = 2; - while (usedIds.has(`${candidate}#${suffix}`)) { - suffix += 1; - } - return `${candidate}#${suffix}`; - }; - - const nextCommitted: ChatEntry[] = []; - let nextSettled = settled; - let nextLive = live; - let tailChanged = false; - for (const entry of entries) { - const key = chatEntryDedupKey(entry); - const committedMatch = committedQueueByKey.get(key)?.shift(); - if (committedMatch) { - // Same logical entry: keep the rendered id, upgrade the payload - // (history carries full, untrimmed tool content). - const id = uniqueId(committedMatch.id); - nextCommitted.push(entry.id === id ? entry : { ...entry, id }); - usedIds.add(id); - continue; - } - const settledIndex = settledIndexQueueByKey.get(key)?.shift(); - if (settledIndex !== undefined) { - // Already rendered in the tail region (folds in at run_started). - // Upgrade the tail entry in place — same id, same position — with - // the history payload: this is what gives the just-settled user - // bubble its messageRef (edit-resend affordance) and tool cards - // their full, untrimmed content. - const existing = nextSettled[settledIndex]; - if (existing) { - if (nextSettled === settled) { - nextSettled = settled.slice(); - } - nextSettled[settledIndex] = { ...entry, id: existing.id }; - tailChanged = true; - } - continue; - } - const liveIndex = liveIndexQueueByKey.get(key)?.shift(); - if (liveIndex !== undefined) { - const existing = nextLive[liveIndex]; - if (existing) { - if (nextLive === live) { - nextLive = live.slice(); - } - nextLive[liveIndex] = { ...entry, id: existing.id }; - tailChanged = true; - } - continue; - } - const id = uniqueId(entry.id); - nextCommitted.push(entry.id === id ? entry : { ...entry, id }); - usedIds.add(id); - } - if (tailChanged) { - settled = nextSettled; - live = nextLive; - } - const committedUnchanged = - nextCommitted.length === committed.length && - nextCommitted.every((entry, index) => entry === committed[index]); - if (!tailChanged && committedUnchanged) { - return; - } - committed = nextCommitted; - schedule(true); - }, - - foldSettledTail: () => { - foldSettled(true); - }, - - reset: () => { - committed = []; - settled = []; - live = []; - activeRun = null; - toolStatus = null; - toolStatusIsCompaction = false; - lastSeq = 0; - pendingOptimistic.clear(); - adoptedEntryIds.clear(); - schedule(true); - }, - - flush: () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } - commit(); - }, - }; - - // edit_resend: truncate the transcript at the edited user message. The new - // user_message (adopting the optimistic entry) follows in the stream. - function applyRebased(event: ConversationStreamEvent) { - const ref = (event as { base_message_ref?: unknown }).base_message_ref; - if (!ref || typeof ref !== "object") { - return; - } - const refValue = ref as Record; - const messageId = - typeof refValue.message_id === "string" ? refValue.message_id.trim() : ""; - const contentHash = - typeof refValue.content_hash === "string" ? refValue.content_hash.trim() : ""; - if (!messageId && !contentHash) { - return; - } - foldSettled(true); - const index = committed.findIndex( - (entry) => - entry.kind === "user" && - entry.messageRef != null && - ((messageId !== "" && entry.messageRef.messageId === messageId) || - (contentHash !== "" && entry.messageRef.contentHash === contentHash)), - ); - if (index < 0) { - return; - } - committed = committed.slice(0, index); - schedule(true); - } - - function applyOne(event: ConversationStreamEvent) { - const seq = readEventSeq(event); - if (seq > 0) { - if (seq <= lastSeq) { - // Already applied (resubscribe replay / snapshot overlap). - return; - } - lastSeq = seq; - } - const runId = readEventRunId(event); - switch (event.type) { - case "run_started": { - // Fold the previous reply into history in the same commit that will - // render the new run — the one intentional layout change. - foldSettled(true); - activeRun = { - runId, - state: "running", - startedSeq: readEventSeq(event), - toolStatus: null, - toolStatusIsCompaction: false, - clientRequestId: - typeof (event as { client_request_id?: unknown }).client_request_id === "string" - ? (event as { client_request_id: string }).client_request_id - : undefined, - updatedAt: Date.now(), - }; - setToolStatus(null, false, true); - return; - } - case "run_finished": { - applyRunFinished(event); - return; - } - case "run_queued": { - // The prompt went into the desktop queue: drop its provisional - // entries; the queue UI shows it instead. - removeRunEntries(runId); - if (activeRun?.runId === runId) { - activeRun = null; - schedule(true); - } - return; - } - case "rebased": { - applyRebased(event); - return; - } - case "snapshot": { - const payload = event as { entries_json?: string; as_of_seq?: number }; - rebuildLiveFromSnapshot(payload.entries_json ?? "", runId); - if (typeof payload.as_of_seq === "number" && Number.isFinite(payload.as_of_seq)) { - // The snapshot content covers the log through as_of_seq; drop the - // overlapping tail of any concurrent replay. - lastSeq = Math.max(lastSeq, Math.floor(payload.as_of_seq)); - } - const status = (event as { tool_status?: string | null }).tool_status ?? null; - setToolStatus( - typeof status === "string" ? status : null, - (event as { tool_status_is_compaction?: boolean }).tool_status_is_compaction === true, - true, - ); - return; - } - case "tool_status": { - const status = (event as { status?: string | null }).status ?? null; - setToolStatus( - typeof status === "string" ? status : null, - (event as { isCompaction?: boolean }).isCompaction === true, - ); - if (activeRun && activeRun.runId === runId) { - activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction }; - } - return; - } - default: { - applyDelta(event, runId); - } - } - } -} - -function isSnapshotChatEntry(value: unknown): value is ChatEntry { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; - } - const v = value as Record; - if (typeof v.id !== "string" || typeof v.kind !== "string") { - return false; - } - switch (v.kind) { - case "user": - return typeof v.text === "string" && Array.isArray(v.attachments); - case "assistant": - case "thinking": - case "error": - return typeof v.text === "string"; - case "tool_call": - return v.toolCall != null && typeof v.toolCall === "object"; - case "tool_result": - return v.toolResult != null && typeof v.toolResult === "object"; - case "hosted_search": - return v.hostedSearch != null && typeof v.hostedSearch === "object"; - default: - return false; - } -} - -export function parseSnapshotEntries(json: string | undefined): ChatEntry[] { - const raw = typeof json === "string" ? json.trim() : ""; - if (!raw) { - return []; - } - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; - } catch { - return []; - } -} diff --git a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts index 201805de..76a32870 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "r import type { GatewayWebSocketClientLike } from "@/lib/gatewaySocket"; import type { ActivityStore } from "./activityStore"; import type { ConversationStreamEvent, ConversationSubscribeResult } from "./streamTypes"; -import type { TranscriptSnapshot, TranscriptStore } from "./transcriptStore"; -import { createTranscriptStore } from "./transcriptStore"; +import type { TranscriptSnapshot, TranscriptStore } from "../transcript/transcriptStore"; +import { createTranscriptStore } from "../transcript/transcriptStore"; // Registry of transcript stores, one per conversation. Stores persist across // conversation switches so revisiting a conversation keeps its tail state; @@ -48,8 +48,10 @@ export function createTranscriptStoreRegistry(): TranscriptStoreRegistry { } const EMPTY_TRANSCRIPT: TranscriptSnapshot = { - committed: [], - tail: [], + foldedRows: [], + liveRows: [], + activeTurnKey: null, + entryCount: 0, activeRun: null, toolStatus: null, toolStatusIsCompaction: false, diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/historyAlignment.ts b/crates/agent-gateway/web/src/lib/chat/transcript/historyAlignment.ts new file mode 100644 index 00000000..0c2ce911 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/transcript/historyAlignment.ts @@ -0,0 +1,395 @@ +import { type ChatEntry, safeStringify } from "@/lib/chatUi"; + +import type { HistoryApplyMode, Turn, UserChatEntry } from "./types"; + +// History ↔ stream reconciliation. The persisted history and the streamed +// turns describe the same conversation, so they are aligned structurally — +// by persisted message refs where available, by end-anchored position +// otherwise — never by content hashing. A logical turn renders from exactly +// one source: while a stream turn covers it, its history twin is +// enrichment-only (messageRef for edit/resend, untrimmed tool payloads) and +// is never emitted as rows. + +export type HistoryTurn = { + user: UserChatEntry | null; + entries: ChatEntry[]; +}; + +export type AlignResult = { + historyEntries: ChatEntry[]; + turns: Turn[]; + changed: boolean; +}; + +export function groupHistoryEntriesIntoTurns(entries: ChatEntry[]): HistoryTurn[] { + const turns: HistoryTurn[] = []; + let current: HistoryTurn | null = null; + for (const entry of entries) { + if (entry.kind === "user") { + if (current) { + turns.push(current); + } + current = { user: entry, entries: [] }; + continue; + } + if (!current) { + // Window cut mid-turn: leading assistant-side content without its user + // message forms a headless turn. + current = { user: null, entries: [] }; + } + current.entries.push(entry); + } + if (current) { + turns.push(current); + } + return turns; +} + +function flattenHistoryTurns(turns: HistoryTurn[]): ChatEntry[] { + const entries: ChatEntry[] = []; + for (const turn of turns) { + if (turn.user) { + entries.push(turn.user); + } + entries.push(...turn.entries); + } + return entries; +} + +function historyTurnHasAssistantContent(turn: HistoryTurn): boolean { + return turn.entries.some((entry) => entry.kind !== "checkpoint"); +} + +function isLocalTurn(turn: Turn): boolean { + return turn.key.startsWith("local:"); +} + +function isActiveTurn(turn: Turn): boolean { + return turn.phase === "pending" || turn.phase === "streaming"; +} + +function enrichUserSlot(user: UserChatEntry, historyUser: UserChatEntry): UserChatEntry { + let next = user; + if (historyUser.messageRef && user.messageRef?.messageId !== historyUser.messageRef.messageId) { + next = { ...next, messageRef: historyUser.messageRef }; + } + if (next.attachments.length === 0 && historyUser.attachments.length > 0) { + next = { ...next, attachments: historyUser.attachments }; + } + return next; +} + +// Upgrades a stream turn's payloads with their persisted twins (the live +// path trims large arguments/results; history carries the full content). +// Matching is by tool-call identity, falling back to same-kind ordinal — +// never by content. Streamed assistant/thinking text is already complete and +// is never replaced; but a turn that lost its content (rebuilt across a +// reset whose replay could not cover the run) adopts the persisted entries +// wholesale — that is the only content history is authoritative for. +function enrichTurnFromHistory(turn: Turn, historyTurn: HistoryTurn): Turn { + let next = turn; + + if (turn.user && historyTurn.user) { + const nextUser = enrichUserSlot(turn.user, historyTurn.user); + if (nextUser !== turn.user) { + next = { ...next, user: nextUser }; + } + } + + if (next.entries.length === 0 && historyTurn.entries.length > 0 && next.phase === "settled") { + return { ...next, entries: historyTurn.entries }; + } + + const historyToolCalls = historyTurn.entries.filter( + (entry): entry is Extract => entry.kind === "tool_call", + ); + const historyToolResults = historyTurn.entries.filter( + (entry): entry is Extract => entry.kind === "tool_result", + ); + if (historyToolCalls.length === 0 && historyToolResults.length === 0) { + return next; + } + + let entries: ChatEntry[] | null = null; + let toolCallOrdinal = 0; + let toolResultOrdinal = 0; + for (let index = 0; index < next.entries.length; index += 1) { + const entry = next.entries[index]; + if (!entry) continue; + + if (entry.kind === "tool_call") { + const ordinal = toolCallOrdinal++; + const twin = + historyToolCalls.find( + (candidate) => + entry.toolCall.id.trim() !== "" && candidate.toolCall.id === entry.toolCall.id, + ) ?? historyToolCalls[ordinal]; + if ( + twin && + (safeStringify(twin.toolCall) !== safeStringify(entry.toolCall) || + twin.summary !== entry.summary) + ) { + if (!entries) entries = next.entries.slice(); + entries[index] = { ...twin, id: entry.id, round: entry.round }; + } + continue; + } + + if (entry.kind === "tool_result") { + const ordinal = toolResultOrdinal++; + const twin = + historyToolResults.find( + (candidate) => + entry.toolResult.toolCallId.trim() !== "" && + candidate.toolResult.toolCallId === entry.toolResult.toolCallId, + ) ?? historyToolResults[ordinal]; + if (twin && safeStringify(twin.toolResult) !== safeStringify(entry.toolResult)) { + if (!entries) entries = next.entries.slice(); + entries[index] = { ...twin, id: entry.id, round: entry.round }; + } + } + } + if (entries) { + next = next === turn ? { ...turn, entries } : { ...next, entries }; + } + return next; +} + +// The guarded full-repaint fallback: history becomes authoritative for +// everything it covers. Turns whose content history cannot know yet are +// kept — a persisted-ref lookup misses (persistence lag) or a local error +// pseudo-turn that was never on the server. Worst case is a repaint, never +// a duplicate and never on-screen content loss. +function replaceAll(entries: ChatEntry[], turns: Turn[], historyTurns: HistoryTurn[]): AlignResult { + const knownRefs = new Set( + historyTurns.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + const kept = turns.filter((turn) => { + if (isLocalTurn(turn) || isActiveTurn(turn)) { + return true; + } + const ref = turn.user?.messageRef?.messageId; + return ref !== undefined && !knownRefs.has(ref); + }); + return { + historyEntries: entries, + turns: kept, + changed: true, + }; +} + +// mode "replace" — a full (re)load of the conversation (switch-in, +// load-more). The parsed history becomes the folded region; settled turns +// covered by it are dropped in its favor (deterministic parse ids keep the +// previously-loaded region id-stable), turns history cannot know yet +// survive, and pending/streaming turns — the active exchange — always +// survive with their persisted echoes trimmed so the prompt renders once. +function alignReplace(params: { turns: Turn[]; entries: ChatEntry[] }): AlignResult { + let historyTurns = groupHistoryEntriesIntoTurns(params.entries); + + // Trim persisted echoes of active exchanges. Ref-anchored first (covers + // edit-resends whose turn already carries the base ref)… + const activeWithUser = params.turns.filter((turn) => isActiveTurn(turn) && turn.user !== null); + const activeRefs = new Set( + activeWithUser.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + if (activeRefs.size > 0) { + historyTurns = historyTurns.filter( + (turn) => !turn.user?.messageRef?.messageId || !activeRefs.has(turn.user.messageRef.messageId), + ); + } + + // …then trailing user-only turns pair positionally with the remaining + // ref-less active prompts (the agent persists the prompt before the reply). + const reflessActive = activeWithUser.filter((turn) => !turn.user?.messageRef); + let trailingUserOnly = 0; + while (trailingUserOnly < historyTurns.length) { + const candidate = historyTurns[historyTurns.length - 1 - trailingUserOnly]; + if (!candidate || !candidate.user || historyTurnHasAssistantContent(candidate)) { + break; + } + trailingUserOnly += 1; + } + const trimCount = Math.min(trailingUserOnly, reflessActive.length); + // messageRef adoption only when the pairing is unambiguous: exactly as + // many persisted echoes as active prompts. + const enrichPairs = trailingUserOnly === reflessActive.length ? trimCount : 0; + const enrichedActive = new Map(); + for (let pair = 0; pair < enrichPairs; pair += 1) { + const historyTurn = historyTurns[historyTurns.length - 1 - pair]; + const keptTurn = reflessActive[reflessActive.length - 1 - pair]; + if (!historyTurn?.user || !keptTurn?.user) continue; + const enrichedUser = enrichUserSlot(keptTurn.user, historyTurn.user); + if (enrichedUser !== keptTurn.user) { + enrichedActive.set(keptTurn, { ...keptTurn, user: enrichedUser }); + } + } + historyTurns = historyTurns.slice(0, historyTurns.length - trimCount); + + // Decide which settled turns the fetched window covers. Ref-anchored turns + // are covered exactly when their persisted message is in the window. The + // window is always a suffix of the conversation, so for ref-less settled + // turns — the freshest exchanges, whose persistence is the most likely to + // lag the fetch — the number of window turns left after ref matching says + // how many of them are covered; the newest excess survives, or the last + // reply would blank on a reload racing persistence. + const knownRefs = new Set( + historyTurns.flatMap((turn) => + turn.user?.messageRef?.messageId ? [turn.user.messageRef.messageId] : [], + ), + ); + const settledTurns = params.turns.filter((turn) => !isActiveTurn(turn) && !isLocalTurn(turn)); + const refMatchedCount = settledTurns.filter((turn) => { + const ref = turn.user?.messageRef?.messageId; + return ref !== undefined && knownRefs.has(ref); + }).length; + const reflessSettled = settledTurns.filter((turn) => !turn.user?.messageRef); + const historyUserCount = historyTurns.filter((turn) => turn.user !== null).length; + const coveredRefless = Math.min( + reflessSettled.length, + Math.max(0, historyUserCount - refMatchedCount), + ); + const keptRefless = new Set(reflessSettled.slice(coveredRefless)); + + const turns = params.turns.flatMap((turn) => { + if (isActiveTurn(turn)) { + return [enrichedActive.get(turn) ?? turn]; + } + if (isLocalTurn(turn)) { + return [turn]; + } + const ref = turn.user?.messageRef?.messageId; + if (ref !== undefined) { + return knownRefs.has(ref) ? [] : [turn]; + } + return keptRefless.has(turn) ? [turn] : []; + }); + + return { + historyEntries: flattenHistoryTurns(historyTurns), + turns, + changed: true, + }; +} + +// mode "enrich" — the quiet idle refresh. At idle the rendered conversation +// is exactly historyEntries + turns, so the incoming window's user-turn +// count discriminates every case: +// +// |V| < |S| partial window too small to say anything — skip +// |V| < region + |S| suffix window: pair the last |S| turns, leave the +// existing history region untouched +// |V| == region + |S| full window: pair the last |S| turns, everything +// older becomes the history region +// |V| > region + |S| history knows exchanges this client never streamed +// — guarded full repaint (persist-lagged and local +// turns survive; see replaceAll) +// +// Paired turns are upgraded in place (messageRef, full tool payloads); the +// pairing is positional, end-anchored, guarded by persisted message ids +// where both sides carry one. +function alignEnrich(params: { + historyEntries: ChatEntry[]; + turns: Turn[]; + entries: ChatEntry[]; +}): AlignResult { + const unchanged: AlignResult = { + historyEntries: params.historyEntries, + turns: params.turns, + changed: false, + }; + + // Never merge under an active exchange (callers gate on idle; keep the + // invariant local too). + if (params.turns.some(isActiveTurn)) { + return unchanged; + } + + const historyTurns = groupHistoryEntriesIntoTurns(params.entries); + const historyWithUser = historyTurns.filter((turn) => turn.user !== null); + const storeWithUser = params.turns.filter((turn) => turn.user !== null); + const regionUserCount = params.historyEntries.reduce( + (count, entry) => (entry.kind === "user" ? count + 1 : count), + 0, + ); + + if (historyWithUser.length < storeWithUser.length) { + return unchanged; + } + if (historyWithUser.length > regionUserCount + storeWithUser.length) { + return replaceAll(params.entries, params.turns, historyTurns); + } + + // messageRef guard: conflicting persisted identities mean the store turns + // are stale — repaint rather than mis-enrich. + for (let pair = 0; pair < storeWithUser.length; pair += 1) { + const storeTurn = storeWithUser[storeWithUser.length - 1 - pair]; + const historyTurn = historyWithUser[historyWithUser.length - 1 - pair]; + const storeRef = storeTurn?.user?.messageRef?.messageId; + const historyRef = historyTurn?.user?.messageRef?.messageId; + if (storeRef && historyRef && storeRef !== historyRef) { + return replaceAll(params.entries, params.turns, historyTurns); + } + } + + let turns = params.turns; + for (let pair = 0; pair < storeWithUser.length; pair += 1) { + const storeTurn = storeWithUser[storeWithUser.length - 1 - pair]; + const historyTurn = historyWithUser[historyWithUser.length - 1 - pair]; + if (!storeTurn || !historyTurn) continue; + const enriched = enrichTurnFromHistory(storeTurn, historyTurn); + if (enriched !== storeTurn) { + if (turns === params.turns) turns = params.turns.slice(); + const index = turns.indexOf(storeTurn); + if (index >= 0) { + turns[index] = enriched; + } + } + } + + // A suffix window says nothing about older rows: keep the region as-is. + if (historyWithUser.length < regionUserCount + storeWithUser.length) { + if (turns === params.turns) { + return unchanged; + } + return { historyEntries: params.historyEntries, turns, changed: true }; + } + + // Full window: everything older than the paired turns renders from + // history. Parse ids are deterministic, so an id-level comparison detects + // every structural change; identical content reparses to identical ids. + const pairStart = + storeWithUser.length === 0 + ? historyTurns.length + : historyTurns.indexOf(historyWithUser[historyWithUser.length - storeWithUser.length]!); + const nextHistoryEntries = flattenHistoryTurns(historyTurns.slice(0, pairStart)); + + const historyChanged = + nextHistoryEntries.length !== params.historyEntries.length || + nextHistoryEntries.some((entry, index) => params.historyEntries[index]?.id !== entry.id); + + if (!historyChanged && turns === params.turns) { + return unchanged; + } + return { + historyEntries: historyChanged ? nextHistoryEntries : params.historyEntries, + turns, + changed: true, + }; +} + +export function alignHistory(params: { + historyEntries: ChatEntry[]; + turns: Turn[]; + entries: ChatEntry[]; + mode: HistoryApplyMode; +}): AlignResult { + if (params.mode === "replace") { + return alignReplace({ turns: params.turns, entries: params.entries }); + } + return alignEnrich(params); +} diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts b/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts new file mode 100644 index 00000000..632ae7dd --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/transcript/rows.ts @@ -0,0 +1,466 @@ +import type { ToolCall, ToolResultMessage } from "@/lib/agentTypes"; +import { + appendTextDeltaToRound, + appendThinkingDeltaToRound, + attachToolResultToRound, + buildDelegateAgentPlaceholderToolCalls, + getRoundToolTrace, + hasRoundContent, + upsertHostedSearchToRound, + upsertToolCallToRound, +} from "@/lib/chat/uiMessages"; +import type { + DelegateAgentCardResultDetails, + DelegateAgentItemResultDetails, + DelegateAgentResultDetails, +} from "@/lib/tools/builtinTypes"; +import { + type AssistantMeta, + type ChatEntry, + type GatewayTranscriptRound, + hashValue, + stripRecoveredToolCallMarkup, +} from "@/lib/chatUi"; + +import type { TranscriptRow, TranscriptRowOrigin, Turn } from "./types"; + +// The one place transcript rows are built. Consecutive assistant-side +// entries fold into a single assistant row of rounds; rounds without any +// renderable content are dropped, and an assistant row is only emitted when +// at least one round survives — an avatar can therefore never render without +// content next to it. + +type AssistantGroupBuilder = { + id: string; + rounds: GatewayTranscriptRound[]; + roundIndexByNumber: Map; +}; + +function createTranscriptRound(groupId: string, round: number): GatewayTranscriptRound { + return { + key: `${groupId}:r${round}`, + round, + blocks: [], + runningToolCallIds: [], + }; +} + +function ensureAssistantGroup( + builder: AssistantGroupBuilder | null, + seedEntryId: string, +): AssistantGroupBuilder { + if (builder) return builder; + return { + id: `ag:${seedEntryId}`, + rounds: [], + roundIndexByNumber: new Map(), + }; +} + +function ensureTranscriptRound( + builder: AssistantGroupBuilder, + requestedRound?: number, +): GatewayTranscriptRound { + const roundNumber = requestedRound ?? builder.rounds[builder.rounds.length - 1]?.round ?? 1; + const existingIndex = builder.roundIndexByNumber.get(roundNumber); + if (existingIndex !== undefined) { + return builder.rounds[existingIndex]; + } + + const nextRound = createTranscriptRound(builder.id, roundNumber); + builder.roundIndexByNumber.set(roundNumber, builder.rounds.length); + builder.rounds.push(nextRound); + return nextRound; +} + +function updateTranscriptRound( + builder: AssistantGroupBuilder, + roundNumber: number, + updater: (round: GatewayTranscriptRound) => GatewayTranscriptRound, +) { + const round = ensureTranscriptRound(builder, roundNumber); + const index = builder.roundIndexByNumber.get(round.round) ?? 0; + builder.rounds[index] = updater(round); +} + +function collapseThinking(round: GatewayTranscriptRound): GatewayTranscriptRound { + if (!round.thinkingOpen) return round; + return { ...round, thinkingOpen: false }; +} + +function mergeAssistantMeta( + current: AssistantMeta | undefined, + next: AssistantMeta | undefined, +): AssistantMeta | undefined { + if (!next) return current; + return { ...(current ?? {}), ...next }; +} + +function findToolCallInRound(round: GatewayTranscriptRound, toolCallId: string) { + return getRoundToolTrace(round).find((item) => item.toolCall.id === toolCallId)?.toolCall; +} + +function findPendingToolCallByName(round: GatewayTranscriptRound, name: string) { + const trace = getRoundToolTrace(round); + for (let index = trace.length - 1; index >= 0; index -= 1) { + const item = trace[index]; + if (!item) continue; + if (item.toolCall.name === name && !item.toolResult) { + return item.toolCall; + } + } + return undefined; +} + +function resolveToolCallForResult( + builder: AssistantGroupBuilder, + roundNumber: number, + toolResult: ToolResultMessage, +): ToolCall { + const requestedRound = ensureTranscriptRound(builder, roundNumber); + const byId = toolResult.toolCallId && findToolCallInRound(requestedRound, toolResult.toolCallId); + if (byId) { + return byId; + } + + const byName = toolResult.toolName && findPendingToolCallByName(requestedRound, toolResult.toolName); + if (byName) { + return byName; + } + + for (let index = builder.rounds.length - 1; index >= 0; index -= 1) { + const round = builder.rounds[index]; + if (!round) continue; + const candidateById = toolResult.toolCallId && findToolCallInRound(round, toolResult.toolCallId); + if (candidateById) { + return candidateById; + } + const candidateByName = + toolResult.toolName && findPendingToolCallByName(round, toolResult.toolName); + if (candidateByName) { + return candidateByName; + } + } + + return { + type: "toolCall", + id: toolResult.toolCallId || `orphan:${hashValue([toolResult.toolName, toolResult.content])}`, + name: toolResult.toolName || "Tool", + arguments: {}, + } as ToolCall; +} + +function asDelegateAgentResultDetails(details: unknown): DelegateAgentResultDetails | null { + const record = + details && typeof details === "object" ? (details as Record) : {}; + return record.kind === "delegate_agent" && Array.isArray(record.agents) + ? (record as unknown as DelegateAgentResultDetails) + : null; +} + +function readDelegateAgentPrompt(agent: DelegateAgentItemResultDetails) { + return agent.prompt || agent.description || ""; +} + +function buildDelegateAgentCardToolCall(params: { + parentToolResult: ToolResultMessage; + details: DelegateAgentResultDetails; + index: number; + agent: DelegateAgentItemResultDetails; +}): ToolCall { + const parentToolCallId = params.parentToolResult.toolCallId || "agent"; + return { + type: "toolCall", + id: `${parentToolCallId}:agent:${params.index + 1}`, + name: "Agent", + arguments: { + delegate_agent_card: true, + parent_tool_call_id: parentToolCallId, + index: params.index + 1, + total: params.details.agentCount, + concurrency: params.details.concurrency, + id: params.agent.id, + name: params.agent.name, + role: params.agent.role, + agent_id: params.agent.agentId, + prompt: readDelegateAgentPrompt(params.agent), + mode: params.agent.mode, + }, + } as ToolCall; +} + +function buildDelegateAgentCardToolResult(params: { + parentToolResult: ToolResultMessage; + toolCall: ToolCall; + details: DelegateAgentResultDetails; + index: number; + agent: DelegateAgentItemResultDetails; +}): ToolResultMessage { + const details: DelegateAgentCardResultDetails = { + kind: "delegate_agent_item", + parentToolCallId: params.parentToolResult.toolCallId, + index: params.index, + total: params.details.agentCount, + concurrency: params.details.concurrency, + agent: params.agent, + }; + return { + role: "toolResult", + toolCallId: params.toolCall.id, + toolName: params.toolCall.name, + content: [ + { + type: "text", + text: + params.agent.error || + params.agent.applyError || + params.agent.summary || + readDelegateAgentPrompt(params.agent) || + "", + }, + ], + details, + isError: params.agent.status === "failed", + timestamp: params.parentToolResult.timestamp, + } as ToolResultMessage; +} + +function appendDelegateAgentCardsToRound( + round: GatewayTranscriptRound, + parentToolResult: ToolResultMessage, + details: DelegateAgentResultDetails, +): GatewayTranscriptRound { + let nextRound = round; + details.agents.forEach((agent, index) => { + const toolCall = buildDelegateAgentCardToolCall({ parentToolResult, details, index, agent }); + const toolResult = buildDelegateAgentCardToolResult({ + parentToolResult, + toolCall, + details, + index, + agent, + }); + nextRound = attachToolResultToRound(nextRound, toolCall, toolResult) as GatewayTranscriptRound; + }); + + const completedIds = new Set([ + parentToolResult.toolCallId, + ...details.agents.map((_, index) => `${parentToolResult.toolCallId}:agent:${index + 1}`), + ]); + + return { + ...nextRound, + runningToolCallIds: nextRound.runningToolCallIds.filter((id) => !completedIds.has(id)), + }; +} + +export function buildRowsFromEntries( + entries: ChatEntry[], + origin: TranscriptRowOrigin, +): TranscriptRow[] { + const rows: TranscriptRow[] = []; + let assistantGroup: AssistantGroupBuilder | null = null; + + const flushAssistantGroup = () => { + if (!assistantGroup) { + return; + } + // The content gate: rounds that never produced anything renderable + // (meta-only token carriers, empty leading flushes) are dropped, and a + // group whose every round dropped emits no row at all. + const rounds = assistantGroup.rounds.filter((round) => hasRoundContent(round)); + if (rounds.length > 0) { + rows.push({ + key: assistantGroup.id, + origin, + kind: "assistant", + rounds, + }); + } + assistantGroup = null; + }; + + for (const entry of entries) { + if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { + flushAssistantGroup(); + if (entry.kind === "user") { + rows.push({ + key: entry.id, + origin, + kind: "user", + text: entry.text, + attachments: entry.attachments, + messageRef: entry.messageRef, + }); + } else if (entry.kind === "checkpoint") { + rows.push({ + key: entry.id, + origin, + kind: "checkpoint", + content: entry.content, + summaryId: entry.summaryId, + coveredMessageCount: entry.coveredMessageCount, + generatedBy: entry.generatedBy, + timestamp: entry.timestamp, + }); + } else { + rows.push({ key: entry.id, origin, kind: "error", text: entry.text }); + } + continue; + } + + assistantGroup = ensureAssistantGroup(assistantGroup, entry.id); + const roundNumber = + entry.round ?? assistantGroup.rounds[assistantGroup.rounds.length - 1]?.round ?? 1; + + if (entry.kind === "assistant") { + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + let nextRound = round; + if (entry.text !== "") { + nextRound = appendTextDeltaToRound( + collapseThinking(nextRound), + entry.text, + ) as GatewayTranscriptRound; + } + return { + ...nextRound, + meta: mergeAssistantMeta(nextRound.meta, entry.meta), + }; + }); + continue; + } + + if (entry.kind === "thinking") { + const sanitizedThinking = stripRecoveredToolCallMarkup(entry.text); + if (sanitizedThinking === "") { + continue; + } + updateTranscriptRound(assistantGroup, roundNumber, (round) => ({ + ...(appendThinkingDeltaToRound(round, sanitizedThinking) as GatewayTranscriptRound), + thinkingOpen: true, + })); + continue; + } + + if (entry.kind === "tool_call") { + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const visibleToolCalls = buildDelegateAgentPlaceholderToolCalls(entry.toolCall); + const runningCandidateIds = + visibleToolCalls.length > 0 + ? visibleToolCalls.map((toolCall) => toolCall.id) + : entry.toolCall.id + ? [entry.toolCall.id] + : []; + const withToolCall = upsertToolCallToRound( + collapseThinking(round), + entry.toolCall, + ) as GatewayTranscriptRound; + const visibleToolCallIds = new Set( + getRoundToolTrace(withToolCall) + .map((item) => item.toolCall.id) + .filter((id): id is string => Boolean(id)), + ); + const runningToolCallIds = runningCandidateIds.reduce( + (ids, id) => (visibleToolCallIds.has(id) && !ids.includes(id) ? [...ids, id] : ids), + withToolCall.runningToolCallIds, + ); + return { ...withToolCall, runningToolCallIds }; + }); + continue; + } + + if (entry.kind === "hosted_search") { + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const nextRound = upsertHostedSearchToRound( + collapseThinking(round), + entry.hostedSearch, + ) as GatewayTranscriptRound; + const visibleToolCallIds = new Set( + getRoundToolTrace(nextRound) + .map((item) => item.toolCall.id) + .filter((id): id is string => Boolean(id)), + ); + return { + ...nextRound, + runningToolCallIds: nextRound.runningToolCallIds.filter((id) => + visibleToolCallIds.has(id), + ), + }; + }); + continue; + } + + if (entry.kind === "tool_result") { + const delegateDetails = asDelegateAgentResultDetails(entry.toolResult.details); + if (delegateDetails) { + updateTranscriptRound(assistantGroup, roundNumber, (round) => + appendDelegateAgentCardsToRound(collapseThinking(round), entry.toolResult, delegateDetails), + ); + continue; + } + + const toolCall = resolveToolCallForResult(assistantGroup, roundNumber, entry.toolResult); + updateTranscriptRound(assistantGroup, roundNumber, (round) => { + const withResult = attachToolResultToRound( + collapseThinking(round), + toolCall, + entry.toolResult, + ) as GatewayTranscriptRound; + return { + ...withResult, + runningToolCallIds: withResult.runningToolCallIds.filter((id) => id !== toolCall.id), + }; + }); + } + } + + flushAssistantGroup(); + return rows; +} + +// A turn's rows: the user bubble first, then its run's assistant content — +// derived from one object, so the order is fixed by construction. +export function buildTurnRows(turn: Turn): TranscriptRow[] { + const rows: TranscriptRow[] = []; + if (turn.user) { + rows.push({ + key: turn.user.id, + origin: "stream", + kind: "user", + text: turn.user.text, + attachments: turn.user.attachments, + messageRef: turn.user.messageRef, + }); + } + for (const row of buildRowsFromEntries(turn.entries, "stream")) { + rows.push(row.kind === "assistant" ? { ...row, turnKey: turn.key } : row); + } + return rows; +} + +// Row keys feed React reconciliation and the virtualizer's measurement cache; +// a duplicate key collapses row positions onto each other. Ids are unique by +// construction, but this single canonical pass makes the guarantee local to +// the builder instead of distributed across every producer. Pass `seen` to +// dedupe one region against keys already taken by another. +export function dedupeRowKeys(rows: TranscriptRow[], seen = new Set()): TranscriptRow[] { + let next: TranscriptRow[] | null = null; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (!row) continue; + let key = row.key; + if (seen.has(key)) { + let suffix = 2; + while (seen.has(`${key}#${suffix}`)) { + suffix += 1; + } + key = `${key}#${suffix}`; + if (!next) { + next = rows.slice(); + } + next[index] = { ...row, key }; + } + seen.add(key); + } + return next ?? rows; +} diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts new file mode 100644 index 00000000..7606ca2f --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts @@ -0,0 +1,826 @@ +import { type ChatEntry, normalizeLiveUploadedFiles } from "@/lib/chatUi"; +import type { ChatEvent } from "@/lib/gatewayTypes"; +import type { + ConversationStreamEvent, + ConversationSubscribeResult, + StreamRunActivity, +} from "@/lib/chat/stream/streamTypes"; +import { readEventRunId, readEventSeq } from "@/lib/chat/stream/streamTypes"; + +import { alignHistory } from "./historyAlignment"; +import { buildRowsFromEntries, buildTurnRows, dedupeRowKeys } from "./rows"; +import { + applyEventToTurn, + createTurn, + optimisticUserEntryId, + rebuildTurnFromSnapshot, + seededUserEntryId, +} from "./turnReducer"; +import type { + HistoryApplyMode, + TranscriptRow, + TranscriptSnapshot, + Turn, + UserChatEntry, +} from "./types"; + +// One transcript store per conversation, built on two structures: +// +// historyEntries — the parsed persisted conversation (deterministic ids), +// always rendered first, inside the virtualized region +// turns — live exchanges, one Turn per run: a single user-bubble +// slot plus the run's assistant entries +// +// The snapshot is a single derived row list plus a fold boundary index; the +// virtualized region renders rows[0..foldedRowCount) and the live flow the +// rest. Because both regions are slices of one list built from one source, +// a prompt or reply can never render twice, and a turn's user bubble always +// precedes its assistant content. Completion is supersession: at the next +// run_started, settled turns just flip `folded` — row keys and objects never +// change, so the fold moves zero DOM identity. + +export type { TranscriptSnapshot, TranscriptRow, Turn } from "./types"; + +export type TranscriptStore = { + getSnapshot(): TranscriptSnapshot; + subscribe(listener: () => void): () => void; + // Stream plumbing. + applySync(result: ConversationSubscribeResult): void; + applyEvent(event: ConversationStreamEvent): void; + // Optimistic local echo for a command this client is submitting. The + // seeded user_message binds the turn by client_request_id — the bubble + // keeps its id and never remounts. + addOptimisticUserEntry(params: { + clientRequestId: string; + text: string; + attachments?: UserChatEntry["attachments"]; + }): void; + removeOptimisticUserEntry(clientRequestId: string): void; + // Failure surfaced outside the stream (command never bound). + appendLocalError(message: string): void; + // History application: "replace" for full (re)loads, "enrich" for the + // idle quiet refresh (messageRef / full tool payload upgrades). + applyHistorySnapshot(entries: ChatEntry[], options?: { mode?: HistoryApplyMode }): void; + // Fold the settled turns outside of run_started (conversation switched + // away; keeps the next mount clean). + foldSettledTurns(): void; + flush(): void; +}; + +const EMPTY_SNAPSHOT: TranscriptSnapshot = { + foldedRows: [], + liveRows: [], + activeTurnKey: null, + entryCount: 0, + activeRun: null, + toolStatus: null, + toolStatusIsCompaction: false, + foldRevision: 0, + revision: 0, +}; + +function readEventClientRequestId(event: ConversationStreamEvent): string { + const value = (event as { client_request_id?: unknown }).client_request_id; + return typeof value === "string" ? value.trim() : ""; +} + +export function createTranscriptStore(): TranscriptStore { + let historyEntries: ChatEntry[] = []; + let turns: Turn[] = []; + let activeRun: StreamRunActivity | null = null; + let toolStatus: string | null = null; + let toolStatusIsCompaction = false; + let foldRevision = 0; + let localTurnSeq = 0; + // Idempotency cursor: the highest log seq already applied. Re-subscribe + // replays and snapshot+replay overlaps are dropped below it. + let lastSeq = 0; + + let snapshot = EMPTY_SNAPSHOT; + let dirty = false; + let rafId: number | null = null; + const listeners = new Set<() => void>(); + + // Row caches: history rows rebuild only when the history region changes; + // turn rows rebuild only for the turn object that changed (turns are + // immutable-updated, so a token delta re-derives one turn's rows per + // commit, not the whole transcript). The assembled folded-region array is + // additionally cached by identity so the virtualized region's props stay + // referentially stable across streaming commits. + let historyRowsCache: { entries: ChatEntry[]; rows: TranscriptRow[] } | null = null; + const turnRowsCache = new WeakMap(); + let foldedRowsCache: { + historyEntries: ChatEntry[]; + foldedTurns: Turn[]; + rows: TranscriptRow[]; + } | null = null; + + const historyRows = (): TranscriptRow[] => { + if (historyRowsCache?.entries !== historyEntries) { + historyRowsCache = { + entries: historyEntries, + rows: buildRowsFromEntries(historyEntries, "history"), + }; + } + return historyRowsCache.rows; + }; + + const rowsForTurn = (turn: Turn): TranscriptRow[] => { + let rows = turnRowsCache.get(turn); + if (!rows) { + rows = buildTurnRows(turn); + turnRowsCache.set(turn, rows); + } + return rows; + }; + + const findTurnByCri = (clientRequestId: string): Turn | null => { + if (!clientRequestId) return null; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.clientRequestId === clientRequestId) { + return turn; + } + } + return null; + }; + + const findTurnByRunId = (runId: string): Turn | null => { + if (!runId) return null; + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.runId === runId) { + return turn; + } + } + return null; + }; + + const findStreamingTurn = (): Turn | null => { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn && turn.phase === "streaming") { + return turn; + } + } + return null; + }; + + const replaceTurn = (previous: Turn, next: Turn) => { + if (previous === next) return; + turns = turns.map((turn) => (turn === previous ? next : turn)); + }; + + // Binds a turn to its run. If a separate turn already exists for that run + // (a seeded `run:` turn created before the ownership was known), the two + // describe the same exchange: absorb its content into `turn` — the user + // bubble's identity always wins — and drop the duplicate. + const adoptRun = (turn: Turn, runId: string): Turn => { + if (!runId || turn.runId === runId) { + return turn; + } + const existing = findTurnByRunId(runId); + let next: Turn = { ...turn, runId }; + if (existing && existing !== turn) { + const ownIds = new Set(next.entries.map((entry) => entry.id)); + const absorbed = existing.entries.filter((entry) => !ownIds.has(entry.id)); + next = { + ...next, + user: next.user ?? existing.user, + entries: absorbed.length ? [...next.entries, ...absorbed] : next.entries, + phase: existing.phase === "streaming" ? "streaming" : next.phase, + }; + turns = turns.filter((candidate) => candidate !== existing); + } + return next; + }; + + const buildSnapshot = (): TranscriptSnapshot => { + const activeTurn = findStreamingTurn(); + // Partition, not prefix: a folded turn can sit after a still-pending one + // in creation order (a foreign run completing while this client's prompt + // waits), and its rows must still land in the virtualized region. + const foldedTurns: Turn[] = []; + const unfoldedTurns: Turn[] = []; + let entryCount = historyEntries.length; + for (const turn of turns) { + (turn.folded ? foldedTurns : unfoldedTurns).push(turn); + entryCount += turn.entries.length + (turn.user ? 1 : 0); + } + + const cacheValid = + foldedRowsCache !== null && + foldedRowsCache.historyEntries === historyEntries && + foldedRowsCache.foldedTurns.length === foldedTurns.length && + foldedRowsCache.foldedTurns.every((turn, index) => turn === foldedTurns[index]); + let foldedRows: TranscriptRow[]; + if (cacheValid && foldedRowsCache) { + foldedRows = foldedRowsCache.rows; + } else { + foldedRows = dedupeRowKeys([ + ...historyRows(), + ...foldedTurns.flatMap((turn) => rowsForTurn(turn)), + ]); + foldedRowsCache = { historyEntries, foldedTurns, rows: foldedRows }; + } + + const seenKeys = new Set(foldedRows.map((row) => row.key)); + const liveRows = dedupeRowKeys( + unfoldedTurns.flatMap((turn) => rowsForTurn(turn)), + seenKeys, + ); + + return { + foldedRows, + liveRows, + activeTurnKey: activeTurn?.key ?? null, + entryCount, + activeRun, + toolStatus, + toolStatusIsCompaction, + foldRevision, + revision: snapshot.revision + 1, + }; + }; + + const emit = () => { + for (const listener of listeners) { + listener(); + } + }; + const commit = () => { + rafId = null; + if (!dirty) { + return; + } + dirty = false; + snapshot = buildSnapshot(); + emit(); + }; + const schedule = (flush?: boolean) => { + dirty = true; + if (flush) { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + return; + } + if (rafId === null && typeof requestAnimationFrame === "function") { + rafId = requestAnimationFrame(commit); + } else if (typeof requestAnimationFrame !== "function") { + commit(); + } + }; + + // Folding flips flags only: every settled turn moves into the virtualized + // region with identical rows. `settleStreaming` additionally retires a + // still-streaming turn whose run is being superseded by a new run_started. + const foldSettled = (settleStreaming: boolean): boolean => { + let changed = false; + const next = turns.map((turn) => { + if (turn.phase === "settled" && !turn.folded) { + changed = true; + return { ...turn, folded: true }; + } + if (settleStreaming && turn.phase === "streaming") { + changed = true; + return { ...turn, phase: "settled" as const, folded: true }; + } + return turn; + }); + if (changed) { + turns = next; + foldRevision += 1; + } + return changed; + }; + + const setToolStatus = (status: string | null, isCompaction: boolean, flush?: boolean) => { + const next = status && status.trim() ? status.trim() : null; + const nextCompaction = Boolean(next) && isCompaction; + if (toolStatus === next && toolStatusIsCompaction === nextCompaction) { + return; + } + toolStatus = next; + toolStatusIsCompaction = nextCompaction; + schedule(flush); + }; + + const applyUserMessage = (event: ConversationStreamEvent, runId: string) => { + const payload = event as { message?: unknown; uploaded_files?: unknown }; + const clientRequestId = readEventClientRequestId(event); + const text = typeof payload.message === "string" ? payload.message : ""; + const attachments = normalizeLiveUploadedFiles(payload.uploaded_files); + if (!text.trim() && attachments.length === 0) { + return; + } + + // (1) Our own submission: bind the optimistic turn to its run. The user + // slot is already filled — the bubble keeps its id, nothing remounts. + const ownTurn = findTurnByCri(clientRequestId); + if (ownTurn) { + let next = adoptRun(ownTurn, runId); + if (!next.user) { + next = { + ...next, + user: { id: optimisticUserEntryId(clientRequestId), kind: "user", text, attachments }, + }; + } + if (next !== ownTurn) { + replaceTurn(ownTurn, next); + schedule(true); + } + return; + } + + // (2) The run already has a turn: fill the single user slot iff empty. + const runTurn = findTurnByRunId(runId); + if (runTurn) { + if (!runTurn.user) { + replaceTurn(runTurn, { + ...runTurn, + user: { id: seededUserEntryId(runId), kind: "user", text, attachments }, + }); + schedule(true); + } + return; + } + + // (3) Foreign/seeded turn (another viewer's command, replay). + const seq = readEventSeq(event); + turns = [ + ...turns, + { + ...createTurn({ + key: runId ? `run:${runId}` : `run:seed-${seq}`, + runId, + clientRequestId, + phase: activeRun?.runId === runId && runId !== "" ? "streaming" : "pending", + }), + user: { + id: runId ? seededUserEntryId(runId) : `r:seed-${seq}:u`, + kind: "user", + text, + attachments, + }, + }, + ]; + schedule(true); + }; + + const applyDelta = (event: ConversationStreamEvent, runId: string) => { + if (event.type === "user_message") { + applyUserMessage(event, runId); + return; + } + let turn = findTurnByRunId(runId) ?? (runId === "" ? findStreamingTurn() : null); + if (!turn && runId === "") { + // Reuse the trailing orphan turn so run-less deltas coalesce into one + // exchange instead of fragmenting into a turn per event. + const last = turns[turns.length - 1]; + if (last && last.key.startsWith("run:orphan") && !last.folded) { + turn = last; + } + } + if (!turn) { + // Content for a run the store never saw start (defensive). + const seq = readEventSeq(event); + turn = createTurn({ + key: runId ? `run:${runId}` : `run:orphan-${seq}`, + runId, + phase: activeRun?.runId === runId && runId !== "" ? "streaming" : "settled", + }); + turns = [...turns, turn]; + } + const next = applyEventToTurn(turn, event as ChatEvent); + if (next !== turn) { + replaceTurn(turn, next); + schedule(false); + } + }; + + const rebuildActiveTurnFromSnapshot = (entriesJson: string, runId: string) => { + const parsed = parseSnapshotEntries(entriesJson); + let turn = + findTurnByRunId(runId) ?? + (activeRun?.clientRequestId ? findTurnByCri(activeRun.clientRequestId) : null); + if (!turn) { + if (parsed.length === 0) { + return; + } + turn = createTurn({ key: `run:${runId}`, runId, phase: "streaming" }); + turns = [...turns, turn]; + } + let next = adoptRun(turn, runId); + next = rebuildTurnFromSnapshot(next, parsed); + if (next.phase !== "streaming" || next.folded) { + next = { ...next, phase: "streaming", folded: false }; + } + replaceTurn(turn, next); + schedule(true); + }; + + const applyRunFinished = (event: ConversationStreamEvent) => { + const runId = readEventRunId(event); + if (activeRun && runId !== "" && runId !== activeRun.runId) { + // Stray terminal for a non-active run (the gateway appends these + // deliberately, e.g. failing a superseded queued run). Never settle + // the active turn; just drop the stray run's turn. + const stray = findTurnByRunId(runId); + if (stray && !stray.folded) { + turns = turns.filter((turn) => turn !== stray); + schedule(true); + } + return; + } + const payload = event as { status?: string; message?: string; reason?: string }; + let turn = findTurnByRunId(runId) ?? findStreamingTurn(); + if (payload.status === "failed" && payload.message && payload.reason !== "superseded") { + if (!turn) { + turn = createTurn({ key: `run:${runId || `finished-${readEventSeq(event)}`}`, runId }); + turns = [...turns, turn]; + } + const withError = applyEventToTurn(turn, { + type: "error", + message: payload.message, + } as ChatEvent); + replaceTurn(turn, withError); + turn = withError; + } + if (turn && turn.phase !== "settled") { + replaceTurn(turn, { ...turn, phase: "settled" }); + } + activeRun = null; + setToolStatus(null, false); + schedule(true); + }; + + // edit_resend: truncate the transcript at the edited user message. The new + // user_message (binding the optimistic turn) follows in the stream. + const applyRebased = (event: ConversationStreamEvent) => { + const ref = (event as { base_message_ref?: unknown }).base_message_ref; + if (!ref || typeof ref !== "object") { + return; + } + const refValue = ref as Record; + const messageId = typeof refValue.message_id === "string" ? refValue.message_id.trim() : ""; + const contentHash = + typeof refValue.content_hash === "string" ? refValue.content_hash.trim() : ""; + if (!messageId && !contentHash) { + return; + } + // Prefer the exact message id; the content hash is only a fallback for + // refs without one — matching on it eagerly would truncate at the FIRST + // occurrence of a re-sent identical prompt. + const matchesRef = (user: UserChatEntry | null) => { + if (!user?.messageRef) { + return false; + } + if (messageId !== "") { + return user.messageRef.messageId === messageId; + } + return contentHash !== "" && user.messageRef.contentHash === contentHash; + }; + + const turnIndex = turns.findIndex((turn) => matchesRef(turn.user)); + if (turnIndex >= 0) { + turns = [ + ...turns.slice(0, turnIndex), + ...turns.slice(turnIndex).filter((turn) => turn.phase === "pending"), + ]; + schedule(true); + return; + } + + const entryIndex = historyEntries.findIndex( + (entry) => entry.kind === "user" && matchesRef(entry), + ); + if (entryIndex < 0) { + return; + } + historyEntries = historyEntries.slice(0, entryIndex); + turns = turns.filter((turn) => turn.phase === "pending"); + schedule(true); + }; + + function applyOne(event: ConversationStreamEvent) { + const seq = readEventSeq(event); + if (seq > 0) { + if (seq <= lastSeq) { + // Already applied (resubscribe replay / snapshot overlap). + return; + } + lastSeq = seq; + } + const runId = readEventRunId(event); + switch (event.type) { + case "run_started": { + // Fold the previous exchange into the virtualized region in the same + // commit that renders the new run — the one intentional layout change. + foldSettled(true); + const clientRequestId = readEventClientRequestId(event); + let turn = findTurnByCri(clientRequestId) ?? findTurnByRunId(runId); + if (!turn) { + turn = createTurn({ key: `run:${runId}`, runId, clientRequestId }); + turns = [...turns, turn]; + } + const bound = adoptRun(turn, runId); + replaceTurn(turn, { + ...bound, + phase: "streaming", + folded: false, + }); + activeRun = { + runId, + state: "running", + startedSeq: seq, + toolStatus: null, + toolStatusIsCompaction: false, + clientRequestId: clientRequestId || undefined, + updatedAt: Date.now(), + }; + setToolStatus(null, false, true); + schedule(true); + return; + } + case "run_finished": { + applyRunFinished(event); + return; + } + case "run_queued": { + // The prompt went into the desktop queue: drop its turn (user bubble + // and provisional entries); the queue panel shows it instead. + const turn = findTurnByRunId(runId) ?? findTurnByCri(readEventClientRequestId(event)); + if (turn && !turn.folded) { + turns = turns.filter((candidate) => candidate !== turn); + schedule(true); + } + if (activeRun?.runId === runId) { + activeRun = null; + schedule(true); + } + return; + } + case "rebased": { + applyRebased(event); + return; + } + case "snapshot": { + const payload = event as { entries_json?: string; as_of_seq?: number }; + const asOfSeq = + typeof payload.as_of_seq === "number" && Number.isFinite(payload.as_of_seq) + ? Math.floor(payload.as_of_seq) + : 0; + if (asOfSeq > 0 && asOfSeq <= lastSeq) { + // Snapshot events carry no seq of their own; a replayed one that + // covers less of the log than we already applied must not roll the + // active turn back to an older state. + return; + } + rebuildActiveTurnFromSnapshot(payload.entries_json ?? "", runId); + if (asOfSeq > 0) { + // The snapshot content covers the log through as_of_seq; drop the + // overlapping tail of any concurrent replay. + lastSeq = Math.max(lastSeq, asOfSeq); + } + const status = (event as { tool_status?: string | null }).tool_status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { tool_status_is_compaction?: boolean }).tool_status_is_compaction === true, + true, + ); + return; + } + case "tool_status": { + const status = (event as { status?: string | null }).status ?? null; + setToolStatus( + typeof status === "string" ? status : null, + (event as { isCompaction?: boolean }).isCompaction === true, + ); + if (activeRun && activeRun.runId === runId) { + activeRun = { ...activeRun, toolStatus, toolStatusIsCompaction }; + } + return; + } + default: { + applyDelta(event, runId); + } + } + } + + return { + getSnapshot: () => snapshot, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + applySync: (result) => { + if (result.reset) { + // Seq continuity broke (gateway restart / buffer gap). Folded and + // settled turns hold finished content with stable ids — fold, never + // drop. A streaming turn's content is rebuilt from the snapshot and + // replay, but the turn object (and its user bubble) survives, so the + // active exchange never remounts. Pending turns — optimistic echoes + // whose run hasn't started — survive untouched and bind normally + // when their seed replays. + lastSeq = 0; + let changed = false; + turns = turns.map((turn) => { + if (turn.phase === "settled" && !turn.folded) { + changed = true; + return { ...turn, folded: true }; + } + if (turn.phase === "streaming") { + changed = true; + if (result.activity && result.activity.runId === turn.runId) { + // Still running server-side: the snapshot/replay rebuilds the + // content into this same turn object. + return { ...turn, entries: [] }; + } + // The run ended while this client was away and the replay may + // not cover it any more: settle the turn (never strand it as a + // pending zombie) so the idle enrich can adopt its persisted + // reply from history. + return { ...turn, entries: [], phase: "settled" as const }; + } + return turn; + }); + if (changed) { + foldRevision += 1; + } + toolStatus = null; + toolStatusIsCompaction = false; + // Set the activity before the rebuild so the snapshot can target the + // optimistic pending turn by client_request_id (its user bubble then + // keeps its identity instead of a duplicate run turn appearing). + activeRun = result.activity; + if (result.snapshot) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } else { + activeRun = result.activity; + if (result.snapshot) { + // Late join mid-run where the buffer cannot cover the run start. + // The snapshot folds every event through asOfSeq into its entries; + // advancing the cursor drops the overlapping replay below. + const existing = findTurnByRunId(result.snapshot.runId); + if (!existing || existing.entries.length === 0) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } + } + if (result.activity) { + setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); + } else if (result.reset) { + setToolStatus(null, false); + } + for (const event of result.events) { + applyOne(event); + } + lastSeq = Math.max(lastSeq, result.latestSeq); + schedule(true); + }, + + applyEvent: (event) => { + applyOne(event); + }, + + addOptimisticUserEntry: ({ clientRequestId, text, attachments }) => { + if (findTurnByCri(clientRequestId)) { + return; + } + turns = [ + ...turns, + { + ...createTurn({ key: `req:${clientRequestId}`, clientRequestId, phase: "pending" }), + user: { + id: optimisticUserEntryId(clientRequestId), + kind: "user", + text, + attachments: attachments ?? [], + }, + }, + ]; + schedule(true); + }, + + removeOptimisticUserEntry: (clientRequestId) => { + const turn = findTurnByCri(clientRequestId); + if (!turn || turn.folded) { + return; + } + if (turn.entries.length === 0) { + turns = turns.filter((candidate) => candidate !== turn); + } else if (turn.user) { + // Keep the run's content but settle a headless pending turn — it can + // never bind again, and a lingering pending turn would block the + // idle history enrich. + replaceTurn(turn, { + ...turn, + user: null, + phase: turn.phase === "streaming" ? turn.phase : "settled", + }); + } else { + return; + } + schedule(true); + }, + + appendLocalError: (message) => { + const alreadyShown = turns.some( + (turn) => + !turn.folded && + turn.entries.some( + (entry) => + (entry.kind === "assistant" || entry.kind === "error") && + entry.text.trim() === message.trim(), + ), + ); + if (alreadyShown) { + return; + } + const turn = createTurn({ key: `local:${localTurnSeq++}`, phase: "settled" }); + const withError = applyEventToTurn(turn, { type: "error", message } as ChatEvent); + if (withError.entries.length === 0) { + return; + } + turns = [...turns, withError]; + schedule(true); + }, + + applyHistorySnapshot: (entries, options) => { + const result = alignHistory({ + historyEntries, + turns, + entries, + mode: options?.mode ?? "enrich", + }); + if (!result.changed) { + return; + } + historyEntries = result.historyEntries; + turns = result.turns; + schedule(true); + }, + + foldSettledTurns: () => { + if (foldSettled(false)) { + schedule(true); + } + }, + + flush: () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + commit(); + }, + }; +} + +function isSnapshotChatEntry(value: unknown): value is ChatEntry { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const v = value as Record; + if (typeof v.id !== "string" || typeof v.kind !== "string") { + return false; + } + switch (v.kind) { + case "user": + return typeof v.text === "string" && Array.isArray(v.attachments); + case "assistant": + case "thinking": + case "error": + return typeof v.text === "string"; + case "tool_call": + return v.toolCall != null && typeof v.toolCall === "object"; + case "tool_result": + return v.toolResult != null && typeof v.toolResult === "object"; + case "hosted_search": + return v.hostedSearch != null && typeof v.hostedSearch === "object"; + default: + return false; + } +} + +function parseSnapshotEntries(json: string | undefined): ChatEntry[] { + const raw = typeof json === "string" ? json.trim() : ""; + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(isSnapshotChatEntry) : []; + } catch { + return []; + } +} diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts new file mode 100644 index 00000000..734282d9 --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts @@ -0,0 +1,651 @@ +import { isAbortLikeError } from "@/lib/chat/chatPageHelpers"; +import { + enrichHostedSearchBlockWithText, + mergeHostedSearchBlocks, + normalizeHostedSearchBlock, +} from "@/lib/chat/hostedSearch"; +import { summarizeToolCall } from "@/lib/chat/uiMessages"; +import { + buildAssistantMeta, + buildHostedSearchEntry, + buildToolCallEntry, + buildToolResultEntry, + type ChatEntry, + formatLiveErrorMessage, + hashText, + hashValue, + isCheckpointTokenEvent, + isMatchingToolCallEntry, + isMatchingToolResultEntry, + normalizeCheckpointEntry, + normalizeToolArguments, + normalizeToolCallLike, + safeStringify, + stripRecoveredToolCallMarkup, +} from "@/lib/chatUi"; +import type { ChatEvent } from "@/lib/gatewayTypes"; +import type { ToolCall } from "@/lib/agentTypes"; + +import type { Turn, TurnPhase } from "./types"; + +// Applies one assistant-side stream event to a turn. Every merge/dedup scan +// is bounded by the turn (and, within it, the segment since the last +// checkpoint/error entry) — the turn boundary replaces every "walk the tail +// back to the last user bubble" scan of the old flat-entry pipeline. +// +// Entry ids are deterministic in the run namespace (`r::…`) so a +// resubscribe replay rebuilds identical ids and nothing remounts. + +export function optimisticUserEntryId(clientRequestId: string): string { + return `ou:${clientRequestId}`; +} + +export function seededUserEntryId(runId: string): string { + return `r:${runId}:u`; +} + +export function createTurn(params: { + key: string; + runId?: string; + clientRequestId?: string; + user?: Turn["user"]; + phase?: TurnPhase; +}): Turn { + return { + key: params.key, + runId: params.runId ?? "", + clientRequestId: params.clientRequestId ?? "", + user: params.user ?? null, + entries: [], + phase: params.phase ?? "pending", + folded: false, + }; +} + +function turnNamespace(turn: Turn): string { + return `r:${turn.runId || turn.key}`; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readRound(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + return value > 0 ? Math.floor(value) : undefined; +} + +function recordHasEntries(value: Record): boolean { + return Object.keys(value).length > 0; +} + +// Start index of the current segment: entries after the last checkpoint or +// standalone error entry (both flush the assistant group in the row builder, +// so merges must not reach across them). +function segmentStartIndex(entries: ChatEntry[]): number { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const kind = entries[index]?.kind; + if (kind === "checkpoint" || kind === "error") { + return index + 1; + } + } + return 0; +} + +function findLastSegmentAssistantIndex(entries: ChatEntry[], round?: number): number { + const start = segmentStartIndex(entries); + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if (entry?.kind === "assistant" && (round === undefined || entry.round === round)) { + return index; + } + } + return -1; +} + +function countSegmentEntries( + entries: ChatEntry[], + matcher: (entry: ChatEntry) => boolean, +): number { + const start = segmentStartIndex(entries); + let count = 0; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry && matcher(entry)) { + count += 1; + } + } + return count; +} + +function countTurnEntries( + entries: ChatEntry[], + matcher: (entry: ChatEntry) => boolean, +): number { + let count = 0; + for (const entry of entries) { + if (matcher(entry)) { + count += 1; + } + } + return count; +} + +function segmentHasAssistantText(entries: ChatEntry[]): boolean { + const start = segmentStartIndex(entries); + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "assistant" && entry.text.trim()) { + return true; + } + } + return false; +} + +function hasSegmentEntry( + entries: ChatEntry[], + matcher: (entry: ChatEntry) => boolean, +): boolean { + const start = segmentStartIndex(entries); + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry && matcher(entry)) { + return true; + } + } + return false; +} + +// Fills empty tool-call arguments in place when a later event carries the +// full payload (tool_call_delta upgrades, tool_result argument echoes). +function mergeSegmentToolCallArguments( + entries: ChatEntry[], + params: { + id?: unknown; + name?: unknown; + arguments?: unknown; + round?: number; + }, +): { entries: ChatEntry[]; matched: boolean } { + const incomingArgs = normalizeToolArguments(params.arguments); + const hasIncomingArgs = recordHasEntries(incomingArgs); + const incomingId = readString(params.id).trim(); + const incomingName = readString(params.name).trim(); + const start = segmentStartIndex(entries); + + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if (entry?.kind !== "tool_call") { + continue; + } + if (params.round !== undefined && entry.round !== params.round) { + continue; + } + + const existingArgs = normalizeToolArguments(entry.toolCall.arguments); + const hasExistingArgs = recordHasEntries(existingArgs); + let matches = false; + let canUpdate = false; + + if (incomingId !== "") { + matches = entry.toolCall.id === incomingId; + canUpdate = + matches && hasIncomingArgs && safeStringify(existingArgs) !== safeStringify(incomingArgs); + } else if (incomingName !== "" && entry.toolCall.name === incomingName) { + const sameArguments = safeStringify(existingArgs) === safeStringify(incomingArgs); + matches = sameArguments || (!hasExistingArgs && hasIncomingArgs); + canUpdate = matches && !hasExistingArgs && hasIncomingArgs; + } + + if (!matches) { + continue; + } + if (!canUpdate) { + return { entries, matched: true }; + } + + const nextToolCall = { + ...entry.toolCall, + id: incomingId || entry.toolCall.id, + name: incomingName || entry.toolCall.name, + arguments: incomingArgs, + } as ToolCall; + const next = entries.slice(); + next[index] = { + ...entry, + toolCall: nextToolCall, + summary: summarizeToolCall(nextToolCall), + text: safeStringify(nextToolCall.arguments), + }; + return { entries: next, matched: true }; + } + + return { entries, matched: false }; +} + +// Hosted-search blocks stream before the text that cites them; once the +// segment has assistant text, backfill citation sources into source-less +// search entries. +function enrichSegmentHostedSearches(entries: ChatEntry[]): ChatEntry[] { + const start = segmentStartIndex(entries); + + // Cheap pre-check: without a source-less search there is nothing to + // enrich, and this runs after every token append. + let hasSourcelessSearch = false; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "hosted_search" && entry.hostedSearch.sources.length === 0) { + hasSourcelessSearch = true; + break; + } + } + if (!hasSourcelessSearch) { + return entries; + } + + let allText = ""; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind === "assistant") { + allText += entry.text; + } + } + if (allText === "") { + return entries; + } + + let next: ChatEntry[] | null = null; + for (let index = start; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry?.kind !== "hosted_search" || entry.hostedSearch.sources.length > 0) { + continue; + } + + let nextSearchIndex = entries.length; + for (let probe = index + 1; probe < entries.length; probe += 1) { + if (entries[probe]?.kind === "hosted_search") { + nextSearchIndex = probe; + break; + } + } + + let nearbyText = ""; + for (let probe = index + 1; probe < nextSearchIndex; probe += 1) { + const textEntry = entries[probe]; + if (textEntry?.kind === "assistant") { + nearbyText += textEntry.text; + } + } + + const enriched = enrichHostedSearchBlockWithText(entry.hostedSearch, nearbyText || allText); + if (enriched.sources.length === entry.hostedSearch.sources.length) { + continue; + } + + if (!next) { + next = entries.slice(); + } + next[index] = { ...entry, hostedSearch: enriched }; + } + + return next ?? entries; +} + +function withEntries(turn: Turn, entries: ChatEntry[]): Turn { + return entries === turn.entries ? turn : { ...turn, entries }; +} + +function applyTokenEvent(turn: Turn, event: Extract): Turn { + if (isCheckpointTokenEvent(event)) { + const checkpoint = normalizeCheckpointEntry({ + id: event.checkpoint?.summaryId, + content: event.text, + timestamp: event.checkpoint?.timestamp, + summaryMeta: { + coveredMessageCount: event.checkpoint?.coveredMessageCount, + generatedBy: event.checkpoint?.generatedBy, + }, + checkpoint: event.checkpoint, + fallbackId: `${turnNamespace(turn)}:cp:${countTurnEntries( + turn.entries, + (entry) => entry.kind === "checkpoint", + )}`, + }); + if ( + !checkpoint || + turn.entries.some( + (entry) => entry.kind === "checkpoint" && entry.summaryId === checkpoint.summaryId, + ) + ) { + return turn; + } + return withEntries(turn, [...turn.entries, checkpoint]); + } + + const text = stripRecoveredToolCallMarkup(event.text ?? ""); + const round = readRound(event.round); + const meta = buildAssistantMeta({ + provider: event.provider, + model: event.model, + api: event.api, + stopReason: event.stopReason, + usage: event.usage, + }); + + if (text === "" && !meta) { + return turn; + } + + const entries = turn.entries; + const tail = entries.at(-1); + const assistantIndex = + tail?.kind === "assistant" && (round === undefined || tail.round === round) + ? entries.length - 1 + : text === "" + ? findLastSegmentAssistantIndex(entries, round) + : -1; + if (assistantIndex >= 0) { + const target = entries[assistantIndex]; + if (target?.kind !== "assistant") return turn; + const next = entries.slice(); + next[assistantIndex] = { + ...target, + text: target.text + text, + round: round ?? target.round, + meta: meta ? { ...(target.meta ?? {}), ...meta } : target.meta, + }; + return withEntries(turn, enrichSegmentHostedSearches(next)); + } + + const occurrence = countSegmentEntries( + entries, + (entry) => entry.kind === "assistant" && entry.round === round, + ); + const next: ChatEntry[] = [ + ...entries, + { + id: `${turnNamespace(turn)}:a:${round ?? 0}:${occurrence}`, + kind: "assistant", + text, + round, + meta, + }, + ]; + return withEntries(turn, enrichSegmentHostedSearches(next)); +} + +function applyThinkingEvent(turn: Turn, event: Extract): Turn { + const text = stripRecoveredToolCallMarkup(event.text ?? ""); + if (text === "") { + return turn; + } + const round = readRound(event.round); + const entries = turn.entries; + const last = entries.at(-1); + if (last?.kind === "thinking" && last.round === round) { + return withEntries(turn, [...entries.slice(0, -1), { ...last, text: last.text + text }]); + } + const occurrence = countSegmentEntries( + entries, + (entry) => entry.kind === "thinking" && entry.round === round, + ); + return withEntries(turn, [ + ...entries, + { + id: `${turnNamespace(turn)}:th:${round ?? 0}:${occurrence}`, + kind: "thinking", + round, + text, + }, + ]); +} + +function applyToolCallEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const eventToolCall = normalizeToolCallLike(event); + const merged = mergeSegmentToolCallArguments(turn.entries, { + id: eventToolCall.id, + name: eventToolCall.name, + arguments: eventToolCall.arguments, + round, + }); + if (merged.matched) { + return withEntries(turn, merged.entries); + } + + const explicitId = readString(eventToolCall.id).trim(); + const baseId = explicitId + ? `${turnNamespace(turn)}:tc:${round ?? 0}:${explicitId}` + : `${turnNamespace(turn)}:tc:${round ?? 0}:${readString(eventToolCall.name).trim() || "Tool"}:${hashValue( + normalizeToolArguments(eventToolCall.arguments), + )}`; + const occurrence = countTurnEntries( + turn.entries, + (entry) => entry.kind === "tool_call" && entry.id.startsWith(baseId), + ); + const stableId = `${baseId}:${occurrence}`; + + return withEntries(turn, [ + ...turn.entries, + buildToolCallEntry( + { + id: eventToolCall.id, + name: eventToolCall.name, + arguments: eventToolCall.arguments, + }, + round, + { entryId: stableId, fallbackToolCallId: stableId }, + ), + ]); +} + +function applyToolResultEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const resultToolCall = normalizeToolCallLike(event); + const hasResultToolCallArgs = recordHasEntries(normalizeToolArguments(resultToolCall.arguments)); + const merged = mergeSegmentToolCallArguments(turn.entries, { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + round, + }); + const entries = merged.entries; + if ( + hasSegmentEntry(entries, (entry) => + isMatchingToolResultEntry(entry, { + toolCallId: resultToolCall.id ?? event.id, + toolName: resultToolCall.name ?? event.name, + content: event.content, + isError: event.isError, + round, + }), + ) + ) { + return withEntries(turn, entries); + } + + const explicitId = readString(resultToolCall.id ?? event.id).trim(); + const baseId = explicitId + ? `${turnNamespace(turn)}:tr:${round ?? 0}:${explicitId}` + : `${turnNamespace(turn)}:tr:${round ?? 0}:${readString(resultToolCall.name ?? event.name).trim() || "Tool"}:${ + event.isError ? "error" : "ok" + }:${hashValue(event.content)}`; + const occurrence = countTurnEntries( + turn.entries, + (entry) => entry.kind === "tool_result" && entry.id.startsWith(baseId), + ); + const stableId = `${baseId}:${occurrence}`; + const shouldPrependToolCall = + hasResultToolCallArgs && + !merged.matched && + !hasSegmentEntry(entries, (entry) => + isMatchingToolCallEntry(entry, { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + round, + }), + ); + + return withEntries(turn, [ + ...entries, + ...(shouldPrependToolCall + ? [ + buildToolCallEntry( + { + id: resultToolCall.id, + name: resultToolCall.name, + arguments: resultToolCall.arguments, + }, + round, + { + entryId: `${stableId}:tc`, + fallbackToolCallId: `${stableId}:tc`, + }, + ), + ] + : []), + buildToolResultEntry( + { + toolCallId: resultToolCall.id ?? event.id, + toolName: resultToolCall.name ?? event.name, + content: event.content, + details: event.details, + isError: event.isError, + }, + round, + { entryId: stableId, fallbackToolCallId: stableId }, + ), + ]); +} + +function applyHostedSearchEvent( + turn: Turn, + event: Extract, +): Turn { + const round = readRound(event.round); + const hostedSearch = normalizeHostedSearchBlock({ + type: "hostedSearch", + id: event.id, + provider: event.provider, + status: event.status, + queries: event.queries, + sources: event.sources, + updatedAt: event.updatedAt, + }); + if (!hostedSearch) return turn; + + const entries = turn.entries; + const start = segmentStartIndex(entries); + for (let index = entries.length - 1; index >= start; index -= 1) { + const entry = entries[index]; + if ( + entry?.kind === "hosted_search" && + entry.round === round && + entry.hostedSearch.id === hostedSearch.id + ) { + const next = entries.slice(); + next[index] = { + ...entry, + hostedSearch: mergeHostedSearchBlocks(entry.hostedSearch, hostedSearch), + }; + return withEntries(turn, enrichSegmentHostedSearches(next)); + } + } + + const baseId = `${turnNamespace(turn)}:hs:${round ?? 0}:${ + readString(hostedSearch.id).trim() || hashValue(hostedSearch.queries) + }`; + const occurrence = countTurnEntries( + entries, + (entry) => entry.kind === "hosted_search" && entry.id.startsWith(baseId), + ); + const next: ChatEntry[] = [ + ...entries, + buildHostedSearchEntry(hostedSearch, round, { + entryId: occurrence === 0 ? baseId : `${baseId}:${occurrence}`, + }), + ]; + return withEntries(turn, enrichSegmentHostedSearches(next)); +} + +function applyErrorEvent(turn: Turn, event: Extract): Turn { + const round = readRound(event.round); + const rawMessage = event.message ?? ""; + const message = formatLiveErrorMessage(rawMessage.trim() || "Request failed", false); + if (isAbortLikeError(message)) { + return turn; + } + const alreadyShown = turn.entries.some((entry) => { + if (entry.kind === "error" || entry.kind === "assistant") { + return entry.text.trim() === message; + } + return false; + }); + if (alreadyShown) { + return turn; + } + const text = `${segmentHasAssistantText(turn.entries) ? "\n\n" : ""}${message}`; + return withEntries(turn, [ + ...turn.entries, + { + id: `${turnNamespace(turn)}:err:${round ?? 0}:${hashText(message)}`, + kind: "assistant", + round, + text, + }, + ]); +} + +// Assistant-side stream events; user_message routing lives in the store (it +// picks the turn by client_request_id / run_id before any reducer runs). +export function applyEventToTurn(turn: Turn, event: ChatEvent): Turn { + switch (event.type) { + case "token": + return applyTokenEvent(turn, event); + case "thinking": + return applyThinkingEvent(turn, event); + case "tool_call": + case "tool_call_delta": + return applyToolCallEvent(turn, event); + case "tool_result": + return applyToolResultEvent(turn, event); + case "hosted_search": + return applyHostedSearchEvent(turn, event); + case "error": + return applyErrorEvent(turn, event); + default: + return turn; + } +} + +// Rebuilds a turn's content from a runtime snapshot (late join / reconnect +// mid-run). The rebuild targets the existing turn object: the turn key and an +// already-present user bubble keep their identity, so nothing remounts. +export function rebuildTurnFromSnapshot(turn: Turn, parsed: ChatEntry[]): Turn { + const ns = turnNamespace(turn); + let user = turn.user; + const entries: ChatEntry[] = []; + for (const entry of parsed) { + if (entry.kind === "user") { + if (!user) { + user = { ...entry, id: seededUserEntryId(turn.runId || turn.key) }; + } + continue; + } + // Snapshot entries carry runtime-assigned ids that are stable per run; + // prefixing with the turn namespace keeps them from colliding with other + // runs while staying identical across repeated snapshots. + entries.push({ ...entry, id: `${ns}:s:${entry.id}` } as ChatEntry); + } + if (user === turn.user && entries.length === 0 && turn.entries.length === 0) { + return turn; + } + return { ...turn, user, entries }; +} diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/types.ts b/crates/agent-gateway/web/src/lib/chat/transcript/types.ts new file mode 100644 index 00000000..efefd7ce --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/transcript/types.ts @@ -0,0 +1,94 @@ +import type { ChatEntry, GatewayTranscriptRound } from "@/lib/chatUi"; +import type { HistoryMessageRef } from "@/lib/chat/conversationState"; +import type { PendingUploadedFile } from "@/lib/chat/uploadedFiles"; +import type { StreamRunActivity } from "@/lib/chat/stream/streamTypes"; + +export type UserChatEntry = Extract; + +// A turn is one prompt/response exchange of the live stream: the user bubble +// (a single slot — a second user_message for the same run can only upsert it, +// never append a sibling) plus every assistant-side entry its run produced. +// Rows are emitted user-first from the same object, so "assistant content +// rendered above its own prompt" and "duplicate prompt bubble" are +// structurally unrepresentable. +export type TurnPhase = "pending" | "streaming" | "settled"; + +export type Turn = { + // Render identity, fixed at creation and never re-keyed: + // req: — this client's own submissions + // run: — foreign/seeded turns (other viewers, replays) + // local: — local error pseudo-turns + key: string; + // "" until the stream binds the turn to a run. + runId: string; + // "" for foreign turns. + clientRequestId: string; + user: UserChatEntry | null; + // Assistant-side entries: assistant | thinking | tool_call | tool_result | + // hosted_search | checkpoint | error. + entries: ChatEntry[]; + phase: TurnPhase; + // Folded turns render inside the virtualized region; the fold only flips + // this flag — row keys and objects never change, so nothing remounts. + folded: boolean; +}; + +export type TranscriptRowOrigin = "history" | "stream"; + +// One rendered transcript row. Both regions (virtualized + live flow) are +// slices of a single row list, so an entry can never render twice. +export type TranscriptRow = + | { + key: string; + origin: TranscriptRowOrigin; + kind: "user"; + text: string; + attachments: PendingUploadedFile[]; + messageRef?: HistoryMessageRef; + } + | { + key: string; + origin: TranscriptRowOrigin; + kind: "assistant"; + rounds: GatewayTranscriptRound[]; + turnKey?: string; + } + | { + key: string; + origin: TranscriptRowOrigin; + kind: "checkpoint"; + content: string; + summaryId: string; + coveredMessageCount: number; + generatedBy: { + providerId: string; + model: string; + promptVersion?: string; + }; + timestamp?: number; + } + | { key: string; origin: TranscriptRowOrigin; kind: "error"; text: string }; + +export type HistoryApplyMode = "replace" | "enrich"; + +export type TranscriptSnapshot = { + // Rendered in the virtualized region. Identity-stable across streaming + // commits — the array only changes when the history region or the folded + // turn set changes — so the virtualized region skips re-renders while a + // reply streams. + foldedRows: readonly TranscriptRow[]; + // Rendered in the plain live flow below the virtualized region. + liveRows: readonly TranscriptRow[]; + // Key of the turn whose run is streaming (live structural state + caret + // attribution in the renderer). + activeTurnKey: string | null; + // Total entry count (history entries + turn entries + user slots) — drives + // "does this conversation hold content" checks. + entryCount: number; + activeRun: StreamRunActivity | null; + toolStatus: string | null; + toolStatusIsCompaction: boolean; + // Bumped whenever turns fold into the virtualized region. + foldRevision: number; + revision: number; +}; diff --git a/crates/agent-gateway/web/src/lib/chatUi.ts b/crates/agent-gateway/web/src/lib/chatUi.ts index 675d9254..50c7dfaf 100644 --- a/crates/agent-gateway/web/src/lib/chatUi.ts +++ b/crates/agent-gateway/web/src/lib/chatUi.ts @@ -1,35 +1,19 @@ import type { Message, ToolCall, ToolResultMessage, Usage } from "@/lib/agentTypes"; -import { isAbortLikeError } from "@/lib/chat/chatPageHelpers"; import type { HistoryMessageRef } from "@/lib/chat/conversationState"; import { getUserMessageAttachments, getUserMessageDisplayText, type PendingUploadedFile, - uploadedFilesVisuallyEqual, } from "@/lib/chat/uploadedFiles"; import { - appendTextDeltaToRound, - appendThinkingDeltaToRound, - attachToolResultToRound, - buildDelegateAgentPlaceholderToolCalls, - getRoundToolTrace, summarizeToolCall as summarizeDesktopToolCall, - upsertHostedSearchToRound, - upsertToolCallToRound, type UiRound, } from "@/lib/chat/uiMessages"; import { - enrichHostedSearchBlockWithText, - mergeHostedSearchBlocks, normalizeHostedSearchBlock, type HostedSearchBlock, } from "@/lib/chat/hostedSearch"; -import type { - DelegateAgentCardResultDetails, - DelegateAgentItemResultDetails, - DelegateAgentResultDetails, -} from "@/lib/tools/builtinTypes"; import type { ChatCheckpointPayload, @@ -45,31 +29,6 @@ export type GatewayTranscriptRound = UiRound & { thinkingOpen?: boolean; }; -export type GatewayTranscriptItem = - | { - id: string; - kind: "user"; - text: string; - attachments: PendingUploadedFile[]; - userOrdinal: number; - messageRef?: HistoryMessageRef; - } - | { - id: string; - kind: "checkpoint"; - content: string; - summaryId: string; - coveredMessageCount: number; - generatedBy: { - providerId: string; - model: string; - promptVersion?: string; - }; - timestamp?: number; - } - | { id: string; kind: "assistant"; rounds: GatewayTranscriptRound[] } - | { id: string; kind: "error"; text: string }; - export type ChatEntry = | { id: string; @@ -117,41 +76,6 @@ export type ChatEntry = } | { id: string; kind: "error"; text: string }; -// Stable content identity for deduplicating the same logical entry across the -// three transcript sources (live stream, committed runtime messages, parsed -// history). Tool entries are compared by identity rather than content: the -// gateway trims large tool arguments/results on the live path and tool result -// timestamps differ per parse, so content comparison would classify every -// live entry as different from its persisted twin — forcing a full transcript -// replacement (and a visible flash) whenever history is refreshed. -export function chatEntryDedupKey(entry: ChatEntry): string { - switch (entry.kind) { - case "user": - return JSON.stringify({ kind: entry.kind, text: entry.text }); - case "assistant": - return JSON.stringify({ kind: entry.kind, text: entry.text, round: entry.round ?? 0 }); - case "thinking": - return JSON.stringify({ kind: entry.kind, text: entry.text, round: entry.round ?? 0 }); - case "tool_call": - return JSON.stringify({ - kind: entry.kind, - identity: entry.toolCall.id.trim() || entry.toolCall.name, - round: entry.round ?? 0, - }); - case "tool_result": - return JSON.stringify({ - kind: entry.kind, - identity: entry.toolResult.toolCallId.trim() || entry.toolResult.toolName, - isError: Boolean(entry.toolResult.isError), - round: entry.round ?? 0, - }); - default: { - const { id: _id, ...rest } = entry; - return JSON.stringify(rest); - } - } -} - type StoredMessage = { role?: unknown; id?: unknown; @@ -170,7 +94,7 @@ type StoredMessage = { liveAgentHistoryRef?: unknown; }; -type ToolCallLike = { +export type ToolCallLike = { id?: unknown; name?: unknown; toolCallId?: unknown; @@ -194,12 +118,6 @@ type NormalizedAssistantBlock = | { type: "toolCall"; toolCall: ToolCallLike } | { type: "hostedSearch"; hostedSearch: HostedSearchBlock }; -type AssistantGroupBuilder = { - id: string; - rounds: GatewayTranscriptRound[]; - roundIndexByNumber: Map; -}; - type UploadedFilesUserMessage = Pick & Record; const LIVE_UPLOADED_FILE_KINDS = new Set([ @@ -216,7 +134,7 @@ function randomId(prefix: string) { return `${prefix}-${crypto.randomUUID()}`; } -function hashText(value: string) { +export function hashText(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); @@ -225,7 +143,7 @@ function hashText(value: string) { return (hash >>> 0).toString(36); } -function hashValue(value: unknown) { +export function hashValue(value: unknown) { return hashText(safeStringify(value)); } @@ -235,7 +153,7 @@ const DSML_TOOL_CALL_DISPLAY_PATTERN = new RegExp( "gi", ); -function stripRecoveredToolCallMarkup(value: string) { +export function stripRecoveredToolCallMarkup(value: string) { if ( !value.includes("") && !(value.includes("DSML") && value.includes("tool_calls")) @@ -250,14 +168,6 @@ function stripRecoveredToolCallMarkup(value: string) { .trim(); } -function buildAssistantGroupId(seedEntryId: string) { - return `assistant-group-${seedEntryId}`; -} - -function buildTranscriptRoundKey(groupId: string, round: number) { - return `${groupId}-round-${round}`; -} - function readString(value: unknown) { return typeof value === "string" ? value : ""; } @@ -308,7 +218,7 @@ function normalizeLiveUploadedFile(value: unknown): PendingUploadedFile | null { return file; } -function normalizeLiveUploadedFiles(value: unknown): PendingUploadedFile[] { +export function normalizeLiveUploadedFiles(value: unknown): PendingUploadedFile[] { if (!Array.isArray(value)) { return []; } @@ -317,18 +227,6 @@ function normalizeLiveUploadedFiles(value: unknown): PendingUploadedFile[] { .filter((file): file is PendingUploadedFile => file !== null); } -function liveUserEntryMatches( - entry: ChatEntry | undefined, - text: string, - attachments: PendingUploadedFile[], -) { - return ( - entry?.kind === "user" && - entry.text === text && - uploadedFilesVisuallyEqual(entry.attachments, attachments) - ); -} - function readHistoryMessageRef(value: unknown): HistoryMessageRef | undefined { const record = asRecord(value); const segmentIndex = readNumber(record.segmentIndex ?? record.segment_index); @@ -402,7 +300,7 @@ function parseJsonRecord(value: unknown): Record { } } -function normalizeToolArguments(...candidates: unknown[]): Record { +export function normalizeToolArguments(...candidates: unknown[]): Record { for (const candidate of candidates) { const direct = asNonArrayRecord(candidate); if (recordHasEntries(direct)) { @@ -416,7 +314,7 @@ function normalizeToolArguments(...candidates: unknown[]): Record value !== undefined) ? meta : undefined; } -function normalizeCheckpointEntry(params: { +export function normalizeCheckpointEntry(params: { id?: unknown; content?: unknown; timestamp?: unknown; @@ -564,7 +462,7 @@ function normalizeCheckpointEntry(params: { }; } -function isCheckpointTokenEvent(event: Extract) { +export function isCheckpointTokenEvent(event: Extract) { return Boolean( event.checkpoint || event.api === "liveagent-compaction" || @@ -669,7 +567,7 @@ function getTextFromContent(content: unknown) { return text; } -function getToolResultText(content: unknown) { +export function getToolResultText(content: unknown) { const directText = getTextFromContent(content); if (directText.trim() !== "") return directText; @@ -729,7 +627,7 @@ function normalizeAssistantBlocks(content: unknown): NormalizedAssistantBlock[] return blocks; } -function buildToolCallEntry( +export function buildToolCallEntry( toolCall: ToolCallLike, round?: number, options?: { @@ -751,7 +649,7 @@ function buildToolCallEntry( }; } -function buildToolResultEntry( +export function buildToolResultEntry( message: StoredMessage, round?: number, options?: { @@ -778,7 +676,7 @@ function buildToolResultEntry( }; } -function buildHostedSearchEntry( +export function buildHostedSearchEntry( hostedSearch: HostedSearchBlock, round?: number, options?: { entryId?: string }, @@ -791,7 +689,7 @@ function buildHostedSearchEntry( }; } -function formatLiveErrorMessage(message: string, prefix: boolean) { +export function formatLiveErrorMessage(message: string, prefix: boolean) { if (!prefix || message === "Request failed") { return message; } @@ -800,32 +698,6 @@ function formatLiveErrorMessage(message: string, prefix: boolean) { : `Request failed: ${message}`; } -function hasTailAssistantText(entries: ChatEntry[]) { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) continue; - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - return false; - } - if (entry.kind === "assistant" && entry.text.trim()) { - return true; - } - } - return false; -} - -function hasLiveErrorTextEntry(entries: ChatEntry[], message: string) { - return entries.some((entry) => { - if (entry.kind === "error") { - return entry.text.trim() === message; - } - if (entry.kind === "assistant") { - return entry.text.trim() === message; - } - return false; - }); -} - export function parseHistoryMessagesJson(raw: string): ChatEntry[] { if (raw.trim() === "") return []; @@ -834,27 +706,23 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { parsed = JSON.parse(raw); } catch (error) { const message = error instanceof Error ? error.message : "unknown error"; - return [ - { - id: randomId("history-error"), - kind: "error", - text: `历史消息解析失败:${message}`, - }, - ]; + const text = `历史消息解析失败:${message}`; + return [{ id: `history-error:${hashText(text)}`, kind: "error", text }]; } if (!Array.isArray(parsed)) { - return [ - { - id: randomId("history-error"), - kind: "error", - text: "历史消息载荷不是数组,无法渲染。", - }, - ]; + const text = "历史消息载荷不是数组,无法渲染。"; + return [{ id: `history-error:${hashText(text)}`, kind: "error", text }]; } const entries: ChatEntry[] = []; + const usedUserIds = new Map(); let currentRound = 0; + // Turn anchor: the current user entry's id ("h:^" for a window that starts + // mid-turn) plus a per-turn block counter for every non-user entry. + let turnKey = "ht:^"; + let turnEntrySeq = 0; + const nextEntryId = () => `${turnKey}>${turnEntrySeq++}`; for (const item of parsed) { const message = asRecord(item) as StoredMessage; @@ -867,8 +735,16 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { const attachments = getUserMessageAttachments(userRecord); const messageRef = readHistoryMessageRef(userRecord.liveAgentHistoryRef); if (text.trim() || attachments.length > 0) { + const baseId = messageRef + ? `hu:${messageRef.messageId}` + : `hu:~${hashText(text)}`; + const occurrence = usedUserIds.get(baseId) ?? 0; + usedUserIds.set(baseId, occurrence + 1); + const id = occurrence === 0 ? baseId : `${baseId}:${occurrence}`; + turnKey = `ht:${id}`; + turnEntrySeq = 0; entries.push({ - id: messageRef ? `user-${messageRef.messageId}` : randomId("user"), + id, kind: "user", text, attachments, @@ -884,7 +760,7 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { content: message.content, timestamp: message.timestamp, summaryMeta: message.summaryMeta, - fallbackId: randomId("checkpoint"), + fallbackId: nextEntryId(), }); if (checkpoint) { entries.push(checkpoint); @@ -909,7 +785,7 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { const flushText = () => { if (textBuffer === "" && (!meta || metaEmitted)) return; entries.push({ - id: randomId("assistant"), + id: nextEntryId(), kind: "assistant", text: textBuffer, round, @@ -931,7 +807,7 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { if (block.type === "thinking" && block.text.trim()) { entries.push({ - id: randomId("thinking"), + id: nextEntryId(), kind: "thinking", round, text: block.text, @@ -939,11 +815,17 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { } if (block.type === "toolCall") { - entries.push(buildToolCallEntry(block.toolCall, round)); + const entryId = nextEntryId(); + entries.push( + buildToolCallEntry(block.toolCall, round, { + entryId, + fallbackToolCallId: entryId, + }), + ); } if (block.type === "hostedSearch") { - entries.push(buildHostedSearchEntry(block.hostedSearch, round)); + entries.push(buildHostedSearchEntry(block.hostedSearch, round, { entryId: nextEntryId() })); } } @@ -952,141 +834,20 @@ export function parseHistoryMessagesJson(raw: string): ChatEntry[] { } if (role === "toolResult") { - entries.push(buildToolResultEntry(message, currentRound || 1)); + const entryId = nextEntryId(); + entries.push( + buildToolResultEntry(message, currentRound || 1, { + entryId, + fallbackToolCallId: entryId, + }), + ); } } return entries; } -function findLastAssistantEntryIndex(entries: ChatEntry[], round?: number) { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) continue; - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - if (entry.kind === "assistant" && (round === undefined || entry.round === round)) { - return index; - } - } - return -1; -} - -function hasTailAssistantEntry( - entries: ChatEntry[], - matcher: (entry: ChatEntry) => boolean, -) { - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) { - continue; - } - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - if (matcher(entry)) { - return true; - } - } - return false; -} - -function countTailAssistantEntries( - entries: ChatEntry[], - matcher: (entry: ChatEntry) => boolean, -) { - let count = 0; - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) { - continue; - } - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - if (matcher(entry)) { - count += 1; - } - } - return count; -} - -function buildLiveAssistantEntryId(round?: number, occurrence = 0) { - const baseId = `live-assistant-${round ?? 0}`; - return occurrence <= 0 ? baseId : `${baseId}-${occurrence}`; -} - -function isLiveAssistantEntryIdForRound(id: string, round?: number, idPrefix = "") { - const baseId = `${idPrefix}live-assistant-${round ?? 0}`; - return id === baseId || id.startsWith(`${baseId}-`); -} - -function buildLiveThinkingEntryId(round: number | undefined, occurrence: number) { - return `live-thinking-${round ?? 0}-${occurrence}`; -} - -function buildLiveToolCallBaseId(params: { - round?: number; - id?: unknown; - name?: unknown; - arguments?: unknown; -}) { - const explicitId = readString(params.id).trim(); - if (explicitId !== "") { - return `live-tool-call-${params.round ?? 0}-${explicitId}`; - } - return [ - "live-tool-call", - String(params.round ?? 0), - readString(params.name).trim() || "Tool", - hashValue(asRecord(params.arguments)), - ].join("-"); -} - -function buildLiveToolResultBaseId(params: { - round?: number; - toolCallId?: unknown; - toolName?: unknown; - content?: unknown; - isError?: unknown; -}) { - const explicitId = readString(params.toolCallId).trim(); - if (explicitId !== "") { - return `live-tool-result-${params.round ?? 0}-${explicitId}`; - } - return [ - "live-tool-result", - String(params.round ?? 0), - readString(params.toolName).trim() || "Tool", - Boolean(params.isError) ? "error" : "ok", - hashValue(params.content), - ].join("-"); -} - -function buildLiveHostedSearchBaseId(params: { - round?: number; - id?: unknown; - queries?: unknown; -}) { - const explicitId = readString(params.id).trim(); - if (explicitId !== "") { - return `live-hosted-search-${params.round ?? 0}-${explicitId}`; - } - return [ - "live-hosted-search", - String(params.round ?? 0), - hashValue(params.queries), - ].join("-"); -} - -function hasCheckpointEntry(entries: ChatEntry[], summaryId: string) { - return entries.some( - (entry) => entry.kind === "checkpoint" && entry.summaryId === summaryId, - ); -} - -function isMatchingToolCallEntry( +export function isMatchingToolCallEntry( entry: ChatEntry, params: { id?: unknown; @@ -1115,7 +876,7 @@ function isMatchingToolCallEntry( return safeStringify(entry.toolCall.arguments) === safeStringify(asRecord(params.arguments)); } -function isMatchingToolResultEntry( +export function isMatchingToolResultEntry( entry: ChatEntry, params: { toolCallId?: unknown; @@ -1148,861 +909,6 @@ function isMatchingToolResultEntry( return getToolResultText(entry.toolResult.content) === getToolResultText(params.content); } -function mergeTailToolCallArguments( - entries: ChatEntry[], - params: { - id?: unknown; - name?: unknown; - arguments?: unknown; - round?: number; - }, -) { - const incomingArgs = normalizeToolArguments(params.arguments); - const hasIncomingArgs = recordHasEntries(incomingArgs); - const incomingId = readString(params.id).trim(); - const incomingName = readString(params.name).trim(); - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) { - continue; - } - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - if (entry.kind !== "tool_call") { - continue; - } - if (params.round !== undefined && entry.round !== params.round) { - continue; - } - - const existingArgs = normalizeToolArguments(entry.toolCall.arguments); - const hasExistingArgs = recordHasEntries(existingArgs); - let matches = false; - let canUpdate = false; - - if (incomingId !== "") { - matches = entry.toolCall.id === incomingId; - canUpdate = - matches && - hasIncomingArgs && - safeStringify(existingArgs) !== safeStringify(incomingArgs); - } else if (incomingName !== "" && entry.toolCall.name === incomingName) { - const sameArguments = - safeStringify(existingArgs) === safeStringify(incomingArgs); - matches = sameArguments || (!hasExistingArgs && hasIncomingArgs); - canUpdate = matches && !hasExistingArgs && hasIncomingArgs; - } - - if (!matches) { - continue; - } - if (!canUpdate) { - return { entries, matched: true }; - } - - const nextToolCall = { - ...entry.toolCall, - id: incomingId || entry.toolCall.id, - name: incomingName || entry.toolCall.name, - arguments: incomingArgs, - } as ToolCall; - const next = entries.slice(); - next[index] = { - ...entry, - toolCall: nextToolCall, - summary: summarizeToolCall(nextToolCall), - text: safeStringify(nextToolCall.arguments), - }; - return { entries: next, matched: true }; - } - - return { entries, matched: false }; -} - -function enrichTailHostedSearchEntriesWithText(entries: ChatEntry[]): ChatEntry[] { - let startIndex = 0; - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) continue; - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - startIndex = index + 1; - break; - } - } - - let allText = ""; - for (let index = startIndex; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry?.kind === "assistant") { - allText += entry.text; - } - } - if (allText === "") { - return entries; - } - - let next: ChatEntry[] | null = null; - for (let index = startIndex; index < entries.length; index += 1) { - const entry = entries[index]; - if (entry?.kind !== "hosted_search" || entry.hostedSearch.sources.length > 0) { - continue; - } - - let nextSearchIndex = entries.length; - for (let probe = index + 1; probe < entries.length; probe += 1) { - if (entries[probe]?.kind === "hosted_search") { - nextSearchIndex = probe; - break; - } - } - - let nearbyText = ""; - for (let probe = index + 1; probe < nextSearchIndex; probe += 1) { - const textEntry = entries[probe]; - if (textEntry?.kind === "assistant") { - nearbyText += textEntry.text; - } - } - - const enriched = enrichHostedSearchBlockWithText( - entry.hostedSearch, - nearbyText || allText, - ); - if (enriched.sources.length === entry.hostedSearch.sources.length) { - continue; - } - - if (!next) { - next = entries.slice(); - } - next[index] = { - ...entry, - hostedSearch: enriched, - }; - } - - return next ?? entries; -} - -export type PushChatEventOptions = { - // Namespaces generated entry ids (e.g. "run-abc/") so entries from - // different runs of one conversation can never collide on id. - entryIdPrefix?: string; -}; - -export function pushChatEvent( - entries: ChatEntry[], - event: ChatEvent, - options?: PushChatEventOptions, -): ChatEntry[] { - const idPrefix = options?.entryIdPrefix ?? ""; - if (event.type === "user_message") { - const text = readString(event.message); - const attachments = normalizeLiveUploadedFiles(event.uploaded_files); - if (!text.trim() && attachments.length === 0) { - return entries; - } - if (liveUserEntryMatches(entries.at(-1), text, attachments)) { - return entries; - } - return [ - ...entries, - { - id: idPrefix + randomId("live-user"), - kind: "user", - text, - attachments, - }, - ]; - } - - if (event.type === "token") { - if (isCheckpointTokenEvent(event)) { - const checkpoint = normalizeCheckpointEntry({ - id: event.checkpoint?.summaryId, - content: event.text, - timestamp: event.checkpoint?.timestamp, - summaryMeta: { - coveredMessageCount: event.checkpoint?.coveredMessageCount, - generatedBy: event.checkpoint?.generatedBy, - }, - checkpoint: event.checkpoint, - fallbackId: randomId("checkpoint"), - }); - if (!checkpoint || hasCheckpointEntry(entries, checkpoint.summaryId)) { - return entries; - } - return [...entries, checkpoint]; - } - - const text = stripRecoveredToolCallMarkup(event.text ?? ""); - const round = readRound(event.round); - const meta = buildAssistantMeta({ - provider: event.provider, - model: event.model, - api: event.api, - stopReason: event.stopReason, - usage: event.usage, - }); - - if (text === "" && !meta) { - return entries; - } - - const tail = entries.at(-1); - const assistantIndex = - tail?.kind === "assistant" && (round === undefined || tail.round === round) - ? entries.length - 1 - : text === "" - ? findLastAssistantEntryIndex(entries, round) - : -1; - if (assistantIndex >= 0) { - const target = entries[assistantIndex]; - if (target?.kind !== "assistant") return entries; - const next = entries.slice(); - next[assistantIndex] = { - ...target, - text: target.text + text, - round: round ?? target.round, - meta: meta ? { ...(target.meta ?? {}), ...meta } : target.meta, - }; - return enrichTailHostedSearchEntriesWithText(next); - } - - const occurrence = countTailAssistantEntries( - entries, - (entry) => - entry.kind === "assistant" && - isLiveAssistantEntryIdForRound(entry.id, round, idPrefix), - ); - const next: ChatEntry[] = [ - ...entries, - { - id: idPrefix + buildLiveAssistantEntryId(round, occurrence), - kind: "assistant", - text, - round, - meta, - }, - ]; - return enrichTailHostedSearchEntriesWithText(next); - } - - if (event.type === "thinking") { - const text = stripRecoveredToolCallMarkup(event.text ?? ""); - if (text === "") { - return entries; - } - const round = readRound(event.round); - const last = entries.at(-1); - if (last?.kind === "thinking" && last.round === round) { - return [...entries.slice(0, -1), { ...last, text: last.text + text }]; - } - const occurrence = countTailAssistantEntries( - entries, - (entry) => entry.kind === "thinking" && entry.round === round, - ); - return [ - ...entries, - { - id: idPrefix + buildLiveThinkingEntryId(round, occurrence), - kind: "thinking", - round, - text, - }, - ]; - } - - if (event.type === "tool_call" || event.type === "tool_call_delta") { - const round = readRound(event.round); - const eventToolCall = normalizeToolCallLike(event); - const mergedToolCall = mergeTailToolCallArguments(entries, { - id: eventToolCall.id, - name: eventToolCall.name, - arguments: eventToolCall.arguments, - round, - }); - if (mergedToolCall.matched) { - return mergedToolCall.entries; - } - - const baseId = idPrefix + buildLiveToolCallBaseId({ - round, - id: eventToolCall.id, - name: eventToolCall.name, - arguments: eventToolCall.arguments, - }); - const occurrence = countTailAssistantEntries(entries, (entry) => - entry.kind === "tool_call" && entry.id.startsWith(baseId), - ); - const stableId = `${baseId}-${occurrence}`; - - return [ - ...entries, - buildToolCallEntry( - { - id: eventToolCall.id, - name: eventToolCall.name, - arguments: eventToolCall.arguments, - }, - round, - { - entryId: stableId, - fallbackToolCallId: stableId, - }, - ), - ]; - } - - if (event.type === "tool_result") { - const round = readRound(event.round); - const resultToolCall = normalizeToolCallLike(event); - const hasResultToolCallArgs = recordHasEntries( - normalizeToolArguments(resultToolCall.arguments), - ); - const mergedToolCall = mergeTailToolCallArguments(entries, { - id: resultToolCall.id, - name: resultToolCall.name, - arguments: resultToolCall.arguments, - round, - }); - const nextEntries = mergedToolCall.entries; - if ( - hasTailAssistantEntry(nextEntries, (entry) => - isMatchingToolResultEntry(entry, { - toolCallId: resultToolCall.id ?? event.id, - toolName: resultToolCall.name ?? event.name, - content: event.content, - isError: event.isError, - round, - }), - ) - ) { - return nextEntries; - } - - const baseId = idPrefix + buildLiveToolResultBaseId({ - round, - toolCallId: resultToolCall.id ?? event.id, - toolName: resultToolCall.name ?? event.name, - content: event.content, - isError: event.isError, - }); - const occurrence = countTailAssistantEntries(entries, (entry) => - entry.kind === "tool_result" && entry.id.startsWith(baseId), - ); - const stableId = `${baseId}-${occurrence}`; - const shouldPrependToolCall = - hasResultToolCallArgs && - !mergedToolCall.matched && - !hasTailAssistantEntry(nextEntries, (entry) => - isMatchingToolCallEntry(entry, { - id: resultToolCall.id, - name: resultToolCall.name, - arguments: resultToolCall.arguments, - round, - }), - ); - - return [ - ...nextEntries, - ...(shouldPrependToolCall - ? [ - buildToolCallEntry( - { - id: resultToolCall.id, - name: resultToolCall.name, - arguments: resultToolCall.arguments, - }, - round, - { - entryId: `${stableId}-tool-call`, - fallbackToolCallId: `${stableId}-tool-call`, - }, - ), - ] - : []), - buildToolResultEntry( - { - toolCallId: resultToolCall.id ?? event.id, - toolName: resultToolCall.name ?? event.name, - content: event.content, - details: event.details, - isError: event.isError, - }, - round, - { - entryId: stableId, - fallbackToolCallId: stableId, - }, - ), - ]; - } - - if (event.type === "hosted_search") { - const round = readRound(event.round); - const hostedSearch = normalizeHostedSearchBlock({ - type: "hostedSearch", - id: event.id, - provider: event.provider, - status: event.status, - queries: event.queries, - sources: event.sources, - updatedAt: event.updatedAt, - }); - if (!hostedSearch) return entries; - - for (let index = entries.length - 1; index >= 0; index -= 1) { - const entry = entries[index]; - if (!entry) continue; - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - break; - } - if ( - entry.kind === "hosted_search" && - entry.round === round && - entry.hostedSearch.id === hostedSearch.id - ) { - const next = entries.slice(); - next[index] = { - ...entry, - hostedSearch: mergeHostedSearchBlocks(entry.hostedSearch, hostedSearch), - }; - return enrichTailHostedSearchEntriesWithText(next); - } - } - - const baseId = idPrefix + buildLiveHostedSearchBaseId({ - round, - id: hostedSearch.id, - queries: hostedSearch.queries, - }); - const next: ChatEntry[] = [ - ...entries, - buildHostedSearchEntry(hostedSearch, round, { entryId: baseId }), - ]; - return enrichTailHostedSearchEntriesWithText(next); - } - - if (event.type === "error") { - const round = readRound(event.round); - const rawMessage = event.message ?? ""; - const message = formatLiveErrorMessage(rawMessage.trim() || "Request failed", false); - if (isAbortLikeError(message)) { - return entries; - } - if (hasLiveErrorTextEntry(entries, message)) { - return entries; - } - const errorId = hashText(message); - const text = `${hasTailAssistantText(entries) ? "\n\n" : ""}${message}`; - return [ - ...entries, - { - id: `${idPrefix}live-error-${round ?? 0}-${errorId}`, - kind: "assistant", - round, - text, - }, - ]; - } - - return entries; -} - -function createTranscriptRound(groupId: string, round: number): GatewayTranscriptRound { - return { - key: buildTranscriptRoundKey(groupId, round), - round, - blocks: [], - runningToolCallIds: [], - }; -} - -function ensureAssistantGroup(builder: AssistantGroupBuilder | null, seedEntryId: string) { - if (builder) return builder; - return { - id: buildAssistantGroupId(seedEntryId), - rounds: [], - roundIndexByNumber: new Map(), - }; -} - -function ensureTranscriptRound( - builder: AssistantGroupBuilder, - requestedRound?: number, -): GatewayTranscriptRound { - const roundNumber = - requestedRound ?? - builder.rounds[builder.rounds.length - 1]?.round ?? - 1; - const existingIndex = builder.roundIndexByNumber.get(roundNumber); - if (existingIndex !== undefined) { - return builder.rounds[existingIndex]; - } - - const nextRound = createTranscriptRound(builder.id, roundNumber); - builder.roundIndexByNumber.set(roundNumber, builder.rounds.length); - builder.rounds.push(nextRound); - return nextRound; -} - -function updateTranscriptRound( - builder: AssistantGroupBuilder, - roundNumber: number, - updater: (round: GatewayTranscriptRound) => GatewayTranscriptRound, -) { - const round = ensureTranscriptRound(builder, roundNumber); - const index = builder.roundIndexByNumber.get(round.round) ?? 0; - const nextRound = updater(round); - builder.rounds[index] = nextRound; - return nextRound; -} - -function collapseThinking(round: GatewayTranscriptRound): GatewayTranscriptRound { - if (!round.thinkingOpen) return round; - return { ...round, thinkingOpen: false }; -} - -function mergeAssistantMeta( - current: AssistantMeta | undefined, - next: AssistantMeta | undefined, -) { - if (!next) return current; - return { - ...(current ?? {}), - ...next, - }; -} - -function findToolCallInRound(round: GatewayTranscriptRound, toolCallId: string) { - return getRoundToolTrace(round).find((item) => item.toolCall.id === toolCallId)?.toolCall; -} - -function findPendingToolCallByName( - round: GatewayTranscriptRound, - name: string, -) { - const trace = getRoundToolTrace(round); - for (let index = trace.length - 1; index >= 0; index -= 1) { - const item = trace[index]; - if (!item) continue; - if (item.toolCall.name === name && !item.toolResult) { - return item.toolCall; - } - } - return undefined; -} - -function resolveToolCallForResult( - builder: AssistantGroupBuilder, - roundNumber: number, - toolResult: ToolResultMessage, -) { - const requestedRound = ensureTranscriptRound(builder, roundNumber); - const byId = - toolResult.toolCallId && findToolCallInRound(requestedRound, toolResult.toolCallId); - if (byId) { - return byId; - } - - const byName = - toolResult.toolName && findPendingToolCallByName(requestedRound, toolResult.toolName); - if (byName) { - return byName; - } - - for (let index = builder.rounds.length - 1; index >= 0; index -= 1) { - const round = builder.rounds[index]; - if (!round) continue; - const candidateById = - toolResult.toolCallId && findToolCallInRound(round, toolResult.toolCallId); - if (candidateById) { - return candidateById; - } - const candidateByName = - toolResult.toolName && findPendingToolCallByName(round, toolResult.toolName); - if (candidateByName) { - return candidateByName; - } - } - - return { - type: "toolCall", - id: toolResult.toolCallId || randomId("tool-call"), - name: toolResult.toolName || "Tool", - arguments: {}, - } as ToolCall; -} - -function asDelegateAgentResultDetails( - details: unknown, -): DelegateAgentResultDetails | null { - const record = asRecord(details); - return record.kind === "delegate_agent" && Array.isArray(record.agents) - ? (record as DelegateAgentResultDetails) - : null; -} - -function readDelegateAgentPrompt(agent: DelegateAgentItemResultDetails) { - return agent.prompt || agent.description || ""; -} - -function buildDelegateAgentCardToolCall(params: { - parentToolResult: ToolResultMessage; - details: DelegateAgentResultDetails; - index: number; - agent: DelegateAgentItemResultDetails; -}): ToolCall { - const parentToolCallId = params.parentToolResult.toolCallId || "agent"; - return { - type: "toolCall", - id: `${parentToolCallId}:agent:${params.index + 1}`, - name: "Agent", - arguments: { - delegate_agent_card: true, - parent_tool_call_id: parentToolCallId, - index: params.index + 1, - total: params.details.agentCount, - concurrency: params.details.concurrency, - id: params.agent.id, - name: params.agent.name, - role: params.agent.role, - agent_id: params.agent.agentId, - prompt: readDelegateAgentPrompt(params.agent), - mode: params.agent.mode, - }, - } as ToolCall; -} - -function buildDelegateAgentCardToolResult(params: { - parentToolResult: ToolResultMessage; - toolCall: ToolCall; - details: DelegateAgentResultDetails; - index: number; - agent: DelegateAgentItemResultDetails; -}): ToolResultMessage { - const details: DelegateAgentCardResultDetails = { - kind: "delegate_agent_item", - parentToolCallId: params.parentToolResult.toolCallId, - index: params.index, - total: params.details.agentCount, - concurrency: params.details.concurrency, - agent: params.agent, - }; - return { - role: "toolResult", - toolCallId: params.toolCall.id, - toolName: params.toolCall.name, - content: [ - { - type: "text", - text: - params.agent.error || - params.agent.applyError || - params.agent.summary || - readDelegateAgentPrompt(params.agent) || - "", - }, - ], - details, - isError: params.agent.status === "failed", - timestamp: params.parentToolResult.timestamp, - } as ToolResultMessage; -} - -function appendDelegateAgentCardsToRound( - round: GatewayTranscriptRound, - parentToolResult: ToolResultMessage, - details: DelegateAgentResultDetails, -): GatewayTranscriptRound { - let nextRound = round; - details.agents.forEach((agent, index) => { - const toolCall = buildDelegateAgentCardToolCall({ - parentToolResult, - details, - index, - agent, - }); - const toolResult = buildDelegateAgentCardToolResult({ - parentToolResult, - toolCall, - details, - index, - agent, - }); - nextRound = attachToolResultToRound( - nextRound, - toolCall, - toolResult, - ) as GatewayTranscriptRound; - }); - - const completedIds = new Set([ - parentToolResult.toolCallId, - ...details.agents.map((_, index) => `${parentToolResult.toolCallId}:agent:${index + 1}`), - ]); - - return { - ...nextRound, - runningToolCallIds: nextRound.runningToolCallIds.filter((id) => !completedIds.has(id)), - }; -} - -export function buildTranscriptItems(entries: ChatEntry[]): GatewayTranscriptItem[] { - const items: GatewayTranscriptItem[] = []; - let assistantGroup: AssistantGroupBuilder | null = null; - let userOrdinal = 0; - - const flushAssistantGroup = () => { - if (!assistantGroup || assistantGroup.rounds.length === 0) { - assistantGroup = null; - return; - } - items.push({ - id: assistantGroup.id, - kind: "assistant", - rounds: assistantGroup.rounds, - }); - assistantGroup = null; - }; - - for (const entry of entries) { - if (entry.kind === "user" || entry.kind === "checkpoint" || entry.kind === "error") { - flushAssistantGroup(); - if (entry.kind === "user") { - items.push({ - ...entry, - userOrdinal, - }); - userOrdinal += 1; - } else { - items.push(entry); - } - continue; - } - - assistantGroup = ensureAssistantGroup(assistantGroup, entry.id); - const roundNumber = entry.round ?? assistantGroup.rounds[assistantGroup.rounds.length - 1]?.round ?? 1; - - if (entry.kind === "assistant") { - updateTranscriptRound(assistantGroup, roundNumber, (round) => { - let nextRound = round; - if (entry.text !== "") { - nextRound = appendTextDeltaToRound(collapseThinking(nextRound), entry.text) as GatewayTranscriptRound; - } - return { - ...nextRound, - meta: mergeAssistantMeta(nextRound.meta, entry.meta), - }; - }); - continue; - } - - if (entry.kind === "thinking") { - const sanitizedThinking = stripRecoveredToolCallMarkup(entry.text); - if (sanitizedThinking === "") { - continue; - } - updateTranscriptRound(assistantGroup, roundNumber, (round) => ({ - ...(appendThinkingDeltaToRound(round, sanitizedThinking) as GatewayTranscriptRound), - thinkingOpen: true, - })); - continue; - } - - if (entry.kind === "tool_call") { - updateTranscriptRound(assistantGroup, roundNumber, (round) => { - const visibleToolCalls = buildDelegateAgentPlaceholderToolCalls(entry.toolCall); - const runningCandidateIds = - visibleToolCalls.length > 0 - ? visibleToolCalls.map((toolCall) => toolCall.id) - : entry.toolCall.id - ? [entry.toolCall.id] - : []; - const withToolCall = upsertToolCallToRound( - collapseThinking(round), - entry.toolCall, - ) as GatewayTranscriptRound; - const visibleToolCallIds = new Set( - getRoundToolTrace(withToolCall) - .map((item) => item.toolCall.id) - .filter((id): id is string => Boolean(id)), - ); - const runningToolCallIds = runningCandidateIds.reduce( - (ids, id) => - visibleToolCallIds.has(id) && !ids.includes(id) ? [...ids, id] : ids, - withToolCall.runningToolCallIds, - ); - return { - ...withToolCall, - runningToolCallIds, - }; - }); - continue; - } - - if (entry.kind === "hosted_search") { - updateTranscriptRound(assistantGroup, roundNumber, (round) => { - const nextRound = upsertHostedSearchToRound( - collapseThinking(round), - entry.hostedSearch, - ) as GatewayTranscriptRound; - const visibleToolCallIds = new Set( - getRoundToolTrace(nextRound) - .map((item) => item.toolCall.id) - .filter((id): id is string => Boolean(id)), - ); - return { - ...nextRound, - runningToolCallIds: nextRound.runningToolCallIds.filter((id) => - visibleToolCallIds.has(id), - ), - }; - }); - continue; - } - - if (entry.kind === "tool_result") { - const delegateDetails = asDelegateAgentResultDetails(entry.toolResult.details); - if (delegateDetails) { - updateTranscriptRound(assistantGroup, roundNumber, (round) => - appendDelegateAgentCardsToRound( - collapseThinking(round), - entry.toolResult, - delegateDetails, - ), - ); - continue; - } - - const toolCall = resolveToolCallForResult( - assistantGroup, - roundNumber, - entry.toolResult, - ); - updateTranscriptRound(assistantGroup, roundNumber, (round) => { - const withResult = attachToolResultToRound( - collapseThinking(round), - toolCall, - entry.toolResult, - ) as GatewayTranscriptRound; - return { - ...withResult, - runningToolCallIds: withResult.runningToolCallIds.filter( - (id) => id !== toolCall.id, - ), - }; - }); - } - } - - flushAssistantGroup(); - return items; -} - export function formatConversationTitle( conversation?: Pick | null, fallbackId?: string, diff --git a/crates/agent-gateway/web/src/pages/SharedHistoryPage.tsx b/crates/agent-gateway/web/src/pages/SharedHistoryPage.tsx index 403db7e9..d824eb34 100644 --- a/crates/agent-gateway/web/src/pages/SharedHistoryPage.tsx +++ b/crates/agent-gateway/web/src/pages/SharedHistoryPage.tsx @@ -3,6 +3,7 @@ import { AlertCircle, Loader2, MessageSquareText } from "../components/icons"; import { ScrollArea } from "../components/ui/scroll-area"; import { GatewayTranscript } from "../components/GatewayTranscript"; +import { buildRowsFromEntries, dedupeRowKeys } from "../lib/chat/transcript/rows"; import type { ChatEntry } from "../lib/chatUi"; import type { SharedHistoryDetail } from "../lib/gatewayTypes"; import { fetchSharedHistory, formatSharedHistoryTimestamp } from "../lib/historyShare"; @@ -50,6 +51,13 @@ export function SharedHistoryPage({ token }: SharedHistoryPageProps) { () => formatSharedHistoryTimestamp(summary?.updated_at), [summary?.updated_at], ); + const transcriptRows = useMemo( + () => + state.status === "ready" + ? dedupeRowKeys(buildRowsFromEntries(state.entries, "history")) + : [], + [state], + ); return (
@@ -107,7 +115,7 @@ export function SharedHistoryPage({ token }: SharedHistoryPageProps) { { }); }); -test("buildTranscriptItems assigns stable user ordinals", () => { - const items = buildTranscriptItems([ - { - id: "user-1", - kind: "user", - text: "first", - attachments: [], - }, - { - id: "assistant-1", - kind: "assistant", - text: "reply", - round: 1, - }, - { - id: "user-2", - kind: "user", - text: "second", - attachments: [], - }, - { - id: "checkpoint-1", - kind: "checkpoint", - content: "summary", - summaryId: "summary-1", - coveredMessageCount: 2, - generatedBy: { - providerId: "codex", - model: "test-model", +test("buildRowsFromEntries emits user rows keyed by entry id", () => { + const rows = buildRowsFromEntries( + [ + { + id: "user-1", + kind: "user", + text: "first", + attachments: [], }, - }, - { - id: "user-3", - kind: "user", - text: "third", - attachments: [], - }, - ]); + { + id: "assistant-1", + kind: "assistant", + text: "reply", + round: 1, + }, + { + id: "user-2", + kind: "user", + text: "second", + attachments: [], + }, + { + id: "checkpoint-1", + kind: "checkpoint", + content: "summary", + summaryId: "summary-1", + coveredMessageCount: 2, + generatedBy: { + providerId: "codex", + model: "test-model", + }, + }, + { + id: "user-3", + kind: "user", + text: "third", + attachments: [], + }, + ], + "history", + ); assert.deepEqual( - items.filter((item) => item.kind === "user").map((item) => item.userOrdinal), - [0, 1, 2], + rows.map((row) => row.kind), + ["user", "assistant", "user", "checkpoint", "user"], + ); + assert.deepEqual( + rows.filter((row) => row.kind === "user").map((row) => row.key), + ["user-1", "user-2", "user-3"], ); }); -test("pushChatEvent ignores empty start tokens without creating a blank assistant", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn ignores empty start tokens without creating a blank assistant", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "", round: 0, conversation_id: "conversation-1", }); - assert.deepEqual(entries, []); + assert.deepEqual(turn.entries, []); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "answer", round: 1, conversation_id: "conversation-1", }); - assert.equal(entries.length, 1); - assert.equal(entries[0].kind, "assistant"); - assert.equal(entries[0].text, "answer"); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].kind, "assistant"); + assert.equal(turn.entries[0].text, "answer"); }); -test("pushChatEvent and buildTranscriptItems preserve hosted search events", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn and buildRowsFromEntries preserve hosted search events", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-1", provider: "gemini", @@ -236,7 +250,7 @@ test("pushChatEvent and buildTranscriptItems preserve hosted search events", () sources: [], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-1", provider: "gemini", @@ -245,18 +259,19 @@ test("pushChatEvent and buildTranscriptItems preserve hosted search events", () sources: [{ url: "https://example.com/docs", title: "Docs" }], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "done", round: 1, }); + const entries = turn.entries; assert.equal(entries.length, 2); assert.equal(entries[0].kind, "hosted_search"); assert.equal(entries[0].hostedSearch.status, "completed"); assert.equal(entries[0].hostedSearch.sources[0].url, "https://example.com/docs"); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); assert.deepEqual( @@ -265,14 +280,14 @@ test("pushChatEvent and buildTranscriptItems preserve hosted search events", () ); }); -test("buildTranscriptItems keeps delayed hosted search after streamed text", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("buildRowsFromEntries keeps delayed hosted search after streamed text", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "answer before metadata", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-delayed", provider: "codex", @@ -282,7 +297,7 @@ test("buildTranscriptItems keeps delayed hosted search after streamed text", () round: 1, }); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); assert.deepEqual( @@ -292,14 +307,14 @@ test("buildTranscriptItems keeps delayed hosted search after streamed text", () assert.equal(items[0].rounds[0].blocks[0].text, "answer before metadata"); }); -test("buildTranscriptItems anchors delayed hosted search inside the streamed text", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("buildRowsFromEntries anchors delayed hosted search inside the streamed text", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "任务1完成。现在按顺序进行联网检索设计模式定义。任务2完成:设计模式是可复用方案。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-pattern", provider: "codex", @@ -309,7 +324,7 @@ test("buildTranscriptItems anchors delayed hosted search inside the streamed tex round: 1, }); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); const blocks = items[0].rounds[0].blocks; @@ -321,14 +336,14 @@ test("buildTranscriptItems anchors delayed hosted search inside the streamed tex assert.equal(blocks[2].text, "任务2完成:设计模式是可复用方案。"); }); -test("pushChatEvent keeps streamed text after hosted search in event order", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn keeps streamed text after hosted search in event order", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "任务1完成。现在开始联网搜索。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-live-order", provider: "codex", @@ -337,12 +352,12 @@ test("pushChatEvent keeps streamed text after hosted search in event order", () sources: [], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "任务2继续输出,应该出现在搜索卡片之后。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-live-order", provider: "codex", @@ -352,6 +367,7 @@ test("pushChatEvent keeps streamed text after hosted search in event order", () round: 1, }); + const entries = turn.entries; assert.deepEqual( entries.map((entry) => entry.kind), ["assistant", "hosted_search", "assistant"], @@ -359,7 +375,7 @@ test("pushChatEvent keeps streamed text after hosted search in event order", () assert.equal(entries[1].hostedSearch.status, "completed"); assert.equal(entries[1].hostedSearch.sources[0].url, "https://example.com/live-order"); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); const blocks = items[0].rounds[0].blocks; @@ -371,14 +387,14 @@ test("pushChatEvent keeps streamed text after hosted search in event order", () assert.equal(blocks[2].text, "任务2继续输出,应该出现在搜索卡片之后。"); }); -test("buildTranscriptItems groups live hosted searches separated by streamed text", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("buildRowsFromEntries groups live hosted searches separated by streamed text", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "先查第一组资料。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-a", provider: "codex", @@ -387,12 +403,12 @@ test("buildTranscriptItems groups live hosted searches separated by streamed tex sources: [{ url: "https://example.com/a", title: "A" }], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "继续说明中间过程。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-b", provider: "codex", @@ -402,7 +418,7 @@ test("buildTranscriptItems groups live hosted searches separated by streamed tex round: 1, }); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); const blocks = items[0].rounds[0].blocks; @@ -418,14 +434,14 @@ test("buildTranscriptItems groups live hosted searches separated by streamed tex ); }); -test("pushChatEvent does not split a sentence when hosted search arrives mid sentence", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn does not split a sentence when hosted search arrives mid sentence", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "现在反过来,我先看“谁", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-sentence", provider: "codex", @@ -434,13 +450,13 @@ test("pushChatEvent does not split a sentence when hosted search arrives mid sen sources: [], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "为什么会掏钱”。然后再看市场。", round: 1, }); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); const blocks = items[0].rounds[0].blocks; @@ -542,9 +558,9 @@ test("web UI hydrates persisted hosted search sources from answer links", () => ]); }); -test("pushChatEvent hydrates live hosted search sources from streamed answer links", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn hydrates live hosted search sources from streamed answer links", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "hosted_search", id: "search-live-empty", provider: "codex", @@ -553,14 +569,14 @@ test("pushChatEvent hydrates live hosted search sources from streamed answer lin sources: [], round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "参考:Dell 官方 iDRAC 页面:https://www.dell.com/en-us/lp/dt/open-manage-idrac", round: 1, }); - assert.equal(entries[0].kind, "hosted_search"); - assert.deepEqual(entries[0].hostedSearch.sources, [ + assert.equal(turn.entries[0].kind, "hosted_search"); + assert.deepEqual(turn.entries[0].hostedSearch.sources, [ { url: "https://www.dell.com/en-us/lp/dt/open-manage-idrac", title: "参考:Dell 官方 iDRAC 页面", @@ -651,32 +667,32 @@ test("hosted search finalization does not split a sentence at the stream event o assert.equal(assistant.content[2].text, "然后再分析产品。"); }); -test("pushChatEvent keeps streamed text after tool calls in event order", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn keeps streamed text after tool calls in event order", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "token", text: "先说明工具调用前的内容。", round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_call", id: "call-1", name: "Read", arguments: { path: "README.md" }, round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "token", text: "工具调用后的正文应该留在工具卡之后。", round: 1, }); assert.deepEqual( - entries.map((entry) => entry.kind), + turn.entries.map((entry) => entry.kind), ["assistant", "tool_call", "assistant"], ); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); const blocks = items[0].rounds[0].blocks; @@ -688,16 +704,16 @@ test("pushChatEvent keeps streamed text after tool calls in event order", () => assert.equal(blocks[2].text, "工具调用后的正文应该留在工具卡之后。"); }); -test("pushChatEvent merges streamed Write deltas with final call and result", () => { - let entries = []; - entries = pushChatEvent(entries, { +test("applyEventToTurn merges streamed Write deltas with final call and result", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "tool_call_delta", id: "call-write", name: "Write", arguments: { path: "src/app.ts", content: "con" }, round: 1, }); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_call_delta", id: "call-write", name: "Write", @@ -705,11 +721,11 @@ test("pushChatEvent merges streamed Write deltas with final call and result", () round: 1, }); - assert.equal(entries.length, 1); - assert.equal(entries[0].kind, "tool_call"); - assert.equal(entries[0].toolCall.arguments.content, "console.log(1);\n"); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].kind, "tool_call"); + assert.equal(turn.entries[0].toolCall.arguments.content, "console.log(1);\n"); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_call", id: "call-write", name: "Write", @@ -717,10 +733,10 @@ test("pushChatEvent merges streamed Write deltas with final call and result", () round: 1, }); - assert.equal(entries.length, 1); - assert.equal(entries[0].toolCall.arguments.content, "console.log(1);\n"); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].toolCall.arguments.content, "console.log(1);\n"); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_result", id: "call-write", name: "Write", @@ -731,13 +747,13 @@ test("pushChatEvent merges streamed Write deltas with final call and result", () round: 1, }); - assert.equal(entries.length, 2); + assert.equal(turn.entries.length, 2); assert.deepEqual( - entries.map((entry) => entry.kind), + turn.entries.map((entry) => entry.kind), ["tool_call", "tool_result"], ); - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(turn.entries, "stream"); assert.equal(items.length, 1); const toolBlocks = items[0].rounds[0].blocks.filter((block) => block.kind === "tool"); assert.equal(toolBlocks.length, 1); @@ -747,7 +763,7 @@ test("pushChatEvent merges streamed Write deltas with final call and result", () assert.deepEqual(items[0].rounds[0].runningToolCallIds, []); }); -test("pushChatEvent preserves streaming preview metadata for Write metrics", () => { +test("applyEventToTurn preserves streaming preview metadata for Write metrics", () => { const metadataKey = uiMessages.LIVE_TOOL_PREVIEW_META_KEY; const previewContent = "head\n...[truncated 9000 chars]...\ntail"; const previewArgs = { @@ -767,8 +783,8 @@ test("pushChatEvent preserves streaming preview metadata for Write metrics", () }, }; - let entries = []; - entries = pushChatEvent(entries, { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "tool_call_delta", id: "call-large-write", name: "Write", @@ -776,14 +792,14 @@ test("pushChatEvent preserves streaming preview metadata for Write metrics", () round: 1, }); - assert.equal(entries.length, 1); - assert.equal(entries[0].toolCall.arguments.content, previewContent); - const deltaPreview = uiMessages.getStreamingWriteToolPreview(entries[0].toolCall); + assert.equal(turn.entries.length, 1); + assert.equal(turn.entries[0].toolCall.arguments.content, previewContent); + const deltaPreview = uiMessages.getStreamingWriteToolPreview(turn.entries[0].toolCall); assert.equal(deltaPreview.content.chars, 12000); assert.equal(deltaPreview.content.lines, 800); assert.equal(deltaPreview.content.truncated, true); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_call", id: "call-large-write", name: "Write", @@ -791,17 +807,17 @@ test("pushChatEvent preserves streaming preview metadata for Write metrics", () round: 1, }); - assert.equal(entries.length, 1); - const finalPreview = uiMessages.getStreamingWriteToolPreview(entries[0].toolCall); + assert.equal(turn.entries.length, 1); + const finalPreview = uiMessages.getStreamingWriteToolPreview(turn.entries[0].toolCall); assert.equal(finalPreview.content.text, previewContent); assert.equal(finalPreview.content.chars, 12000); assert.equal(finalPreview.content.lines, 800); }); -test("pushChatEvent snapshots mutable streamed Write arguments", () => { +test("applyEventToTurn snapshots mutable streamed Write arguments", () => { const args = { path: "src/app.ts", content: "first" }; - let entries = []; - entries = pushChatEvent(entries, { + let turn = newTurn(); + turn = applyEventToTurn(turn, { type: "tool_call_delta", id: "call-write-mutable", name: "Write", @@ -810,19 +826,19 @@ test("pushChatEvent snapshots mutable streamed Write arguments", () => { }); args.content = "first\nsecond"; - assert.equal(entries[0].toolCall.arguments.content, "first"); + assert.equal(turn.entries[0].toolCall.arguments.content, "first"); - entries = pushChatEvent(entries, { + turn = applyEventToTurn(turn, { type: "tool_call_delta", id: "call-write-mutable", name: "Write", arguments: args, round: 1, }); - assert.equal(entries[0].toolCall.arguments.content, "first\nsecond"); + assert.equal(turn.entries[0].toolCall.arguments.content, "first\nsecond"); }); -test("buildTranscriptItems expands parent Agent aggregate results into Agent cards", () => { +test("buildRowsFromEntries expands parent Agent aggregate results into Agent cards", () => { const entries = [ { id: "assistant-tool-call", @@ -871,7 +887,7 @@ test("buildTranscriptItems expands parent Agent aggregate results into Agent car }, ]; - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(entries, "history"); assert.equal(items.length, 1); assert.equal(items[0].kind, "assistant"); assert.equal(items[0].rounds.length, 1); @@ -932,7 +948,7 @@ test("buildDelegateAgentPlaceholderToolCalls parses agent_spec into stable Agent ); }); -test("buildTranscriptItems shows Agent placeholders while aggregate result is pending", () => { +test("buildRowsFromEntries shows Agent placeholders while aggregate result is pending", () => { const parentToolCallEntry = { id: "assistant-tool-call", kind: "tool_call", @@ -958,7 +974,7 @@ test("buildTranscriptItems shows Agent placeholders while aggregate result is pe text: "{}", }; - const pendingItems = buildTranscriptItems([parentToolCallEntry]); + const pendingItems = buildRowsFromEntries([parentToolCallEntry], "history"); const pendingBlocks = pendingItems[0].rounds[0].blocks.filter( (block) => block.kind === "tool", ); @@ -976,36 +992,39 @@ test("buildTranscriptItems shows Agent placeholders while aggregate result is pe "call-agent:agent:2", ]); - const completedItems = buildTranscriptItems([ - parentToolCallEntry, - { - id: "agent-aggregate-result", - kind: "tool_result", - round: 1, - toolResult: { - role: "toolResult", - toolCallId: "call-agent", - toolName: "Agent", - content: [{ type: "text", text: "aggregate" }], - details: { - kind: "delegate_agent", - agentCount: 2, - concurrency: 2, - totalDurationMs: 2400, - readOnly: true, - mode: "readonly", - agents: [ - createDelegateAgent("player1", "第一轮请给出观点", "一号完成"), - createDelegateAgent("player2", "第二轮请反驳", "二号完成"), - ], + const completedItems = buildRowsFromEntries( + [ + parentToolCallEntry, + { + id: "agent-aggregate-result", + kind: "tool_result", + round: 1, + toolResult: { + role: "toolResult", + toolCallId: "call-agent", + toolName: "Agent", + content: [{ type: "text", text: "aggregate" }], + details: { + kind: "delegate_agent", + agentCount: 2, + concurrency: 2, + totalDurationMs: 2400, + readOnly: true, + mode: "readonly", + agents: [ + createDelegateAgent("player1", "第一轮请给出观点", "一号完成"), + createDelegateAgent("player2", "第二轮请反驳", "二号完成"), + ], + }, + isError: false, + timestamp: 123, }, - isError: false, - timestamp: 123, + summary: "Agent result", + text: "aggregate", }, - summary: "Agent result", - text: "aggregate", - }, - ]); + ], + "history", + ); const completedBlocks = completedItems[0].rounds[0].blocks.filter( (block) => block.kind === "tool", ); @@ -1020,7 +1039,7 @@ test("buildTranscriptItems shows Agent placeholders while aggregate result is pe assert.deepEqual(completedItems[0].rounds[0].runningToolCallIds, []); }); -test("buildTranscriptItems uses the stable Agent name supplied by item results", () => { +test("buildRowsFromEntries uses the stable Agent name supplied by item results", () => { const firstAgent = { ...createDelegateAgent("agent-1", "哲学视角探讨生命的意义", "first"), name: "哲学家 - 苏格拉底", @@ -1081,7 +1100,7 @@ test("buildTranscriptItems uses the stable Agent name supplied by item results", }, ]; - const items = buildTranscriptItems(entries); + const items = buildRowsFromEntries(entries, "history"); const firstTool = items[0].rounds[0].blocks.find((block) => block.kind === "tool"); const secondTool = items[0].rounds[1].blocks.find((block) => block.kind === "tool"); assert.equal(firstTool.item.toolResult.details.agent.name, "哲学家 - 苏格拉底"); diff --git a/crates/agent-gateway/web/test/transcript-rows.test.mjs b/crates/agent-gateway/web/test/transcript-rows.test.mjs new file mode 100644 index 00000000..ba678710 --- /dev/null +++ b/crates/agent-gateway/web/test/transcript-rows.test.mjs @@ -0,0 +1,383 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); + +const { buildRowsFromEntries, buildTurnRows, dedupeRowKeys } = loader.loadModule( + "src/lib/chat/transcript/rows.ts", +); +const { createTurn, applyEventToTurn } = loader.loadModule( + "src/lib/chat/transcript/turnReducer.ts", +); +const { alignHistory, groupHistoryEntriesIntoTurns } = loader.loadModule( + "src/lib/chat/transcript/historyAlignment.ts", +); +const { parseHistoryMessagesJson } = loader.loadModule("src/lib/chatUi.ts"); + +function rowText(row) { + if (row.kind === "assistant") { + return row.rounds + .map((round) => + round.blocks.flatMap((block) => (block.kind === "text" ? [block.text] : [])).join(""), + ) + .join("\n"); + } + return row.text ?? ""; +} + +function ref(messageId, messageIndex = 0) { + return { + segmentIndex: 0, + messageIndex, + segmentId: "segment-1", + messageId, + role: "user", + contentHash: `hash-${messageId}`, + }; +} + +// --------------------------------------------------------------------------- +// Row builder + +test("meta-only assistant entries never emit an avatar row", () => { + const rows = buildRowsFromEntries( + [{ id: "a-1", kind: "assistant", text: "", round: 1, meta: { provider: "deepseek" } }], + "history", + ); + assert.equal(rows.length, 0, "a content-less round renders nothing"); +}); + +test("a meta carrier merges into the round that has content", () => { + const rows = buildRowsFromEntries( + [ + { id: "a-meta", kind: "assistant", text: "", round: 1, meta: { provider: "deepseek" } }, + { id: "th-1", kind: "thinking", text: "reasoning", round: 1 }, + { id: "a-1", kind: "assistant", text: "answer", round: 1 }, + ], + "history", + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "assistant"); + assert.equal(rows[0].rounds.length, 1); + assert.equal(rows[0].rounds[0].meta?.provider, "deepseek", "meta survives on the visible round"); +}); + +test("thinking-only rounds count as content", () => { + const rows = buildRowsFromEntries( + [{ id: "th-1", kind: "thinking", text: "chain of thought", round: 1 }], + "stream", + ); + assert.equal(rows.length, 1); + assert.equal(rows[0].kind, "assistant"); +}); + +test("checkpoint and error entries flush the assistant group", () => { + const rows = buildRowsFromEntries( + [ + { id: "a-1", kind: "assistant", text: "before", round: 1 }, + { + id: "checkpoint-s1", + kind: "checkpoint", + content: "summary", + summaryId: "s1", + coveredMessageCount: 3, + generatedBy: { providerId: "liveagent", model: "summary" }, + }, + { id: "a-2", kind: "assistant", text: "after", round: 1 }, + { id: "err-1", kind: "error", text: "boom" }, + ], + "history", + ); + assert.deepEqual( + rows.map((row) => row.kind), + ["assistant", "checkpoint", "assistant", "error"], + ); + assert.equal(rowText(rows[0]), "before"); + assert.equal(rowText(rows[2]), "after"); +}); + +test("buildTurnRows emits the user bubble before any assistant content, tagged with the turn key", () => { + let turn = createTurn({ key: "req:c1", runId: "run-1" }); + turn = { ...turn, user: { id: "ou:c1", kind: "user", text: "prompt", attachments: [] } }; + turn = applyEventToTurn(turn, { type: "token", text: "reply", round: 1 }); + const rows = buildTurnRows(turn); + assert.deepEqual( + rows.map((row) => row.kind), + ["user", "assistant"], + ); + assert.equal(rows[0].key, "ou:c1"); + assert.equal(rows[0].origin, "stream"); + assert.equal(rows[1].turnKey, "req:c1"); +}); + +test("dedupeRowKeys suffixes collisions deterministically without touching unique keys", () => { + const rows = [ + { key: "a", origin: "history", kind: "error", text: "1" }, + { key: "a", origin: "history", kind: "error", text: "2" }, + { key: "b", origin: "history", kind: "error", text: "3" }, + ]; + const deduped = dedupeRowKeys(rows); + assert.deepEqual( + deduped.map((row) => row.key), + ["a", "a#2", "b"], + ); + const untouched = [ + { key: "a", origin: "history", kind: "error", text: "1" }, + { key: "b", origin: "history", kind: "error", text: "2" }, + ]; + assert.equal(dedupeRowKeys(untouched), untouched, "no copy when keys are already unique"); +}); + +// --------------------------------------------------------------------------- +// Deterministic history parse ids + +test("parseHistoryMessagesJson yields identical ids across reparses", () => { + const raw = JSON.stringify([ + { + role: "user", + id: "m1", + content: "问题", + liveAgentHistoryRef: { + segmentIndex: 0, + messageIndex: 0, + segmentId: "seg-1", + messageId: "m1", + role: "user", + contentHash: "h1", + }, + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "思考" }, + { type: "text", text: "回答" }, + ], + provider: "deepseek", + }, + { role: "user", content: "继续" }, + { role: "user", content: "继续" }, + ]); + const first = parseHistoryMessagesJson(raw); + const second = parseHistoryMessagesJson(raw); + assert.deepEqual( + first.map((entry) => entry.id), + second.map((entry) => entry.id), + "reparse is id-stable", + ); + assert.equal(first[0].id, "hu:m1", "ref-anchored user id"); + assert.ok(first[1].id.startsWith("ht:hu:m1>"), "turn-anchored block id"); + const dupIds = first.filter((entry) => entry.kind === "user" && entry.text === "继续"); + assert.equal(new Set(dupIds.map((entry) => entry.id)).size, 2, "identical prompts get distinct ids"); +}); + +test("thinking-first persisted replies emit a meta carrier that rows suppress", () => { + const raw = JSON.stringify([ + { role: "user", id: "m1", content: "查询" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "推理" }, + { type: "text", text: "结论" }, + ], + provider: "deepseek", + model: "deepseek-v4", + }, + ]); + const entries = parseHistoryMessagesJson(raw); + const metaCarrier = entries.find((entry) => entry.kind === "assistant" && entry.text === ""); + assert.ok(metaCarrier, "parser keeps the meta carrier entry"); + const rows = buildRowsFromEntries(entries, "history"); + assert.deepEqual( + rows.map((row) => row.kind), + ["user", "assistant"], + "no avatar-only row from the meta carrier", + ); +}); + +// --------------------------------------------------------------------------- +// History alignment + +function settledTurn(key, runId, userText, replyText, userRef) { + let turn = createTurn({ key, runId, phase: "settled" }); + turn = { + ...turn, + user: { + id: `ou:${key}`, + kind: "user", + text: userText, + attachments: [], + ...(userRef ? { messageRef: userRef } : {}), + }, + }; + if (replyText !== null) { + turn = applyEventToTurn(turn, { type: "token", text: replyText, round: 1 }); + } + return { ...turn, phase: "settled" }; +} + +test("replace keeps pending/streaming turns and trims their persisted echoes", () => { + const streaming = { ...settledTurn("req:c9", "run-9", "active prompt", null), phase: "streaming" }; + const result = alignHistory({ + historyEntries: [], + turns: [streaming], + entries: [ + { id: "hu:m1", kind: "user", text: "old", attachments: [], messageRef: ref("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + { id: "hu:m9", kind: "user", text: "active prompt", attachments: [], messageRef: ref("m9", 2) }, + ], + mode: "replace", + }); + assert.equal(result.turns.length, 1, "streaming turn kept"); + assert.equal(result.turns[0].user.messageRef?.messageId, "m9", "echo's ref adopted"); + assert.deepEqual( + result.historyEntries.map((entry) => entry.id), + ["hu:m1", "ht:hu:m1>0"], + "persisted echo of the active prompt trimmed", + ); +}); + +test("replace drops settled turns in favor of the parsed history", () => { + const settled = settledTurn("req:c1", "run-1", "prompt", "reply"); + const result = alignHistory({ + historyEntries: [], + turns: [settled], + entries: [ + { id: "hu:m1", kind: "user", text: "prompt", attachments: [], messageRef: ref("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "reply", round: 1 }, + ], + mode: "replace", + }); + assert.equal(result.turns.length, 0); + assert.equal(result.historyEntries.length, 2); +}); + +test("enrich pairs the trailing turns and upgrades tool payloads by id", () => { + let turn = settledTurn("req:c1", "run-1", "prompt", null, undefined); + turn = applyEventToTurn(turn, { + type: "tool_call", + id: "call-1", + name: "Read", + arguments: {}, + round: 1, + }); + turn = { ...turn, phase: "settled" }; + const entryId = turn.entries[0].id; + + const result = alignHistory({ + historyEntries: [], + turns: [turn], + entries: [ + { id: "hu:m1", kind: "user", text: "prompt", attachments: [], messageRef: ref("m1") }, + { + id: "ht:hu:m1>0", + kind: "tool_call", + round: 1, + toolCall: { type: "toolCall", id: "call-1", name: "Read", arguments: { file: "a.ts" } }, + summary: "Read a.ts", + text: '{"file":"a.ts"}', + }, + ], + mode: "enrich", + }); + assert.equal(result.changed, true); + const enriched = result.turns[0]; + assert.equal(enriched.user.messageRef?.messageId, "m1"); + assert.equal(enriched.entries[0].id, entryId, "tool entry keeps its rendered id"); + assert.deepEqual(enriched.entries[0].toolCall.arguments, { file: "a.ts" }, "full args adopted"); +}); + +test("enrich with a partial suffix window leaves the history region untouched", () => { + const region = [ + { id: "hu:m1", kind: "user", text: "old", attachments: [], messageRef: ref("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + ]; + const turn = settledTurn("req:c2", "run-2", "new", "new reply"); + const result = alignHistory({ + historyEntries: region, + turns: [turn], + entries: [ + { id: "hu:m2", kind: "user", text: "new", attachments: [], messageRef: ref("m2", 2) }, + { id: "ht:hu:m2>0", kind: "assistant", text: "new reply", round: 1 }, + ], + mode: "enrich", + }); + assert.equal(result.historyEntries, region, "suffix window cannot truncate the region"); + assert.equal(result.turns[0].user.messageRef?.messageId, "m2", "pairing still enriches"); +}); + +test("enrich replaces wholesale when history is ahead of the store", () => { + const turn = settledTurn("req:c1", "run-1", "known", "known reply"); + const entries = [ + { id: "hu:m1", kind: "user", text: "known", attachments: [], messageRef: ref("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "known reply", round: 1 }, + { id: "hu:m2", kind: "user", text: "foreign", attachments: [], messageRef: ref("m2", 2) }, + { id: "ht:hu:m2>0", kind: "assistant", text: "foreign reply", round: 1 }, + ]; + const result = alignHistory({ historyEntries: [], turns: [turn], entries, mode: "enrich" }); + assert.deepEqual(result.turns, [], "stale turns dropped"); + assert.equal(result.historyEntries, entries, "history becomes authoritative"); +}); + +test("enrich repaints on a messageRef conflict but never loses unpersisted exchanges", () => { + const conflicted = settledTurn("req:c1", "run-1", "prompt", "reply", ref("expected")); + const covered = settledTurn("req:c0", "run-0", "known", "known reply", ref("other")); + const entries = [ + { id: "hu:other", kind: "user", text: "known", attachments: [], messageRef: ref("other") }, + { id: "ht:hu:other>0", kind: "assistant", text: "known reply", round: 1 }, + { id: "hu:foreign", kind: "user", text: "foreign", attachments: [], messageRef: ref("foreign") }, + { id: "ht:hu:foreign>0", kind: "assistant", text: "foreign reply", round: 1 }, + ]; + const result = alignHistory({ + historyEntries: [], + turns: [covered, conflicted], + entries, + mode: "enrich", + }); + assert.equal(result.changed, true); + assert.equal(result.historyEntries, entries, "history becomes authoritative"); + assert.deepEqual( + result.turns.map((turn) => turn.key), + ["req:c1"], + "the turn history covers is dropped; the one it cannot know survives", + ); +}); + +test("enrich never replaces streamed text with the persisted shape", () => { + const turn = settledTurn("req:c1", "run-1", "as typed", "streamed reply"); + const result = alignHistory({ + historyEntries: [], + turns: [turn], + entries: [ + { + id: "hu:m1", + kind: "user", + text: "as persisted (expanded mentions)", + attachments: [], + messageRef: ref("m1"), + }, + { id: "ht:hu:m1>0", kind: "assistant", text: "persisted reply shape", round: 1 }, + ], + mode: "enrich", + }); + const enriched = result.turns[0]; + assert.equal(enriched.user.text, "as typed", "display text stays the streamed one"); + assert.equal(enriched.user.messageRef?.messageId, "m1"); + const replyEntry = enriched.entries.find((entry) => entry.kind === "assistant"); + assert.equal(replyEntry.text, "streamed reply", "assistant text never replaced"); +}); + +test("groupHistoryEntriesIntoTurns keeps a headless leading turn", () => { + const turns = groupHistoryEntriesIntoTurns([ + { id: "ht:^>0", kind: "assistant", text: "cut mid-turn", round: 1 }, + { id: "hu:m1", kind: "user", text: "prompt", attachments: [] }, + { id: "ht:hu:m1>0", kind: "assistant", text: "reply", round: 1 }, + ]); + assert.equal(turns.length, 2); + assert.equal(turns[0].user, null); + assert.equal(turns[1].user?.id, "hu:m1"); +}); diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index dc3e0468..8a2f2903 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -8,7 +8,9 @@ const loader = createWebModuleLoader({ rootDir: fileURLToPath(new URL("../", import.meta.url)), }); -const { createTranscriptStore } = loader.loadModule("src/lib/chat/stream/transcriptStore.ts"); +const { createTranscriptStore } = loader.loadModule( + "src/lib/chat/transcript/transcriptStore.ts", +); function runStarted(runId, seq, extra = {}) { return { type: "run_started", conversation_id: "conv-1", run_id: runId, seq, ...extra }; @@ -26,7 +28,51 @@ function userMessage(runId, seq, message, extra = {}) { return { type: "user_message", conversation_id: "conv-1", run_id: runId, seq, message, ...extra }; } -test("run lifecycle: reply settles in the tail and folds at the next run_started", () => { +function foldedRows(snapshot) { + return snapshot.foldedRows; +} + +function liveRows(snapshot) { + return snapshot.liveRows; +} + +function allRows(snapshot) { + return [...snapshot.foldedRows, ...snapshot.liveRows]; +} + +function rowText(row) { + if (row.kind === "assistant") { + return row.rounds + .map((round) => + round.blocks.flatMap((block) => (block.kind === "text" ? [block.text] : [])).join(""), + ) + .join("\n"); + } + return row.text ?? ""; +} + +function assertUniqueKeys(snapshot) { + const keys = allRows(snapshot).map((row) => row.key); + assert.equal(new Set(keys).size, keys.length, `duplicate keys: ${keys.join(", ")}`); + assert.equal( + keys.some((key) => key.includes("#")), + false, + `keys needed a collision suffix: ${keys.join(", ")}`, + ); +} + +function messageRef(messageId, messageIndex = 0) { + return { + segmentIndex: 0, + messageIndex, + segmentId: "segment-1", + messageId, + role: "user", + contentHash: `hash-${messageId}`, + }; +} + +test("run lifecycle: reply renders in the live flow and folds at the next run_started", () => { const store = createTranscriptStore(); store.applyEvent(userMessage("run-1", 1, "hello")); store.applyEvent(runStarted("run-1", 2)); @@ -35,43 +81,49 @@ test("run lifecycle: reply settles in the tail and folds at the next run_started store.flush(); let snapshot = store.getSnapshot(); - assert.equal(snapshot.committed.length, 0); - assert.equal(snapshot.tail.length, 2); + assert.equal(foldedRows(snapshot).length, 0); + assert.deepEqual( + liveRows(snapshot).map((row) => row.kind), + ["user", "assistant"], + ); assert.equal(snapshot.activeRun?.runId, "run-1"); + const assistantKey = liveRows(snapshot)[1].key; + assert.equal(rowText(liveRows(snapshot)[1]), "answer text"); - const assistantId = snapshot.tail[1].id; - assert.equal(snapshot.tail[1].text, "answer text"); - - // Reply end: entries stay in the tail (zero DOM movement), busy clears. + // Reply end: rows stay in the live flow (zero DOM movement), busy clears. store.applyEvent(runFinished("run-1", 5)); store.flush(); snapshot = store.getSnapshot(); assert.equal(snapshot.activeRun, null); - assert.equal(snapshot.committed.length, 0); - assert.equal(snapshot.tail.length, 2); - assert.equal(snapshot.tail[1].id, assistantId, "settled entry keeps its id"); + assert.equal(foldedRows(snapshot).length, 0); + assert.equal(liveRows(snapshot)[1].key, assistantKey, "settled row keeps its key"); const foldRevisionBefore = snapshot.foldRevision; - // Queue auto-send handoff: the next run folds the settled tail into - // committed and streams into a fresh live segment. + // Queue auto-send handoff: the next run folds the settled turn into the + // virtualized region and streams into a fresh turn. store.applyEvent(userMessage("run-2", 6, "queued prompt")); store.applyEvent(runStarted("run-2", 7)); store.applyEvent(token("run-2", 8, "second")); store.flush(); snapshot = store.getSnapshot(); - assert.equal(snapshot.committed.length, 2, "previous reply folded into committed"); - assert.equal(snapshot.committed[1].id, assistantId, "fold preserves ids"); + assert.deepEqual( + foldedRows(snapshot).map((row) => row.kind), + ["user", "assistant"], + "previous exchange folded", + ); + assert.equal(foldedRows(snapshot)[1].key, assistantKey, "fold preserves keys"); assert.ok(snapshot.foldRevision > foldRevisionBefore); assert.equal(snapshot.activeRun?.runId, "run-2"); assert.deepEqual( - snapshot.tail.map((entry) => entry.kind), + liveRows(snapshot).map((row) => row.kind), ["user", "assistant"], ); - assert.equal(snapshot.tail[0].text, "queued prompt"); - assert.equal(snapshot.tail[1].text, "second"); + assert.equal(liveRows(snapshot)[0].text, "queued prompt"); + assert.equal(rowText(liveRows(snapshot)[1]), "second"); + assertUniqueKeys(snapshot); }); -test("cross-run entries never collide on id", () => { +test("cross-run rows never collide on key", () => { const store = createTranscriptStore(); for (const runId of ["run-1", "run-2"]) { store.applyEvent(runStarted(runId, runId === "run-1" ? 1 : 4)); @@ -80,23 +132,21 @@ test("cross-run entries never collide on id", () => { } store.applyEvent(runStarted("run-3", 7)); store.flush(); - - const snapshot = store.getSnapshot(); - const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); - assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); + assertUniqueKeys(store.getSnapshot()); }); -test("optimistic user entry is adopted by client_request_id keeping its id", () => { +test("optimistic user bubble binds to its run keeping its key", () => { const store = createTranscriptStore(); store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "hi there" }); store.flush(); - const optimisticId = store.getSnapshot().tail[0].id; + const optimisticKey = liveRows(store.getSnapshot())[0].key; store.applyEvent(userMessage("run-1", 1, "hi there", { client_request_id: "client-1" })); store.flush(); const snapshot = store.getSnapshot(); - assert.equal(snapshot.tail.length, 1, "seeded echo must not duplicate the bubble"); - assert.equal(snapshot.tail[0].id, optimisticId, "user bubble keeps its identity"); + const users = allRows(snapshot).filter((row) => row.kind === "user"); + assert.equal(users.length, 1, "seeded echo must not duplicate the bubble"); + assert.equal(users[0].key, optimisticKey, "user bubble keeps its identity"); }); test("failed run appends an error entry and clears busy", () => { @@ -106,16 +156,16 @@ test("failed run appends an error entry and clears busy", () => { store.flush(); const snapshot = store.getSnapshot(); assert.equal(snapshot.activeRun, null); - const tailText = snapshot.tail.map((entry) => entry.text ?? "").join("\n"); - assert.match(tailText, /model exploded/); + const text = allRows(snapshot).map((row) => rowText(row)).join("\n"); + assert.match(text, /model exploded/); }); -test("run_queued removes the run's provisional entries", () => { +test("run_queued removes the turn entirely", () => { const store = createTranscriptStore(); store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "park me" }); store.applyEvent(userMessage("run-1", 1, "park me", { client_request_id: "client-1" })); store.flush(); - assert.equal(store.getSnapshot().tail.length, 1); + assert.equal(allRows(store.getSnapshot()).length, 1); store.applyEvent({ type: "run_queued", @@ -125,79 +175,239 @@ test("run_queued removes the run's provisional entries", () => { client_request_id: "client-1", }); store.flush(); - assert.equal(store.getSnapshot().tail.length, 0, "queued prompt leaves the transcript"); + assert.equal(allRows(store.getSnapshot()).length, 0, "queued prompt leaves the transcript"); }); -test("history snapshot merge preserves rendered ids and upgrades content", () => { +test("first prompt: rows are exactly [user, assistant] with a stable user key", () => { + // The reported bug: after the first prompt of a fresh conversation, an + // avatar-only assistant row appeared above the user bubble (or the bubble + // duplicated). The full first-prompt sequence must produce exactly one + // user row followed by one assistant row, with the optimistic bubble's + // identity stable throughout. const store = createTranscriptStore(); - store.applyEvent(userMessage("run-1", 1, "question")); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "什么是磁重联放电" }); + store.flush(); + const userKey = allRows(store.getSnapshot())[0].key; + + store.applyEvent( + userMessage("run-1", 1, "什么是磁重联放电", { client_request_id: "client-1" }), + ); + store.applyEvent(runStarted("run-1", 2, { client_request_id: "client-1" })); + // DeepSeek-style thinking-first reply with a leading meta-only token. + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-1", + seq: 3, + text: "", + provider: "deepseek", + model: "deepseek-v4", + }); + store.applyEvent({ + type: "thinking", + conversation_id: "conv-1", + run_id: "run-1", + seq: 4, + text: "思考中……", + }); + store.applyEvent(token("run-1", 5, "磁重联是…")); + store.applyEvent(runFinished("run-1", 6)); + store.flush(); + + const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => row.kind), + ["user", "assistant"], + "exactly one user row followed by one assistant row", + ); + assert.equal(allRows(snapshot)[0].key, userKey, "user bubble never re-keyed"); + assertUniqueKeys(snapshot); +}); + +test("meta-only token never produces an assistant row", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "hi")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent({ + type: "token", + conversation_id: "conv-1", + run_id: "run-1", + seq: 3, + text: "", + provider: "openai", + model: "gpt", + usage: { totalTokens: 12 }, + }); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => row.kind), + ["user"], + "no avatar row for a content-less reply", + ); +}); + +test("enrich upgrades the settled turn in place (messageRef arrives)", () => { + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "edit me" }); + store.applyEvent(userMessage("run-1", 1, "edit me", { client_request_id: "client-1" })); store.applyEvent(runStarted("run-1", 2)); store.applyEvent(token("run-1", 3, "reply")); store.applyEvent(runFinished("run-1", 4)); - store.applyEvent(userMessage("run-2", 5, "next")); - store.applyEvent(runStarted("run-2", 6)); store.flush(); - const settledAssistantId = store.getSnapshot().committed.find( - (entry) => entry.kind === "assistant", - )?.id; - assert.ok(settledAssistantId, "first reply folded into committed"); + const before = store.getSnapshot(); + const userRow = allRows(before).find((row) => row.kind === "user"); + assert.ok(userRow); + assert.equal(userRow.messageRef, undefined); - // History arrives with its own ids for the same logical entries plus the - // tail entries; tail entries must stay in the tail region. - store.applyHistorySnapshot([ - { id: "hist-1", kind: "user", text: "question", attachments: [] }, - { id: "hist-2", kind: "assistant", text: "reply", round: 0 }, - { id: "hist-3", kind: "user", text: "next", attachments: [] }, - ]); + const ref = messageRef("message-1"); + store.applyHistorySnapshot( + [ + { id: "hu:message-1", kind: "user", text: "edit me (persisted shape)", attachments: [], messageRef: ref }, + { id: "ht:hu:message-1>0", kind: "assistant", text: "reply", round: 1 }, + ], + { mode: "enrich" }, + ); store.flush(); const snapshot = store.getSnapshot(); - const committedAssistant = snapshot.committed.find((entry) => entry.kind === "assistant"); + const upgraded = allRows(snapshot).find((row) => row.kind === "user"); + assert.equal(upgraded?.key, userRow.key, "row keeps its rendered key"); + assert.deepEqual(upgraded?.messageRef, ref, "messageRef attached in place"); + assert.equal(upgraded?.text, "edit me", "streamed display text is never replaced"); assert.equal( - committedAssistant?.id, - settledAssistantId, - "history merge keeps the rendered id", + allRows(snapshot).filter((row) => row.kind === "user").length, + 1, + "text-shape mismatch cannot duplicate the bubble", ); - assert.equal( - snapshot.committed.some((entry) => entry.text === "next" && entry.kind === "user"), - false, - "entries still rendered in the tail stay out of committed", +}); + +test("enrich with a thinking-first persisted reply adds no avatar row above the user bubble", () => { + // The screenshot scenario: the persisted assistant message starts with a + // thinking block, so the parser emits a meta-carrier assistant entry with + // empty text. The alignment keeps the streamed turn authoritative and the + // row builder drops content-less rounds — the meta carrier can never + // become a floating avatar row above the first user bubble. + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "查询磁重联" }); + store.applyEvent(userMessage("run-1", 1, "查询磁重联", { client_request_id: "client-1" })); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent({ + type: "thinking", + conversation_id: "conv-1", + run_id: "run-1", + seq: 3, + text: "推理…", + }); + store.applyEvent(token("run-1", 4, "结论")); + store.applyEvent(runFinished("run-1", 5)); + store.flush(); + + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "查询磁重联", attachments: [], messageRef: messageRef("m1") }, + // Meta-only carrier the parser emits before a leading thinking block. + { id: "ht:hu:m1>0", kind: "assistant", text: "", round: 1, meta: { provider: "deepseek" } }, + { id: "ht:hu:m1>1", kind: "thinking", text: "推理…", round: 1 }, + { id: "ht:hu:m1>2", kind: "assistant", text: "结论", round: 1 }, + ], + { mode: "enrich" }, ); - assert.equal(snapshot.tail.some((entry) => entry.text === "next"), true); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => row.kind), + ["user", "assistant"], + "no assistant row above the user bubble", + ); + assert.equal(allRows(snapshot)[0].kind, "user"); + assertUniqueKeys(snapshot); }); -test("rebased truncates committed at the edited user message", () => { +test("enrich with exchanges this client never streamed repaints instead of duplicating", () => { const store = createTranscriptStore(); - store.applyHistorySnapshot([ - { - id: "hist-1", - kind: "user", - text: "first", - attachments: [], - messageRef: { - segmentIndex: 0, - messageIndex: 0, - segmentId: "segment-1", - messageId: "message-1", - role: "user", - contentHash: "hash-1", - }, - }, - { id: "hist-2", kind: "assistant", text: "answer", round: 0 }, - { - id: "hist-3", - kind: "user", - text: "second", - attachments: [], - messageRef: { - segmentIndex: 0, - messageIndex: 2, - segmentId: "segment-1", - messageId: "message-3", - role: "user", - contentHash: "hash-3", - }, - }, - ]); + store.applyEvent(userMessage("run-1", 1, "question")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "reply")); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + + // History knows a second exchange (another client) that never streamed + // here: the window is ahead of the store — replace wholesale. + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "question", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "reply", round: 1 }, + { id: "hu:m2", kind: "user", text: "next", attachments: [], messageRef: messageRef("m2", 2) }, + { id: "ht:hu:m2>0", kind: "assistant", text: "other reply", round: 1 }, + ], + { mode: "enrich" }, + ); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => [row.kind, rowText(row)]), + [ + ["user", "question"], + ["assistant", "reply"], + ["user", "next"], + ["assistant", "other reply"], + ], + "every exchange renders exactly once", + ); + assertUniqueKeys(snapshot); +}); + +test("replace keeps the active exchange and trims its persisted echo", () => { + const store = createTranscriptStore(); + // A prior exchange lives in history; a new prompt is in flight. + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + ], + { mode: "replace" }, + ); + store.addOptimisticUserEntry({ clientRequestId: "client-2", text: "new prompt" }); + store.applyEvent(userMessage("run-2", 1, "new prompt", { client_request_id: "client-2" })); + store.applyEvent(runStarted("run-2", 2, { client_request_id: "client-2" })); + store.flush(); + const userKey = allRows(store.getSnapshot()).find((row) => row.kind === "user" && row.text === "new prompt")?.key; + assert.ok(userKey); + + // A mid-run reload returns the persisted state: the agent already stored + // the new prompt (user-only trailing turn). It must render once — from + // its streaming turn — with the persisted ref adopted. + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + { id: "hu:m2", kind: "user", text: "new prompt", attachments: [], messageRef: messageRef("m2", 2) }, + ], + { mode: "replace" }, + ); + store.flush(); + const snapshot = store.getSnapshot(); + const prompts = allRows(snapshot).filter( + (row) => row.kind === "user" && row.text === "new prompt", + ); + assert.equal(prompts.length, 1, "active prompt renders once"); + assert.equal(prompts[0].key, userKey, "streaming turn's bubble keeps its key"); + assert.equal(prompts[0].messageRef?.messageId, "m2", "persisted ref adopted"); + assert.equal(snapshot.activeRun?.runId, "run-2"); + assertUniqueKeys(snapshot); +}); + +test("rebased truncates at the edited user message in the history region", () => { + const store = createTranscriptStore(); + store.applyHistorySnapshot( + [ + { id: "hu:message-1", kind: "user", text: "first", attachments: [], messageRef: messageRef("message-1") }, + { id: "ht:hu:message-1>0", kind: "assistant", text: "answer", round: 1 }, + { id: "hu:message-3", kind: "user", text: "second", attachments: [], messageRef: messageRef("message-3", 2) }, + ], + { mode: "replace" }, + ); store.applyEvent({ type: "rebased", conversation_id: "conv-1", @@ -209,19 +419,46 @@ test("rebased truncates committed at the edited user message", () => { segment_id: "segment-1", message_id: "message-3", role: "user", - content_hash: "hash-3", + content_hash: "hash-message-3", }, }); store.flush(); const snapshot = store.getSnapshot(); assert.deepEqual( - snapshot.committed.map((entry) => entry.id), - ["hist-1", "hist-2"], - "committed truncated at the edited message", + allRows(snapshot).map((row) => [row.kind, rowText(row)]), + [ + ["user", "first"], + ["assistant", "answer"], + ], + "transcript truncated at the edited message", ); }); -test("reset sync rebuilds the tail from a runtime snapshot", () => { +test("rebased truncates at the edited user message in an enriched turn", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "prompt")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "reply")); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + store.applyHistorySnapshot( + [{ id: "hu:m9", kind: "user", text: "prompt", attachments: [], messageRef: messageRef("m9") }], + { mode: "enrich" }, + ); + store.flush(); + + store.applyEvent({ + type: "rebased", + conversation_id: "conv-1", + run_id: "run-2", + seq: 5, + base_message_ref: { message_id: "m9", content_hash: "hash-m9" }, + }); + store.flush(); + assert.equal(allRows(store.getSnapshot()).length, 0, "edited turn removed for its re-send"); +}); + +test("reset sync rebuilds the active turn from a runtime snapshot", () => { const store = createTranscriptStore(); store.applyEvent(runStarted("run-1", 1)); store.applyEvent(token("run-1", 2, "will be lost")); @@ -248,6 +485,7 @@ test("reset sync rebuilds the tail from a runtime snapshot", () => { ]), toolStatus: "Vibing", toolStatusIsCompaction: false, + asOfSeq: 0, }, events: [{ type: "token", conversation_id: "conv-1", run_id: "run-2", seq: 3, text: "!" }], }); @@ -256,11 +494,40 @@ test("reset sync rebuilds the tail from a runtime snapshot", () => { const snapshot = store.getSnapshot(); assert.equal(snapshot.activeRun?.runId, "run-2"); assert.equal(snapshot.toolStatus, "Vibing"); - const text = snapshot.tail.map((entry) => entry.text ?? "").join(""); + const text = allRows(snapshot).map((row) => rowText(row)).join(""); assert.match(text, /rebuilt from snapshot/); assert.doesNotMatch(text, /will be lost/); }); +test("reset keeps the optimistic pending bubble and binds it on replay", () => { + // The old pipeline wiped the live segment on reset while leaving its + // adoption bookkeeping behind, so the replayed seed re-appended a second + // bubble. The turn model keeps the pending turn itself. + const store = createTranscriptStore(); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "hold on" }); + store.flush(); + const userKey = allRows(store.getSnapshot())[0].key; + + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-2", + latestSeq: 2, + reset: true, + activity: null, + snapshot: null, + events: [ + userMessage("run-1", 1, "hold on", { client_request_id: "client-1" }), + runStarted("run-1", 2, { client_request_id: "client-1" }), + ], + }); + store.flush(); + const snapshot = store.getSnapshot(); + const users = allRows(snapshot).filter((row) => row.kind === "user"); + assert.equal(users.length, 1, "optimistic bubble survives the reset without duplicating"); + assert.equal(users[0].key, userKey, "bubble identity survives the reset"); + assert.equal(snapshot.activeRun?.runId, "run-1"); +}); + test("tool status mirrors into the snapshot and clears on run end", () => { const store = createTranscriptStore(); store.applyEvent(runStarted("run-1", 1)); @@ -292,8 +559,8 @@ test("replay idempotency: a resubscribe replaying applied events changes nothing } store.flush(); const before = store.getSnapshot(); - assert.equal(before.tail.length, 2); - assert.equal(before.tail[1].text, "answer text"); + assert.equal(allRows(before).length, 2); + assert.equal(rowText(allRows(before)[1]), "answer text"); // Conversation switch-back: the transport re-subscribes from after_seq=0 // and the gateway replays the whole buffered log plus one new event. @@ -316,19 +583,16 @@ test("replay idempotency: a resubscribe replaying applied events changes nothing store.flush(); const snapshot = store.getSnapshot(); - const userBubbles = snapshot.tail.filter((entry) => entry.kind === "user"); - const assistants = snapshot.tail.filter((entry) => entry.kind === "assistant"); - assert.equal(userBubbles.length, 1, "exactly one user bubble after replay"); - assert.equal(assistants.length, 1, "exactly one assistant entry after replay"); - assert.equal(assistants[0].text, "answer text!", "only the new token applied"); - const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); - assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); + const users = allRows(snapshot).filter((row) => row.kind === "user"); + const assistants = allRows(snapshot).filter((row) => row.kind === "assistant"); + assert.equal(users.length, 1, "exactly one user bubble after replay"); + assert.equal(assistants.length, 1, "exactly one assistant row after replay"); + assert.equal(rowText(assistants[0]), "answer text!", "only the new token applied"); + assertUniqueKeys(snapshot); }); test("snapshot as_of_seq is a replay barrier: overlapping events are dropped", () => { const store = createTranscriptStore(); - // Late join mid-run: the subscribe response carries a runtime snapshot - // covering the log through seq 4, plus a replay that overlaps it. store.applySync({ conversationId: "conv-1", streamEpoch: "epoch-1", @@ -352,15 +616,11 @@ test("snapshot as_of_seq is a replay barrier: overlapping events are dropped", ( toolStatusIsCompaction: false, asOfSeq: 4, }, - events: [ - token("run-1", 3, "answer "), - token("run-1", 4, "text"), - token("run-1", 5, "!"), - ], + events: [token("run-1", 3, "answer "), token("run-1", 4, "text"), token("run-1", 5, "!")], }); store.flush(); const snapshot = store.getSnapshot(); - const text = snapshot.tail.map((entry) => entry.text ?? "").join(""); + const text = allRows(snapshot).map((row) => rowText(row)).join(""); assert.equal(text, "answer text!", "snapshot content is not double-applied"); }); @@ -377,16 +637,16 @@ test("inline snapshot events carry as_of_seq and drop the overlapping tail", () ]), as_of_seq: 6, }); - // Events at or below the snapshot's coverage must be ignored; newer ones apply. store.applyEvent(token("run-1", 5, "stale ")); store.applyEvent(token("run-1", 6, "stale")); store.applyEvent(token("run-1", 7, " and more")); store.flush(); - const text = store.getSnapshot().tail.map((entry) => entry.text ?? "").join(""); + const text = allRows(store.getSnapshot()).map((row) => rowText(row)) + .join(""); assert.equal(text, "full text so far and more"); }); -test("stray run_finished for a non-active run never settles the streaming tail", () => { +test("stray run_finished for a non-active run never settles the streaming turn", () => { const store = createTranscriptStore(); store.applyEvent(runStarted("run-b", 1)); store.applyEvent(token("run-b", 2, "streaming")); @@ -399,59 +659,52 @@ test("stray run_finished for a non-active run never settles the streaming tail", }); store.flush(); - // The gateway deliberately appends terminals for non-active runs (e.g. - // failing a superseded queued run). The active segment must not settle. store.applyEvent(runFinished("run-a", 4, "failed", { message: "queued run failed" })); store.flush(); const snapshot = store.getSnapshot(); assert.equal(snapshot.activeRun?.runId, "run-b", "active run unchanged"); assert.equal(snapshot.toolStatus, "Vibing", "tool status unchanged"); assert.equal( - snapshot.tail.some((entry) => entry.text === "streaming"), + allRows(snapshot).some((row) => rowText(row).includes("streaming")), true, - "streaming entry still live", + "streaming row still live", ); - // The active run's own terminal still settles normally afterwards. store.applyEvent(runFinished("run-b", 5)); store.flush(); assert.equal(store.getSnapshot().activeRun, null); }); -test("run_finished settles only entries owned by the finished run", () => { +test("run_finished settles only its own turn; foreign turns stay live", () => { const store = createTranscriptStore(); store.applyEvent(runStarted("run-a", 1)); store.applyEvent(token("run-a", 2, "reply a")); - // A queued command's seeded user message (run-b) plus a not-yet-adopted + // A queued command's seeded user message (run-b) plus a not-yet-bound // optimistic echo arrive while run-a is still streaming. store.applyEvent(userMessage("run-b", 3, "queued prompt")); store.addOptimisticUserEntry({ clientRequestId: "client-c", text: "pending echo" }); store.applyEvent(runFinished("run-a", 4)); store.flush(); - let snapshot = store.getSnapshot(); - assert.equal(snapshot.activeRun, null); - // run-a's reply settled; run-b's seeded user message and the optimistic - // echo stay live for their own runs. + assert.equal(store.getSnapshot().activeRun, null); store.applyEvent(runStarted("run-b", 5)); store.applyEvent(token("run-b", 6, "reply b")); store.flush(); - snapshot = store.getSnapshot(); + const snapshot = store.getSnapshot(); assert.deepEqual( - snapshot.committed.map((entry) => entry.text), + foldedRows(snapshot).map((row) => rowText(row)), ["reply a"], - "fold took only run-a's settled entries", + "fold took only run-a's turn", ); assert.deepEqual( - snapshot.tail.map((entry) => entry.text), - ["queued prompt", "pending echo", "reply b"], - "foreign entries stayed in the live region", + liveRows(snapshot).map((row) => rowText(row)), + ["queued prompt", "reply b", "pending echo"], + "run-b's reply renders with its own prompt; the pending echo stays live", ); - const ids = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); - assert.equal(new Set(ids).size, ids.length); + assertUniqueKeys(snapshot); }); -test("interleaved run_queued compensation still removes the adopted bubble", () => { +test("interleaved run_queued compensation still removes the bound bubble", () => { const store = createTranscriptStore(); store.applyEvent(runStarted("run-a", 1)); store.addOptimisticUserEntry({ clientRequestId: "client-b", text: "park me" }); @@ -468,21 +721,21 @@ test("interleaved run_queued compensation still removes the adopted bubble", () store.flush(); const snapshot = store.getSnapshot(); assert.equal( - [...snapshot.committed, ...snapshot.tail].some((entry) => entry.text === "park me"), + allRows(snapshot).some((row) => rowText(row) === "park me"), false, "queued prompt left the transcript entirely", ); }); -test("reset folds the settled tail into committed instead of dropping the last reply", () => { +test("reset folds the settled turn instead of dropping the last reply", () => { const store = createTranscriptStore(); store.applyEvent(userMessage("run-1", 1, "question")); store.applyEvent(runStarted("run-1", 2)); store.applyEvent(token("run-1", 3, "the reply")); store.applyEvent(runFinished("run-1", 4)); store.flush(); - const settledIds = store.getSnapshot().tail.map((entry) => entry.id); - assert.equal(settledIds.length, 2); + const keysBefore = allRows(store.getSnapshot()).map((row) => row.key); + assert.equal(keysBefore.length, 2); // Gateway restart while idle: epoch reset with nothing to replay. store.applySync({ @@ -497,134 +750,290 @@ test("reset folds the settled tail into committed instead of dropping the last r store.flush(); const snapshot = store.getSnapshot(); assert.deepEqual( - snapshot.committed.map((entry) => entry.id), - settledIds, - "settled reply folded into committed with stable ids", + foldedRows(snapshot).map((row) => row.key), + keysBefore, + "settled exchange folded with stable keys", ); - assert.equal(snapshot.tail.length, 0); + assert.equal(liveRows(snapshot).length, 0); }); -test("history snapshot upgrades matching tail entries in place (messageRef arrives)", () => { +test("identical prompts across exchanges keep distinct keys through enrich", () => { const store = createTranscriptStore(); - store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "edit me" }); - store.applyEvent(userMessage("run-1", 1, "edit me", { client_request_id: "client-1" })); - store.applyEvent(runStarted("run-1", 2)); - store.applyEvent(token("run-1", 3, "reply")); - store.applyEvent(runFinished("run-1", 4)); + for (const [runId, base] of [ + ["run-1", 0], + ["run-2", 4], + ]) { + store.applyEvent(userMessage(runId, base + 1, "继续")); + store.applyEvent(runStarted(runId, base + 2)); + store.applyEvent(token(runId, base + 3, runId === "run-1" ? "第一次回复" : "第二次回复")); + store.applyEvent(runFinished(runId, base + 4)); + } store.flush(); const before = store.getSnapshot(); - const userId = before.tail.find((entry) => entry.kind === "user")?.id; - assert.ok(userId); - assert.equal(before.tail.find((entry) => entry.kind === "user")?.messageRef, undefined); + assertUniqueKeys(before); + const keysBefore = allRows(before).map((row) => row.key); - const messageRef = { - segmentIndex: 0, - messageIndex: 0, - segmentId: "segment-1", - messageId: "message-1", - role: "user", - contentHash: "hash-1", - }; - store.applyHistorySnapshot([ - { id: "hist-user", kind: "user", text: "edit me", attachments: [], messageRef }, - { id: "hist-assistant", kind: "assistant", text: "reply", round: 0 }, - ]); + store.applyHistorySnapshot( + [ + { id: "hu:ma", kind: "user", text: "继续", attachments: [], messageRef: messageRef("ma") }, + { id: "ht:hu:ma>0", kind: "assistant", text: "第一次回复", round: 1 }, + { id: "hu:mb", kind: "user", text: "继续", attachments: [], messageRef: messageRef("mb", 2) }, + { id: "ht:hu:mb>0", kind: "assistant", text: "第二次回复", round: 1 }, + ], + { mode: "enrich" }, + ); store.flush(); const snapshot = store.getSnapshot(); - const tailUser = snapshot.tail.find((entry) => entry.kind === "user"); - assert.equal(tailUser?.id, userId, "tail entry keeps its rendered id"); - assert.deepEqual(tailUser?.messageRef, messageRef, "history payload upgraded in place"); - assert.equal( - snapshot.committed.some((entry) => entry.kind === "user" && entry.text === "edit me"), - false, - "still excluded from committed", + assert.deepEqual( + allRows(snapshot).map((row) => row.key), + keysBefore, + "identical texts keep their own row identities", + ); + const refs = allRows(snapshot) + .filter((row) => row.kind === "user") + .map((row) => row.messageRef?.messageId); + assert.deepEqual(refs, ["ma", "mb"], "each occurrence adopts its own ref, in order"); + assertUniqueKeys(snapshot); +}); + +test("multi-viewer: seeded events alone render one exchange, replays are no-ops", () => { + const store = createTranscriptStore(); + const events = [ + userMessage("run-1", 1, "their prompt", { client_request_id: "someone-else" }), + runStarted("run-1", 2), + token("run-1", 3, "their reply"), + runFinished("run-1", 4), + ]; + for (const event of events) { + store.applyEvent(event); + } + for (const event of events) { + store.applyEvent(event); + } + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => [row.kind, rowText(row)]), + [ + ["user", "their prompt"], + ["assistant", "their reply"], + ], ); + assertUniqueKeys(snapshot); }); +test("entryCount tracks history entries plus turn content", () => { + const store = createTranscriptStore(); + assert.equal(store.getSnapshot().entryCount, 0); + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old", attachments: [] }, + { id: "ht:hu:m1>0", kind: "assistant", text: "reply", round: 1 }, + ], + { mode: "replace" }, + ); + store.applyEvent(userMessage("run-1", 1, "new")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "streaming")); + store.flush(); + assert.equal(store.getSnapshot().entryCount, 4); +}); + +test("a turn folding behind an earlier pending turn still enters the folded region", () => { + const store = createTranscriptStore(); + // This client's prompt waits (pending) while a foreign run completes. + store.addOptimisticUserEntry({ clientRequestId: "client-a", text: "waiting prompt" }); + store.applyEvent(userMessage("run-b", 1, "foreign prompt")); + store.applyEvent(runStarted("run-b", 2)); + store.applyEvent(token("run-b", 3, "foreign reply")); + store.applyEvent(runFinished("run-b", 4)); + // This client's run starts: the foreign exchange folds even though the + // pending turn precedes it in creation order. + store.applyEvent(userMessage("run-a", 5, "waiting prompt", { client_request_id: "client-a" })); + store.applyEvent(runStarted("run-a", 6, { client_request_id: "client-a" })); + store.applyEvent(token("run-a", 7, "own reply")); + store.flush(); + const snapshot = store.getSnapshot(); + assert.deepEqual( + foldedRows(snapshot).map((row) => rowText(row)), + ["foreign prompt", "foreign reply"], + "the completed foreign exchange is virtualized", + ); + assert.deepEqual( + liveRows(snapshot).map((row) => rowText(row)), + ["waiting prompt", "own reply"], + ); + assertUniqueKeys(snapshot); +}); -test("history merge is occurrence-aware: repeated dedup keys never share an id", () => { +test("reset with the run gone settles the turn and enrich adopts the persisted reply", () => { const store = createTranscriptStore(); - // Two identical user prompts and two id-less same-name tool calls in the - // same round across runs — every dedup key below collides. - store.applyHistorySnapshot([ - { id: "hist-1", kind: "user", text: "继续", attachments: [] }, - { id: "hist-2", kind: "assistant", text: "第一次回复", round: 0 }, - { id: "hist-3", kind: "user", text: "继续", attachments: [] }, - { id: "hist-4", kind: "assistant", text: "第二次回复", round: 0 }, - ]); + store.addOptimisticUserEntry({ clientRequestId: "client-1", text: "prompt" }); + store.applyEvent(userMessage("run-1", 1, "prompt", { client_request_id: "client-1" })); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "partial")); + store.flush(); + + // Gateway restarted; the run finished during the gap and the replay no + // longer covers it. + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-2", + latestSeq: 0, + reset: true, + activity: null, + snapshot: null, + events: [], + }); store.flush(); let snapshot = store.getSnapshot(); - assert.equal(snapshot.committed.length, 4); - let ids = snapshot.committed.map((entry) => entry.id); - assert.equal(new Set(ids).size, ids.length, `duplicate ids: ${ids.join(", ")}`); - - // Re-merging the same snapshot keeps every occurrence, ids stay unique and - // stable (each occurrence adopts its own previous id, in order). - const before = snapshot.committed.map((entry) => entry.id); - store.applyHistorySnapshot([ - { id: "hist-1b", kind: "user", text: "继续", attachments: [] }, - { id: "hist-2b", kind: "assistant", text: "第一次回复", round: 0 }, - { id: "hist-3b", kind: "user", text: "继续", attachments: [] }, - { id: "hist-4b", kind: "assistant", text: "第二次回复", round: 0 }, - ]); + const userKey = allRows(snapshot).find((row) => row.kind === "user")?.key; + assert.ok(userKey, "the prompt bubble survives the reset"); + assert.equal(snapshot.activeRun, null); + + // The idle enrich (triggered by the history upsert) adopts the persisted + // reply into the settled turn — no zombie pending turn blocks it. + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "prompt", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "the full persisted reply", round: 1 }, + ], + { mode: "enrich" }, + ); store.flush(); snapshot = store.getSnapshot(); assert.deepEqual( - snapshot.committed.map((entry) => entry.id), - before, - "occurrences adopt their own previous ids in order", + allRows(snapshot).map((row) => [row.kind, rowText(row)]), + [ + ["user", "prompt"], + ["assistant", "the full persisted reply"], + ], + "the persisted reply appears exactly once", + ); + assert.equal( + allRows(snapshot).find((row) => row.kind === "user")?.key, + userKey, + "the bubble never re-keyed", ); - assert.equal(snapshot.committed.length, 4, "no occurrence dropped"); }); -test("history merge with a committed copy and an identical tail copy keeps both", () => { +test("rebased with duplicate prompt texts truncates at the edited message, not the first hash match", () => { const store = createTranscriptStore(); - // Older identical prompt already in committed… - store.applyHistorySnapshot([ - { id: "hist-1", kind: "user", text: "ok", attachments: [] }, - { id: "hist-2", kind: "assistant", text: "先前的回复", round: 0 }, - ]); - // …and the same text sent again in the current run (tail). - store.applyEvent(userMessage("run-2", 10, "ok")); - store.applyEvent(runStarted("run-2", 11)); - store.applyEvent(runFinished("run-2", 12)); - store.flush(); - - const tailUserId = store.getSnapshot().tail.find((entry) => entry.kind === "user")?.id; - assert.ok(tailUserId, "tail keeps the new occurrence"); - - // History now holds both occurrences: the older one matches the committed - // copy (id preserved), the newer one upgrades the tail copy in place with - // its messageRef — neither is dropped, no id is shared. - store.applyHistorySnapshot([ - { id: "hist-1", kind: "user", text: "ok", attachments: [] }, - { id: "hist-2", kind: "assistant", text: "先前的回复", round: 0 }, - { - id: "hist-3", - kind: "user", - text: "ok", - attachments: [], - messageRef: { - segmentIndex: 0, - messageIndex: 2, - segmentId: "segment-1", - messageId: "message-3", - role: "user", - contentHash: "hash-3", + store.applyHistorySnapshot( + [ + { + id: "hu:m1", + kind: "user", + text: "hello", + attachments: [], + messageRef: { ...messageRef("m1"), contentHash: "same-hash" }, }, - }, - { id: "hist-4", kind: "assistant", text: "新的回复", round: 0 }, - ]); + { id: "ht:hu:m1>0", kind: "assistant", text: "reply one", round: 1 }, + { + id: "hu:m2", + kind: "user", + text: "hello", + attachments: [], + messageRef: { ...messageRef("m2", 2), contentHash: "same-hash" }, + }, + { id: "ht:hu:m2>0", kind: "assistant", text: "reply two", round: 1 }, + ], + { mode: "replace" }, + ); + store.applyEvent({ + type: "rebased", + conversation_id: "conv-1", + run_id: "run-9", + seq: 1, + base_message_ref: { message_id: "m2", content_hash: "same-hash" }, + }); store.flush(); const snapshot = store.getSnapshot(); + assert.deepEqual( + allRows(snapshot).map((row) => rowText(row)), + ["hello", "reply one"], + "the first exchange survives", + ); +}); + +test("streaming commits keep the folded-region array identity stable", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "one")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "first reply")); + store.applyEvent(runFinished("run-1", 4)); + store.applyEvent(userMessage("run-2", 5, "two")); + store.applyEvent(runStarted("run-2", 6)); + store.flush(); + const before = store.getSnapshot(); + assert.equal(before.foldedRows.length, 2); + + store.applyEvent(token("run-2", 7, "streaming ")); + store.flush(); + store.applyEvent(token("run-2", 8, "more")); + store.flush(); + const after = store.getSnapshot(); assert.equal( - snapshot.committed.filter((entry) => entry.kind === "user" && entry.text === "ok").length, - 1, - "committed keeps the older occurrence", + after.foldedRows, + before.foldedRows, + "token deltas must not re-derive the virtualized region's rows", + ); + assert.equal(rowText(after.liveRows[1]), "streaming more"); +}); + +test("replace keeps a settled exchange whose persistence lags the fetched window", () => { + const store = createTranscriptStore(); + store.applyEvent(userMessage("run-1", 1, "old prompt")); + store.applyEvent(runStarted("run-1", 2)); + store.applyEvent(token("run-1", 3, "old reply")); + store.applyEvent(runFinished("run-1", 4)); + store.flush(); + // Enrich attaches the persisted ref to the settled exchange. + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old prompt", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + ], + { mode: "enrich" }, + ); + // A second exchange settles… + store.applyEvent(userMessage("run-2", 5, "new prompt")); + store.applyEvent(runStarted("run-2", 6)); + store.applyEvent(token("run-2", 7, "new reply")); + store.applyEvent(runFinished("run-2", 8)); + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old prompt", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + { id: "hu:m2", kind: "user", text: "new prompt", attachments: [], messageRef: messageRef("m2", 2) }, + { id: "ht:hu:m2>0", kind: "assistant", text: "new reply", round: 1 }, + ], + { mode: "enrich" }, + ); + // …and a third finishes but is NOT yet in the refetched window when the + // user clicks load-more. + store.applyEvent(userMessage("run-3", 9, "fresh prompt")); + store.applyEvent(runStarted("run-3", 10)); + store.applyEvent(token("run-3", 11, "fresh reply")); + store.applyEvent(runFinished("run-3", 12)); + store.flush(); + + store.applyHistorySnapshot( + [ + { id: "hu:m1", kind: "user", text: "old prompt", attachments: [], messageRef: messageRef("m1") }, + { id: "ht:hu:m1>0", kind: "assistant", text: "old reply", round: 1 }, + { id: "hu:m2", kind: "user", text: "new prompt", attachments: [], messageRef: messageRef("m2", 2) }, + { id: "ht:hu:m2>0", kind: "assistant", text: "new reply", round: 1 }, + ], + { mode: "replace" }, + ); + store.flush(); + const snapshot = store.getSnapshot(); + const texts = allRows(snapshot).map((row) => rowText(row)); + assert.deepEqual( + texts, + ["old prompt", "old reply", "new prompt", "new reply", "fresh prompt", "fresh reply"], + "the just-finished exchange survives a lagging reload", ); - const tailUser = snapshot.tail.find((entry) => entry.kind === "user"); - assert.equal(tailUser?.id, tailUserId, "tail occurrence keeps its id"); - assert.equal(tailUser?.messageRef?.messageId, "message-3", "tail occurrence gains its messageRef"); - const allIds = [...snapshot.committed, ...snapshot.tail].map((entry) => entry.id); - assert.equal(new Set(allIds).size, allIds.length, `duplicate ids: ${allIds.join(", ")}`); + assertUniqueKeys(snapshot); }); From 090184e2b2e82c0258b1db879b49c3b448140471 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 00:29:09 +0800 Subject: [PATCH 23/64] fix(chat): prevent file tool previews from regressing --- .../internal/chatwire/payloads.go | 130 ++---- .../internal/chatwire/payloads_test.go | 80 +++- .../internal/session/conversation_ingress.go | 2 +- .../web/src/lib/chat/toolPreview.ts | 199 +++++++++ .../lib/chat/transcript/transcriptStore.ts | 149 ++++--- .../src/lib/chat/transcript/turnReducer.ts | 50 ++- .../web/src/lib/chat/uiMessages.ts | 139 +----- .../web/src/pages/chat/AssistantBubble.tsx | 409 +----------------- .../web/src/pages/chat/EditDiffView.tsx | 95 ++++ .../web/src/pages/chat/FileToolArgs.tsx | 144 ++++++ .../web/src/pages/chat/ToolSurfaces.tsx | 141 ++++++ .../web/test/chatUi-agent.test.mjs | 117 ++++- .../src/lib/chat/messages/toolPreview.ts | 199 +++++++++ .../src/lib/chat/messages/uiMessages.ts | 142 +----- .../assistant-bubble/FileToolArgs.tsx | 147 +++++++ .../assistant-bubble/ToolCallItem.tsx | 171 +------- .../pages/chat/gateway/chatRuntimeSnapshot.ts | 35 +- .../pages/chat/turns/gatewayToolPreview.ts | 82 ++-- .../chat/gateway-runtime-snapshot.test.mjs | 38 ++ crates/agent-gui/test/chat/messages.test.mjs | 97 ++++- 20 files changed, 1470 insertions(+), 1096 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/chat/toolPreview.ts create mode 100644 crates/agent-gateway/web/src/pages/chat/EditDiffView.tsx create mode 100644 crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx create mode 100644 crates/agent-gateway/web/src/pages/chat/ToolSurfaces.tsx create mode 100644 crates/agent-gui/src/lib/chat/messages/toolPreview.ts create mode 100644 crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx diff --git a/crates/agent-gateway/internal/chatwire/payloads.go b/crates/agent-gateway/internal/chatwire/payloads.go index 0f928783..567e5152 100644 --- a/crates/agent-gateway/internal/chatwire/payloads.go +++ b/crates/agent-gateway/internal/chatwire/payloads.go @@ -1,17 +1,22 @@ // Package chatwire shapes agent chat protobuf events into the JSON payloads -// sent to webui clients. Shaping (decode, normalize, trim) happens exactly once -// at ingress so every subscriber observes identical bytes. +// sent to webui clients. Shaping (decode, normalize, result trimming) happens +// exactly once at ingress so every subscriber observes identical bytes. +// +// Tool-call arguments pass through untouched: the desktop app is the single +// producer of streaming previews (truncated text + __liveagent_stream_preview +// metadata) and the gateway must never recompute or overwrite them. package chatwire import ( "encoding/json" "strings" + "unicode/utf8" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) // EventPayload shapes a ChatEvent into a wire payload, decoding the JSON data -// blob and trimming oversized tool content. +// blob and trimming oversized tool-result content. func EventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) map[string]any { protoType := EventTypeName(event.GetType()) payload := map[string]any{ @@ -42,76 +47,26 @@ func EventPayload(event *gatewayv1.ChatEvent, seq int64, workdirInput ...string) payload["conversation_id"] = conversationID } - TrimLargeToolContent(payload, protoType) + TrimLargeToolResultContent(payload, protoType) return payload } -const toolContentMaxChars = 200 +const toolResultMaxBytes = 200 -var toolFieldsToTrim = map[string][]string{ - "Write": {"content"}, - "Edit": {"old_string", "new_string"}, - "NotebookEdit": {"new_source"}, -} - -// TrimLargeToolContent truncates oversized tool arguments/results in place, -// attaching a __liveagent_stream_preview meta block describing the original size. -func TrimLargeToolContent(payload map[string]any, protoType string) { +// TrimLargeToolResultContent truncates oversized tool-result content in place, +// attaching a __liveagent_stream_preview meta block describing the original +// size. Tool-call arguments are never touched. +func TrimLargeToolResultContent(payload map[string]any, protoType string) { eventType, _ := payload["type"].(string) - - if eventType == "tool_call" || eventType == "tool_call_delta" || - protoType == "tool_call" || protoType == "tool_call_delta" { - trimToolCallPayload(payload) - return - } - if eventType == "tool_result" || protoType == "tool_result" { - trimToolResultPayload(payload) - } -} - -func trimToolCallPayload(payload map[string]any) { - toolName := firstString(payload["name"], payload["tool_name"]) - fields, ok := toolFieldsToTrim[toolName] - if !ok { + if eventType != "tool_result" && protoType != "tool_result" { return } - - args := firstMap(payload["arguments"], payload["input"], payload["args"]) - if args == nil { - args = tryParseJSONStringArg(payload, "arguments", "input", "args") - if args == nil { - return - } - } - - for _, field := range fields { - trimStringFieldWithPreview(args, field, toolContentMaxChars) - } -} - -func tryParseJSONStringArg(payload map[string]any, keys ...string) map[string]any { - for _, key := range keys { - s, ok := payload[key].(string) - if !ok || s == "" { - continue - } - var parsed map[string]any - if err := json.Unmarshal([]byte(s), &parsed); err == nil && len(parsed) > 0 { - payload[key] = parsed - return parsed - } - } - return nil -} - -func trimToolResultPayload(payload map[string]any) { switch content := payload["content"].(type) { case string: - if len(content) > toolContentMaxChars { - lines := countLines(content) - payload["content"] = content[:toolContentMaxChars] - ensurePreviewMeta(payload, "content", len(content), lines, true) + if len(content) > toolResultMaxBytes { + payload["content"] = truncateRuneSafe(content, toolResultMaxBytes) + setPreviewMeta(payload, "content", content) } case []any: for _, item := range content { @@ -119,26 +74,27 @@ func trimToolResultPayload(payload map[string]any) { if !ok { continue } - if text, ok := block["text"].(string); ok && len(text) > toolContentMaxChars { - lines := countLines(text) - block["text"] = text[:toolContentMaxChars] - ensurePreviewMeta(block, "text", len(text), lines, true) + if text, ok := block["text"].(string); ok && len(text) > toolResultMaxBytes { + block["text"] = truncateRuneSafe(text, toolResultMaxBytes) + setPreviewMeta(block, "text", text) } } } } -func trimStringFieldWithPreview(args map[string]any, field string, maxChars int) { - text, ok := args[field].(string) - if !ok || len(text) <= maxChars { - return +// truncateRuneSafe cuts s to at most maxBytes without splitting a UTF-8 rune. +func truncateRuneSafe(s string, maxBytes int) string { + if len(s) <= maxBytes { + return s } - lines := countLines(text) - args[field] = text[:maxChars] - ensurePreviewMeta(args, field, len(text), lines, true) + cut := maxBytes + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] } -func ensurePreviewMeta(container map[string]any, fieldName string, chars int, lines int, truncated bool) { +func setPreviewMeta(container map[string]any, fieldName string, original string) { const metaKey = "__liveagent_stream_preview" meta, _ := container[metaKey].(map[string]any) if meta == nil { @@ -151,9 +107,9 @@ func ensurePreviewMeta(container map[string]any, fieldName string, chars int, li meta["fields"] = fields } fields[fieldName] = map[string]any{ - "chars": chars, - "lines": lines, - "truncated": truncated, + "chars": utf8.RuneCountInString(original), + "lines": countLines(original), + "truncated": true, } } @@ -175,24 +131,6 @@ func countLines(s string) int { return n } -func firstString(candidates ...any) string { - for _, c := range candidates { - if s, ok := c.(string); ok && s != "" { - return s - } - } - return "" -} - -func firstMap(candidates ...any) map[string]any { - for _, c := range candidates { - if m, ok := c.(map[string]any); ok && len(m) > 0 { - return m - } - } - return nil -} - // EventTypeName maps the protobuf ChatEvent type enum to its wire name. func EventTypeName(eventType gatewayv1.ChatEvent_ChatEventType) string { switch eventType { diff --git a/crates/agent-gateway/internal/chatwire/payloads_test.go b/crates/agent-gateway/internal/chatwire/payloads_test.go index d83170bc..f2f17251 100644 --- a/crates/agent-gateway/internal/chatwire/payloads_test.go +++ b/crates/agent-gateway/internal/chatwire/payloads_test.go @@ -3,6 +3,7 @@ package chatwire import ( "strings" "testing" + "unicode/utf8" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) @@ -58,48 +59,83 @@ func TestEventPayloadPreservesToolCallDeltaType(t *testing.T) { } } -func TestTrimLargeToolContentTruncatesWriteArgs(t *testing.T) { - longContent := strings.Repeat("x", 500) + "\nline2" - payload := map[string]any{ - "type": "tool_call", - "name": "Write", - "arguments": map[string]any{ - "path": "src/app.ts", - "content": longContent, - }, - } - - TrimLargeToolContent(payload, "tool_call") +// Tool-call arguments must pass through untouched: the desktop app already +// truncated them and stamped the preview meta; the gateway rewriting either +// caused the chars regression this suite guards against. +func TestEventPayloadLeavesToolCallArgsUntouched(t *testing.T) { + longContent := strings.Repeat("x", 500) + payload := EventPayload(&gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_TOOL_CALL, + ConversationId: "conversation-1", + Data: `{"type":"tool_call_delta","id":"call-write","name":"Write","arguments":{"path":"src/app.ts","content":"` + + longContent + + `","__liveagent_stream_preview":{"v":2,"progress":6000,"fields":{"content":{"chars":6000,"lines":12,"truncated":true}}}},"round":1}`, + }, 9) - args := payload["arguments"].(map[string]any) - content := args["content"].(string) - if len(content) != 200 { - t.Fatalf("trimmed content length = %d, want 200", len(content)) + args, ok := payload["arguments"].(map[string]any) + if !ok { + t.Fatalf("expected arguments map, got %#v", payload["arguments"]) + } + if content := args["content"].(string); content != longContent { + t.Fatalf("content modified: len=%d, want %d", len(content), len(longContent)) } meta, ok := args["__liveagent_stream_preview"].(map[string]any) if !ok { - t.Fatalf("expected preview meta, got %#v", args) + t.Fatalf("expected producer preview meta preserved, got %#v", args) } fields := meta["fields"].(map[string]any) info := fields["content"].(map[string]any) - if info["chars"] != len(longContent) || info["truncated"] != true { - t.Fatalf("preview meta = %#v", info) + if chars, _ := info["chars"].(float64); chars != 6000 { + t.Fatalf("producer chars overwritten: %#v", info) + } + if progress, _ := meta["progress"].(float64); progress != 6000 { + t.Fatalf("producer progress overwritten: %#v", meta) } } -func TestTrimLargeToolContentTruncatesToolResult(t *testing.T) { +func TestTrimLargeToolResultContentTruncatesToolResult(t *testing.T) { longText := strings.Repeat("r", 300) payload := map[string]any{ "type": "tool_result", "content": longText, } - TrimLargeToolContent(payload, "tool_result") + TrimLargeToolResultContent(payload, "tool_result") if content := payload["content"].(string); len(content) != 200 { t.Fatalf("trimmed result length = %d, want 200", len(content)) } - if _, ok := payload["__liveagent_stream_preview"]; !ok { + meta, ok := payload["__liveagent_stream_preview"].(map[string]any) + if !ok { t.Fatalf("expected preview meta on tool_result payload") } + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if info["chars"] != 300 || info["truncated"] != true { + t.Fatalf("preview meta = %#v", info) + } +} + +func TestTrimLargeToolResultContentIsRuneSafe(t *testing.T) { + longText := strings.Repeat("汉", 100) // 300 bytes, 100 runes + payload := map[string]any{ + "type": "tool_result", + "content": longText, + } + + TrimLargeToolResultContent(payload, "tool_result") + + content := payload["content"].(string) + if !utf8.ValidString(content) { + t.Fatalf("truncated content is not valid UTF-8") + } + if len(content) > 200 { + t.Fatalf("trimmed result length = %d, want <= 200", len(content)) + } + meta := payload["__liveagent_stream_preview"].(map[string]any) + fields := meta["fields"].(map[string]any) + info := fields["content"].(map[string]any) + if info["chars"] != 100 { + t.Fatalf("chars should count runes, got %#v", info["chars"]) + } } diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go index a5c4cb2e..c333ef66 100644 --- a/crates/agent-gateway/internal/session/conversation_ingress.go +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -10,7 +10,7 @@ import ( // Ingress normalization: the three agent-facing gRPC payloads (ChatEvent, // ChatControlEvent, ChatRuntimeSnapshot) converge here into one append API on -// the conversation stream store. Payload shaping and tool-content trimming +// the conversation stream store. Payload shaping and tool-result trimming // happen exactly once, so every subscriber observes identical events. func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) { diff --git a/crates/agent-gateway/web/src/lib/chat/toolPreview.ts b/crates/agent-gateway/web/src/lib/chat/toolPreview.ts new file mode 100644 index 00000000..7240024c --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/toolPreview.ts @@ -0,0 +1,199 @@ +// Streaming preview protocol for file-writing tools (Write / Edit / +// NotebookEdit) — the single derivation shared by summaries, arg displays and +// the streaming preview cards. +// +// The desktop app is the sole producer: bridge events and runtime snapshots +// both carry field text truncated to a bounded preview plus a +// __liveagent_stream_preview meta block holding the true metrics. `progress` +// (sum of true field chars) is a monotonic revision — streamed arguments only +// grow — used to reject stale writers (late deltas, lagging snapshots) +// wherever tool args are merged. Args without meta (local live rounds, +// persisted history) fall back to raw string lengths, which at any stream +// position are >= the truncated preview's metrics, so the guard composes +// across representations. + +export const LIVE_TOOL_PREVIEW_META_KEY = "__liveagent_stream_preview"; + +export const FILE_TOOL_TEXT_FIELDS: Readonly> = { + Write: ["content"], + Edit: ["old_string", "new_string"], + NotebookEdit: ["new_source"], +}; + +export type PreviewFieldMetrics = { + chars: number; + lines: number; + truncated: boolean; +}; + +export type StreamPreviewMeta = { + v: 2; + progress: number; + fields: Record; +}; + +export function countTextLines(input: string) { + if (input.length === 0) return 0; + let lines = 1; + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + if (code === 13) { + lines += 1; + if (input.charCodeAt(index + 1) === 10) { + index += 1; + } + } else if (code === 10) { + lines += 1; + } + } + return lines; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function finiteNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +// Tolerant reader: accepts metas missing v/progress (older producers) — +// per-field chars still read, the progress guard just stays inert. +export function readStreamPreviewMeta( + args: Record, +): { progress?: number; fields: Record> } | null { + const meta = asRecord(args[LIVE_TOOL_PREVIEW_META_KEY]); + const rawFields = asRecord(meta.fields); + const fields: Record> = {}; + for (const [name, value] of Object.entries(rawFields)) { + const field = asRecord(value); + if (Object.keys(field).length === 0) continue; + fields[name] = { + chars: finiteNumber(field.chars), + lines: finiteNumber(field.lines), + truncated: typeof field.truncated === "boolean" ? field.truncated : undefined, + }; + } + if (Object.keys(fields).length === 0) return null; + return { progress: finiteNumber(meta.progress), fields }; +} + +// True char count of a preview field: producer meta when present, else the +// raw string length. +export function fileToolFieldChars(args: Record, field: string) { + const metaChars = readStreamPreviewMeta(args)?.fields[field]?.chars; + if (metaChars !== undefined) return metaChars; + const value = args[field]; + return typeof value === "string" ? value.length : undefined; +} + +// Monotonic progress of a tool call's args; undefined for untracked tools +// (merge behavior unchanged there). +export function toolArgsProgress(name: string, args: Record) { + const fields = FILE_TOOL_TEXT_FIELDS[name]; + if (!fields) return undefined; + const meta = readStreamPreviewMeta(args); + if (meta?.progress !== undefined) return meta.progress; + let progress = 0; + for (const field of fields) { + progress += fileToolFieldChars(args, field) ?? 0; + } + return progress; +} + +const FILE_TOOL_DISPLAY_MAX_CHARS = 4000; + +export type FileToolFieldPreview = { + has: boolean; + text: string; + chars: number; + lines: number; + truncated: boolean; +}; + +export type FileToolPreview = + | { + kind: "write"; + name: string; + path: string; + field: string; + content: FileToolFieldPreview; + } + | { + kind: "edit"; + name: string; + path: string; + oldString: FileToolFieldPreview; + newString: FileToolFieldPreview; + expectedReplacements?: number; + replaceAll: boolean; + }; + +function deriveFieldPreview(args: Record, field: string): FileToolFieldPreview { + const raw = args[field]; + const has = typeof raw === "string"; + const text = has ? (raw as string) : ""; + const meta = readStreamPreviewMeta(args)?.fields[field]; + if (meta) { + // Producer-truncated text: the meta carries the true metrics. + return { + has, + text, + chars: meta.chars ?? text.length, + lines: meta.lines ?? countTextLines(text), + truncated: meta.truncated ?? false, + }; + } + // Raw full args (local live rounds, persisted history): cap the display. + const truncated = text.length > FILE_TOOL_DISPLAY_MAX_CHARS; + return { + has, + text: truncated + ? `${text.slice(0, FILE_TOOL_DISPLAY_MAX_CHARS)}\n...[truncated ${ + text.length - FILE_TOOL_DISPLAY_MAX_CHARS + } chars]...` + : text, + chars: text.length, + lines: countTextLines(text), + truncated, + }; +} + +export function deriveFileToolPreview(toolCall: { + name: string; + arguments?: Record; +}): FileToolPreview | null { + const name = toolCall.name; + const fields = FILE_TOOL_TEXT_FIELDS[name]; + if (!fields) return null; + const args = toolCall.arguments || {}; + const path = + typeof args.path === "string" + ? args.path + : typeof args.notebook_path === "string" + ? args.notebook_path + : ""; + + if (name === "Edit") { + return { + kind: "edit", + name, + path, + oldString: deriveFieldPreview(args, "old_string"), + newString: deriveFieldPreview(args, "new_string"), + expectedReplacements: + typeof args.expected_replacements === "number" ? args.expected_replacements : undefined, + replaceAll: args.replace_all === true, + }; + } + const field = fields[0]; + return { + kind: "write", + name, + path, + field, + content: deriveFieldPreview(args, field), + }; +} diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts index 7606ca2f..c4733ca4 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts @@ -257,8 +257,15 @@ export function createTranscriptStore(): TranscriptStore { snapshot = buildSnapshot(); emit(); }; + // While a batch is open (applySync), schedule() only marks dirty: the + // snapshot rebuild and the event replay must land as ONE commit, never an + // intermediate frame at the (older) snapshot state. + let batchDepth = 0; const schedule = (flush?: boolean) => { dirty = true; + if (batchDepth > 0) { + return; + } if (flush) { if (rafId !== null) { cancelAnimationFrame(rafId); @@ -610,6 +617,76 @@ export function createTranscriptStore(): TranscriptStore { } } + const applySyncLocked = (result: ConversationSubscribeResult) => { + if (result.reset) { + // Seq continuity broke (gateway restart / buffer gap). Folded and + // settled turns hold finished content with stable ids — fold, never + // drop. A streaming turn's content is rebuilt from the snapshot and + // replay, but the turn object (and its user bubble) survives, so the + // active exchange never remounts. Pending turns — optimistic echoes + // whose run hasn't started — survive untouched and bind normally + // when their seed replays. + lastSeq = 0; + let changed = false; + turns = turns.map((turn) => { + if (turn.phase === "settled" && !turn.folded) { + changed = true; + return { ...turn, folded: true }; + } + if (turn.phase === "streaming") { + changed = true; + if (result.activity && result.activity.runId === turn.runId) { + // Still running server-side: the snapshot/replay rebuilds the + // content into this same turn object. When the incoming snapshot + // targets this run, keep the delta-built entries so the rebuild + // can compare per-tool-call progress instead of starting blind. + return result.snapshot?.runId === turn.runId ? turn : { ...turn, entries: [] }; + } + // The run ended while this client was away and the replay may + // not cover it any more: settle the turn (never strand it as a + // pending zombie) so the idle enrich can adopt its persisted + // reply from history. + return { ...turn, entries: [], phase: "settled" as const }; + } + return turn; + }); + if (changed) { + foldRevision += 1; + } + toolStatus = null; + toolStatusIsCompaction = false; + // Set the activity before the rebuild so the snapshot can target the + // optimistic pending turn by client_request_id (its user bubble then + // keeps its identity instead of a duplicate run turn appearing). + activeRun = result.activity; + if (result.snapshot) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } else { + activeRun = result.activity; + if (result.snapshot) { + // Late join mid-run where the buffer cannot cover the run start. + // The snapshot folds every event through asOfSeq into its entries; + // advancing the cursor drops the overlapping replay below. + const existing = findTurnByRunId(result.snapshot.runId); + if (!existing || existing.entries.length === 0) { + rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); + lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); + } + } + } + if (result.activity) { + setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); + } else if (result.reset) { + setToolStatus(null, false); + } + for (const event of result.events) { + applyOne(event); + } + lastSeq = Math.max(lastSeq, result.latestSeq); + }; + return { getSnapshot: () => snapshot, subscribe: (listener) => { @@ -619,72 +696,16 @@ export function createTranscriptStore(): TranscriptStore { }; }, + // The rebuild + replay of a (re)subscribe commits as one frame: an + // intermediate render at the snapshot's older state is exactly the + // backwards flicker the progress guards exist to prevent. applySync: (result) => { - if (result.reset) { - // Seq continuity broke (gateway restart / buffer gap). Folded and - // settled turns hold finished content with stable ids — fold, never - // drop. A streaming turn's content is rebuilt from the snapshot and - // replay, but the turn object (and its user bubble) survives, so the - // active exchange never remounts. Pending turns — optimistic echoes - // whose run hasn't started — survive untouched and bind normally - // when their seed replays. - lastSeq = 0; - let changed = false; - turns = turns.map((turn) => { - if (turn.phase === "settled" && !turn.folded) { - changed = true; - return { ...turn, folded: true }; - } - if (turn.phase === "streaming") { - changed = true; - if (result.activity && result.activity.runId === turn.runId) { - // Still running server-side: the snapshot/replay rebuilds the - // content into this same turn object. - return { ...turn, entries: [] }; - } - // The run ended while this client was away and the replay may - // not cover it any more: settle the turn (never strand it as a - // pending zombie) so the idle enrich can adopt its persisted - // reply from history. - return { ...turn, entries: [], phase: "settled" as const }; - } - return turn; - }); - if (changed) { - foldRevision += 1; - } - toolStatus = null; - toolStatusIsCompaction = false; - // Set the activity before the rebuild so the snapshot can target the - // optimistic pending turn by client_request_id (its user bubble then - // keeps its identity instead of a duplicate run turn appearing). - activeRun = result.activity; - if (result.snapshot) { - rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); - lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); - } - } else { - activeRun = result.activity; - if (result.snapshot) { - // Late join mid-run where the buffer cannot cover the run start. - // The snapshot folds every event through asOfSeq into its entries; - // advancing the cursor drops the overlapping replay below. - const existing = findTurnByRunId(result.snapshot.runId); - if (!existing || existing.entries.length === 0) { - rebuildActiveTurnFromSnapshot(result.snapshot.entriesJson, result.snapshot.runId); - lastSeq = Math.max(lastSeq, result.snapshot.asOfSeq); - } - } - } - if (result.activity) { - setToolStatus(result.activity.toolStatus, result.activity.toolStatusIsCompaction); - } else if (result.reset) { - setToolStatus(null, false); - } - for (const event of result.events) { - applyOne(event); + batchDepth += 1; + try { + applySyncLocked(result); + } finally { + batchDepth -= 1; } - lastSeq = Math.max(lastSeq, result.latestSeq); schedule(true); }, diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts index 734282d9..0d645cc1 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/turnReducer.ts @@ -4,6 +4,7 @@ import { mergeHostedSearchBlocks, normalizeHostedSearchBlock, } from "@/lib/chat/hostedSearch"; +import { toolArgsProgress } from "@/lib/chat/toolPreview"; import { summarizeToolCall } from "@/lib/chat/uiMessages"; import { buildAssistantMeta, @@ -189,8 +190,21 @@ function mergeSegmentToolCallArguments( if (incomingId !== "") { matches = entry.toolCall.id === incomingId; - canUpdate = - matches && hasIncomingArgs && safeStringify(existingArgs) !== safeStringify(incomingArgs); + if (matches && hasIncomingArgs) { + // Monotonic guard for file tools: streamed args only grow, so a + // lower-progress writer (late delta replay, lagging snapshot echo) + // must never roll the entry back. + const incomingProgress = toolArgsProgress( + incomingName || entry.toolCall.name, + incomingArgs, + ); + const existingProgress = toolArgsProgress(entry.toolCall.name, existingArgs); + const regressed = + incomingProgress !== undefined && + existingProgress !== undefined && + incomingProgress < existingProgress; + canUpdate = !regressed && safeStringify(existingArgs) !== safeStringify(incomingArgs); + } } else if (incomingName !== "" && entry.toolCall.name === incomingName) { const sameArguments = safeStringify(existingArgs) === safeStringify(incomingArgs); matches = sameArguments || (!hasExistingArgs && hasIncomingArgs); @@ -630,6 +644,15 @@ export function applyEventToTurn(turn: Turn, event: ChatEvent): Turn { // already-present user bubble keep their identity, so nothing remounts. export function rebuildTurnFromSnapshot(turn: Turn, parsed: ChatEntry[]): Turn { const ns = turnNamespace(turn); + // Index the delta-built tool calls: the snapshot's content is debounced + // producer state and can lag the live delta stream, so a rebuild must never + // roll a tool call's args back to a lower progress. + const existingToolCalls = new Map(); + for (const entry of turn.entries) { + if (entry.kind === "tool_call" && entry.toolCall.id) { + existingToolCalls.set(entry.toolCall.id, entry); + } + } let user = turn.user; const entries: ChatEntry[] = []; for (const entry of parsed) { @@ -642,7 +665,28 @@ export function rebuildTurnFromSnapshot(turn: Turn, parsed: ChatEntry[]): Turn { // Snapshot entries carry runtime-assigned ids that are stable per run; // prefixing with the turn namespace keeps them from colliding with other // runs while staying identical across repeated snapshots. - entries.push({ ...entry, id: `${ns}:s:${entry.id}` } as ChatEntry); + let merged = entry; + if (entry.kind === "tool_call") { + const prev = existingToolCalls.get(entry.toolCall.id); + if (prev) { + const prevProgress = toolArgsProgress( + prev.toolCall.name, + normalizeToolArguments(prev.toolCall.arguments), + ); + const snapshotProgress = toolArgsProgress( + entry.toolCall.name, + normalizeToolArguments(entry.toolCall.arguments), + ); + if ( + prevProgress !== undefined && + snapshotProgress !== undefined && + snapshotProgress < prevProgress + ) { + merged = { ...entry, toolCall: prev.toolCall, summary: prev.summary, text: prev.text }; + } + } + } + entries.push({ ...merged, id: `${ns}:s:${entry.id}` } as ChatEntry); } if (user === turn.user && entries.length === 0 && turn.entries.length === 0) { return turn; diff --git a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts index c6cfa2c7..068d632b 100644 --- a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts +++ b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts @@ -24,6 +24,7 @@ import { splitTextAroundHostedSearch, type HostedSearchBlock, } from "./hostedSearch"; +import { fileToolFieldChars, LIVE_TOOL_PREVIEW_META_KEY } from "./toolPreview"; const MIN_BASH_TIMEOUT_MS = 1_000; const GLOBAL_BASH_MAX_TIMEOUT_MS = 600_000; @@ -332,9 +333,7 @@ export function summarizeToolCall( path ? `path=${path}` : null, "mode=rewrite", typeof args.content === "string" - ? `contentChars=${ - streamingPreviewFieldChars(args, "content") ?? args.content.length - }` + ? `contentChars=${fileToolFieldChars(args, "content")}` : null, ] : name === "Edit" @@ -346,14 +345,10 @@ export function summarizeToolCall( : null, args.replace_all === true ? "replaceAll=true" : null, typeof args.old_string === "string" - ? `oldChars=${ - streamingPreviewFieldChars(args, "old_string") ?? args.old_string.length - }` + ? `oldChars=${fileToolFieldChars(args, "old_string")}` : null, typeof args.new_string === "string" - ? `newChars=${ - streamingPreviewFieldChars(args, "new_string") ?? args.new_string.length - }` + ? `newChars=${fileToolFieldChars(args, "new_string")}` : null, ] : name === "List" @@ -466,10 +461,7 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { ...displayFileToolRootEntry(args.root), path: args.path, mode: "rewrite", - contentChars: - typeof args.content === "string" - ? (streamingPreviewFieldChars(args, "content") ?? args.content.length) - : undefined, + contentChars: fileToolFieldChars(args, "content"), }; case "Edit": return { @@ -477,14 +469,8 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { path: args.path, expected_replacements: args.expected_replacements, replace_all: args.replace_all, - oldChars: - typeof args.old_string === "string" - ? (streamingPreviewFieldChars(args, "old_string") ?? args.old_string.length) - : undefined, - newChars: - typeof args.new_string === "string" - ? (streamingPreviewFieldChars(args, "new_string") ?? args.new_string.length) - : undefined, + oldChars: fileToolFieldChars(args, "old_string"), + newChars: fileToolFieldChars(args, "new_string"), }; case "Image": { const out: Record = {}; @@ -518,6 +504,7 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { default: { const out: Record = {}; for (const [key, value] of Object.entries(args)) { + if (key === LIVE_TOOL_PREVIEW_META_KEY) continue; if (typeof value === "string" && value.length > 800) { out[key] = `${value.slice(0, 800)}...(len=${value.length})`; } else { @@ -589,116 +576,6 @@ export function previewText(input: string, maxChars = 1200) { return `${text.slice(0, maxChars)}\n...(已截断预览,len=${text.length})...`; } -export const LIVE_TOOL_PREVIEW_META_KEY = "__liveagent_stream_preview"; - -type StreamingPreviewFieldMetrics = { - chars?: number; - lines?: number; - truncated?: boolean; -}; - -function asPlainRecord(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function finiteNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readStreamingPreviewFieldMetrics( - args: Record, - fieldName: string, -): StreamingPreviewFieldMetrics | undefined { - const metadata = asPlainRecord(args[LIVE_TOOL_PREVIEW_META_KEY]); - const fields = asPlainRecord(metadata.fields); - const field = asPlainRecord(fields[fieldName]); - if (Object.keys(field).length === 0) return undefined; - return { - chars: finiteNumber(field.chars), - lines: finiteNumber(field.lines), - truncated: typeof field.truncated === "boolean" ? field.truncated : undefined, - }; -} - -function streamingPreviewFieldChars(args: Record, fieldName: string) { - return readStreamingPreviewFieldMetrics(args, fieldName)?.chars; -} - -export function countTextLines(input: string) { - if (input.length === 0) return 0; - let lines = 1; - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - if (code === 13) { - lines += 1; - if (input.charCodeAt(index + 1) === 10) { - index += 1; - } - } else if (code === 10) { - lines += 1; - } - } - return lines; -} - -export function buildStreamingToolTextPreview( - input: string, - maxChars = 4000, - metrics?: StreamingPreviewFieldMetrics, -) { - const text = input || ""; - const renderedText = previewText(text, maxChars); - return { - text: renderedText, - chars: metrics?.chars ?? text.length, - lines: metrics?.lines ?? countTextLines(text), - truncated: metrics?.truncated ?? text.length > maxChars, - }; -} - -export function getStreamingWriteToolPreview(toolCall: { - name: string; - arguments?: Record; -}) { - if (toolCall.name !== "Write") return null; - const args = toolCall.arguments || {}; - const hasContent = typeof args.content === "string"; - const content = hasContent ? (args.content as string) : ""; - const contentMetrics = readStreamingPreviewFieldMetrics(args, "content"); - return { - path: typeof args.path === "string" ? (args.path as string) : "", - mode: "rewrite" as const, - hasContent, - content: buildStreamingToolTextPreview(content, 4000, contentMetrics), - }; -} - -export function getStreamingEditToolPreview(toolCall: { - name: string; - arguments?: Record; -}) { - if (toolCall.name !== "Edit") return null; - const args = toolCall.arguments || {}; - const hasOldString = typeof args.old_string === "string"; - const hasNewString = typeof args.new_string === "string"; - const oldString = hasOldString ? (args.old_string as string) : ""; - const newString = hasNewString ? (args.new_string as string) : ""; - const oldStringMetrics = readStreamingPreviewFieldMetrics(args, "old_string"); - const newStringMetrics = readStreamingPreviewFieldMetrics(args, "new_string"); - return { - path: typeof args.path === "string" ? (args.path as string) : "", - hasOldString, - hasNewString, - oldString: buildStreamingToolTextPreview(oldString, 4000, oldStringMetrics), - newString: buildStreamingToolTextPreview(newString, 4000, newStringMetrics), - expectedReplacements: - typeof args.expected_replacements === "number" ? args.expected_replacements : undefined, - replaceAll: args.replace_all === true, - }; -} - function appendTextLikeBlock( blocks: UiRoundContentBlock[], kind: "thinking" | "text", diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index 10e1be17..ac9f694e 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -1,5 +1,3 @@ -import { generateDiffFile } from "@git-diff-view/file"; -import { DiffModeEnum, DiffView } from "@git-diff-view/react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import type { ImageContent, ToolResultMessage, Usage } from "../../lib/agentTypes"; import { @@ -30,8 +28,6 @@ import { ImagePreview, type ImagePreviewSlide } from "../../components/chat/Imag import { useLocale } from "../../i18n"; import { cn } from "../../lib/shared/utils"; import { - getStreamingEditToolPreview, - getStreamingWriteToolPreview, previewText, safeStringify, shouldDisplayToolTraceItem, @@ -41,6 +37,20 @@ import { type ToolTraceItem, type UiRound, } from "../../lib/chat/uiMessages"; +import { deriveFileToolPreview, FILE_TOOL_TEXT_FIELDS } from "../../lib/chat/toolPreview"; +import { EditDiffView } from "./EditDiffView"; +import { FileToolArgsDisplay } from "./FileToolArgs"; +import { + fileRootTags, + MetaTags, + PathDisplay, + ToolFactGrid, + ToolScrollablePre, + ToolSection, + ToolSurface, + ToolSurfaceLabel, + type MetaTag, +} from "./ToolSurfaces"; import type { HostedSearchBlock } from "../../lib/chat/hostedSearch"; import { normalizeLiveToolStatus, VIBING_STATUS } from "../../lib/chat/chatPageHelpers"; import { prepareImageProxyUrl } from "../../lib/providers/proxy"; @@ -63,8 +73,6 @@ import type { SubagentMessageResultDetails, WriteResultDetails, } from "../../lib/tools/builtinTypes"; -import "@git-diff-view/react/styles/diff-view.css"; - export function AssistantAvatar(props: { className?: string }) { const { className } = props; return ( @@ -664,8 +672,6 @@ function getToolMeta(name: string): { Icon: IconComponent; accent: string; categ } } -type MetaTag = { label: string; value: string }; - function displayString(value: unknown) { return typeof value === "string" ? value.trim() : ""; } @@ -990,64 +996,6 @@ function getDominantToolName(items: ToolTraceItem[]) { return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "Tool"; } -function ToolSection(props: { - label: string; - trailing?: ReactNode; - children: ReactNode; -}) { - const { label, trailing, children } = props; - return ( -
-
- - {label} - -
- {trailing} -
- {children} -
- ); -} - -function ToolSurface(props: { children: ReactNode; className?: string }) { - const { children, className } = props; - return ( -
- {children} -
- ); -} - -function ToolSurfaceLabel({ label }: { label: string }) { - return ( -
- {label} -
- ); -} - -function ToolFactGrid({ tags }: { tags: MetaTag[] }) { - if (tags.length === 0) return null; - return ( -
- {tags.map((tag) => ( - - -
- {tag.value} -
-
- ))} -
- ); -} - function buildPagedResultTags(params: { label: string; returned: number; @@ -1063,44 +1011,6 @@ function buildPagedResultTags(params: { ]; } -function fileRootTags(root?: string | null): MetaTag[] { - return root && root !== "workspace" ? [{ label: "root", value: root }] : []; -} - -/** Render path with dir dimmed and filename highlighted */ -function PathDisplay({ path, className }: { path: string; className?: string }) { - const lastSlash = path.lastIndexOf("/"); - if (lastSlash < 0) { - return ( - - {path} - - ); - } - const dir = path.slice(0, lastSlash + 1); - const file = path.slice(lastSlash + 1); - return ( - - - {dir.length > 50 ? `…${dir.slice(-50)}` : dir} - - {file} - - ); -} - /** Extract tool-specific display info */ function getToolDisplay(toolCall: { name: string; arguments?: Record }) { const args = toolCall.arguments || {}; @@ -1142,16 +1052,6 @@ function getToolDisplay(toolCall: { name: string; arguments?: Record(); - return ( -
- {tags.map((tag) => { - const seenCount = labelCounts.get(tag.label) ?? 0; - labelCounts.set(tag.label, seenCount + 1); - const stableKey = seenCount === 0 ? tag.label : `${tag.label}-${seenCount}`; - return ( - - - {tag.label} - - - {tag.value} - - ); - })} -
- ); -} - -function StreamingArgPlaceholder({ label }: { label: string }) { - return ( - -
{label}
-
- ); -} - -function StreamingTextPreviewSurface({ - label, - hasValue, - emptyLabel, - preview, -}: { - label: string; - hasValue: boolean; - emptyLabel: string; - preview: { text: string; chars: number; lines: number; truncated: boolean }; -}) { - return ( - -
- -
- {hasValue ? ( - preview.text ? ( - - {preview.text} - - ) : ( -
- {emptyLabel} -
- ) - ) : ( -
- Waiting for {label}... -
- )} -
- ); -} - /** Expanded args display — tool-aware layout */ function ToolArgsDisplay({ item }: { item: ToolTraceItem }) { const toolCall = item.toolCall; - const display = getToolDisplay(toolCall); - const writePreview = getStreamingWriteToolPreview(toolCall); - if (writePreview) { - const hasAnyArg = Boolean(writePreview.path || writePreview.hasContent); - if (!hasAnyArg) { - return ; - } - return ( -
- {writePreview.path ? ( - - - - - ) : null} - {writePreview.hasContent ? ( - - ) : null} - -
- ); + const filePreview = deriveFileToolPreview(toolCall); + if (filePreview) { + return ; } - const editPreview = getStreamingEditToolPreview(toolCall); - if (editPreview) { - const hasAnyArg = - Boolean(editPreview.path) || editPreview.hasOldString || editPreview.hasNewString; - if (!hasAnyArg) { - return ; - } - return ( -
- {editPreview.path ? ( - - - - - ) : null} - - {editPreview.hasOldString && editPreview.hasNewString ? ( - - ) : ( - <> - - - - )} -
- ); - } + const display = getToolDisplay(toolCall); if (isDelegateAgentCardToolCall(toolCall)) { const args = toolCall.arguments || {}; @@ -2059,20 +1794,6 @@ function extractReadBody(text: string) { return marker >= 0 ? text.slice(marker + 2) : text; } -function ToolScrollablePre(props: { children: ReactNode; className?: string }) { - const { children, className } = props; - return ( -
-      {children}
-    
- ); -} - function CodePreview(props: { text: string; maxChars?: number }) { const { text, maxChars = 4000 } = props; if (!/\S/.test(text)) return null; @@ -2083,97 +1804,6 @@ function CodePreview(props: { text: string; maxChars?: number }) { ); } -function guessLangFromPath(filePath?: string): string { - if (!filePath) return "txt"; - const ext = filePath.split(".").pop()?.toLowerCase(); - const map: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - py: "python", - rs: "rust", - go: "go", - java: "java", - kt: "kotlin", - rb: "ruby", - swift: "swift", - c: "c", - cpp: "cpp", - h: "c", - hpp: "cpp", - cs: "csharp", - css: "css", - scss: "scss", - html: "html", - vue: "vue", - json: "json", - yaml: "yaml", - yml: "yaml", - toml: "toml", - xml: "xml", - md: "markdown", - sql: "sql", - sh: "bash", - zsh: "bash", - bash: "bash", - dockerfile: "dockerfile", - lua: "lua", - php: "php", - dart: "dart", - }; - return (ext && map[ext]) || "txt"; -} - -function useIsDark() { - const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark")); - useEffect(() => { - const observer = new MutationObserver(() => { - setIsDark(document.documentElement.classList.contains("dark")); - }); - observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); - return () => observer.disconnect(); - }, []); - return isDark; -} - -function EditDiffView(props: { beforeText: string; afterText: string; filePath?: string }) { - const { beforeText, afterText, filePath } = props; - const isDark = useIsDark(); - const lang = guessLangFromPath(filePath); - - const diffFile = useMemo(() => { - if (!beforeText && !afterText) return undefined; - const instance = generateDiffFile( - filePath ?? "old", - beforeText, - filePath ?? "new", - afterText, - lang, - lang, - ); - instance.init(); - instance.buildSplitDiffLines(); - return instance; - }, [beforeText, afterText, filePath, lang]); - - if (!diffFile) return null; - - return ( -
- -
- ); -} - function ToolResultDisplay({ item, result, @@ -2779,8 +2409,7 @@ function ToolCallItem({ const [open, setOpen] = useState(readOnly || isRedactedToolContent ? false : shouldAutoOpen); const isDelegateAgentCard = isDelegateAgentCardToolCall(item.toolCall); const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0; - const isStreamingFilePreviewTool = - item.toolCall.name === "Write" || item.toolCall.name === "Edit"; + const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined; const shouldShowArgs = !isRedactedToolContent && (!isDelegateAgentCard || !result) && diff --git a/crates/agent-gateway/web/src/pages/chat/EditDiffView.tsx b/crates/agent-gateway/web/src/pages/chat/EditDiffView.tsx new file mode 100644 index 00000000..1f31f8eb --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/EditDiffView.tsx @@ -0,0 +1,95 @@ +import { generateDiffFile } from "@git-diff-view/file"; +import { DiffModeEnum, DiffView } from "@git-diff-view/react"; +import { useEffect, useMemo, useState } from "react"; +import "@git-diff-view/react/styles/diff-view.css"; + +function guessLangFromPath(filePath?: string): string { + if (!filePath) return "txt"; + const ext = filePath.split(".").pop()?.toLowerCase(); + const map: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + rb: "ruby", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + cs: "csharp", + css: "css", + scss: "scss", + html: "html", + vue: "vue", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + xml: "xml", + md: "markdown", + sql: "sql", + sh: "bash", + zsh: "bash", + bash: "bash", + dockerfile: "dockerfile", + lua: "lua", + php: "php", + dart: "dart", + }; + return (ext && map[ext]) || "txt"; +} + +function useIsDark() { + const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark")); + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.classList.contains("dark")); + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + return isDark; +} + +export function EditDiffView(props: { beforeText: string; afterText: string; filePath?: string }) { + const { beforeText, afterText, filePath } = props; + const isDark = useIsDark(); + const lang = guessLangFromPath(filePath); + + const diffFile = useMemo(() => { + if (!beforeText && !afterText) return undefined; + const instance = generateDiffFile( + filePath ?? "old", + beforeText, + filePath ?? "new", + afterText, + lang, + lang, + ); + instance.init(); + instance.buildSplitDiffLines(); + return instance; + }, [beforeText, afterText, filePath, lang]); + + if (!diffFile) return null; + + return ( +
+ +
+ ); +} diff --git a/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx b/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx new file mode 100644 index 00000000..928311a2 --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx @@ -0,0 +1,144 @@ +import type { FileToolFieldPreview, FileToolPreview } from "../../lib/chat/toolPreview"; +import { EditDiffView } from "./EditDiffView"; +import { + MetaTags, + PathDisplay, + ToolScrollablePre, + ToolSurface, + ToolSurfaceLabel, +} from "./ToolSurfaces"; + +// Streaming args display for the file-writing tools (Write / Edit / +// NotebookEdit): live-updating path, true char/line counts and a bounded +// content preview, all derived once by deriveFileToolPreview. + +function StreamingArgPlaceholder({ label }: { label: string }) { + return ( + +
{label}
+
+ ); +} + +function StreamingTextPreviewSurface({ + label, + emptyLabel, + preview, +}: { + label: string; + emptyLabel: string; + preview: FileToolFieldPreview; +}) { + return ( + +
+ +
+ {preview.has ? ( + preview.text ? ( + + {preview.text} + + ) : ( +
+ {emptyLabel} +
+ ) + ) : ( +
+ Waiting for {label}... +
+ )} +
+ ); +} + +function PathSurface({ path }: { path: string }) { + return ( + + + + + ); +} + +export function FileToolArgsDisplay({ preview }: { preview: FileToolPreview }) { + if (preview.kind === "write") { + if (!preview.path && !preview.content.has) { + return ; + } + const fieldLabel = preview.field === "new_source" ? "new source" : "content"; + return ( +
+ {preview.path ? : null} + {preview.content.has ? ( + + ) : null} + +
+ ); + } + + if (!preview.path && !preview.oldString.has && !preview.newString.has) { + return ; + } + return ( +
+ {preview.path ? : null} + + {preview.oldString.has && preview.newString.has ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/crates/agent-gateway/web/src/pages/chat/ToolSurfaces.tsx b/crates/agent-gateway/web/src/pages/chat/ToolSurfaces.tsx new file mode 100644 index 00000000..ac79e6ef --- /dev/null +++ b/crates/agent-gateway/web/src/pages/chat/ToolSurfaces.tsx @@ -0,0 +1,141 @@ +import type { ReactNode } from "react"; + +import { cn } from "../../lib/shared/utils"; + +// Presentational primitives shared by the tool cards (args displays, result +// displays, streaming previews). + +export type MetaTag = { label: string; value: string }; + +export function ToolSection(props: { label: string; trailing?: ReactNode; children: ReactNode }) { + const { label, trailing, children } = props; + return ( +
+
+ + {label} + +
+ {trailing} +
+ {children} +
+ ); +} + +export function ToolSurface(props: { children: ReactNode; className?: string }) { + const { children, className } = props; + return ( +
+ {children} +
+ ); +} + +export function ToolSurfaceLabel({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +export function ToolFactGrid({ tags }: { tags: MetaTag[] }) { + if (tags.length === 0) return null; + return ( +
+ {tags.map((tag) => ( + + +
+ {tag.value} +
+
+ ))} +
+ ); +} + +export function fileRootTags(root?: string | null): MetaTag[] { + return root && root !== "workspace" ? [{ label: "root", value: root }] : []; +} + +/** Render path with dir dimmed and filename highlighted */ +export function PathDisplay({ path, className }: { path: string; className?: string }) { + const lastSlash = path.lastIndexOf("/"); + if (lastSlash < 0) { + return ( + + {path} + + ); + } + const dir = path.slice(0, lastSlash + 1); + const file = path.slice(lastSlash + 1); + return ( + + + {dir.length > 50 ? `…${dir.slice(-50)}` : dir} + + {file} + + ); +} + +/** Inline meta tags */ +export function MetaTags({ tags }: { tags: MetaTag[] }) { + if (tags.length === 0) return null; + const labelCounts = new Map(); + return ( +
+ {tags.map((tag) => { + const seenCount = labelCounts.get(tag.label) ?? 0; + labelCounts.set(tag.label, seenCount + 1); + const stableKey = seenCount === 0 ? tag.label : `${tag.label}-${seenCount}`; + return ( + + + {tag.label} + + + {tag.value} + + ); + })} +
+ ); +} + +export function ToolScrollablePre(props: { children: ReactNode; className?: string }) { + const { children, className } = props; + return ( +
+      {children}
+    
+ ); +} diff --git a/crates/agent-gateway/web/test/chatUi-agent.test.mjs b/crates/agent-gateway/web/test/chatUi-agent.test.mjs index 6def4b8f..b2703ef2 100644 --- a/crates/agent-gateway/web/test/chatUi-agent.test.mjs +++ b/crates/agent-gateway/web/test/chatUi-agent.test.mjs @@ -7,6 +7,7 @@ import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; const rootDir = fileURLToPath(new URL("../", import.meta.url)); const uiMessagesLoader = createWebModuleLoader({ rootDir }); const uiMessages = uiMessagesLoader.loadModule("src/lib/chat/uiMessages.ts"); +const toolPreview = uiMessagesLoader.loadModule("src/lib/chat/toolPreview.ts"); const hostedSearch = uiMessagesLoader.loadModule("src/lib/chat/hostedSearch.ts"); const uploadedImagePreview = uiMessagesLoader.loadModule("src/lib/chat/uploadedImagePreview.ts"); const loader = createWebModuleLoader({ @@ -35,7 +36,7 @@ const loader = createWebModuleLoader({ }, }); -const { createTurn, applyEventToTurn } = loader.loadModule( +const { createTurn, applyEventToTurn, rebuildTurnFromSnapshot } = loader.loadModule( "src/lib/chat/transcript/turnReducer.ts", ); const { buildRowsFromEntries } = loader.loadModule("src/lib/chat/transcript/rows.ts"); @@ -764,20 +765,19 @@ test("applyEventToTurn merges streamed Write deltas with final call and result", }); test("applyEventToTurn preserves streaming preview metadata for Write metrics", () => { - const metadataKey = uiMessages.LIVE_TOOL_PREVIEW_META_KEY; + const metadataKey = toolPreview.LIVE_TOOL_PREVIEW_META_KEY; const previewContent = "head\n...[truncated 9000 chars]...\ntail"; const previewArgs = { path: "src/large.ts", content: previewContent, [metadataKey]: { - version: 1, + v: 2, + progress: 12000, fields: { content: { chars: 12000, lines: 800, - previewChars: previewContent.length, truncated: true, - strategy: "head-tail", }, }, }, @@ -794,7 +794,7 @@ test("applyEventToTurn preserves streaming preview metadata for Write metrics", assert.equal(turn.entries.length, 1); assert.equal(turn.entries[0].toolCall.arguments.content, previewContent); - const deltaPreview = uiMessages.getStreamingWriteToolPreview(turn.entries[0].toolCall); + const deltaPreview = toolPreview.deriveFileToolPreview(turn.entries[0].toolCall); assert.equal(deltaPreview.content.chars, 12000); assert.equal(deltaPreview.content.lines, 800); assert.equal(deltaPreview.content.truncated, true); @@ -808,12 +808,115 @@ test("applyEventToTurn preserves streaming preview metadata for Write metrics", }); assert.equal(turn.entries.length, 1); - const finalPreview = uiMessages.getStreamingWriteToolPreview(turn.entries[0].toolCall); + const finalPreview = toolPreview.deriveFileToolPreview(turn.entries[0].toolCall); assert.equal(finalPreview.content.text, previewContent); assert.equal(finalPreview.content.chars, 12000); assert.equal(finalPreview.content.lines, 800); }); +function writePreviewArgs(chars, content) { + return { + path: "src/large.ts", + content, + [toolPreview.LIVE_TOOL_PREVIEW_META_KEY]: { + v: 2, + progress: chars, + fields: { content: { chars, lines: 1, truncated: true } }, + }, + }; +} + +test("applyEventToTurn never rolls a streaming Write back to lower progress", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { + type: "tool_call_delta", + id: "call-write", + name: "Write", + arguments: writePreviewArgs(6000, "newer preview"), + round: 1, + }); + + // A stale writer (late delta replay / lagging snapshot echo) must lose. + turn = applyEventToTurn(turn, { + type: "tool_call_delta", + id: "call-write", + name: "Write", + arguments: writePreviewArgs(4000, "older preview"), + round: 1, + }); + assert.equal(turn.entries.length, 1); + assert.equal( + toolPreview.deriveFileToolPreview(turn.entries[0].toolCall).content.chars, + 6000, + ); + + // A newer writer still advances. + turn = applyEventToTurn(turn, { + type: "tool_call_delta", + id: "call-write", + name: "Write", + arguments: writePreviewArgs(7000, "newest preview"), + round: 1, + }); + assert.equal( + toolPreview.deriveFileToolPreview(turn.entries[0].toolCall).content.chars, + 7000, + ); +}); + +test("rebuildTurnFromSnapshot keeps newer delta-built tool args over a lagging snapshot", () => { + let turn = newTurn(); + turn = applyEventToTurn(turn, { + type: "tool_call_delta", + id: "call-write", + name: "Write", + arguments: writePreviewArgs(6000, "delta preview"), + round: 1, + }); + + const rebuilt = rebuildTurnFromSnapshot(turn, [ + { + id: "runtime-live-0-tool-call-1-call-write-0", + kind: "tool_call", + round: 1, + toolCall: { + type: "toolCall", + id: "call-write", + name: "Write", + // Raw snapshot content at an earlier stream position (no meta): + // progress falls back to the raw length and must not win. + arguments: { path: "src/large.ts", content: "x".repeat(4500) }, + }, + summary: "Write", + text: "{}", + }, + ]); + + const entry = rebuilt.entries.find((candidate) => candidate.kind === "tool_call"); + assert.ok(entry, "expected the snapshot tool_call entry"); + assert.equal(toolPreview.deriveFileToolPreview(entry.toolCall).content.chars, 6000); + assert.equal(entry.toolCall.arguments.content, "delta preview"); + + // A snapshot that is ahead of the deltas replaces the args. + const advanced = rebuildTurnFromSnapshot(turn, [ + { + id: "runtime-live-0-tool-call-1-call-write-0", + kind: "tool_call", + round: 1, + toolCall: { + type: "toolCall", + id: "call-write", + name: "Write", + arguments: { path: "src/large.ts", content: "y".repeat(8000) }, + }, + summary: "Write", + text: "{}", + }, + ]); + const advancedEntry = advanced.entries.find((candidate) => candidate.kind === "tool_call"); + assert.equal(toolPreview.deriveFileToolPreview(advancedEntry.toolCall).content.chars, 8000); +}); + test("applyEventToTurn snapshots mutable streamed Write arguments", () => { const args = { path: "src/app.ts", content: "first" }; let turn = newTurn(); diff --git a/crates/agent-gui/src/lib/chat/messages/toolPreview.ts b/crates/agent-gui/src/lib/chat/messages/toolPreview.ts new file mode 100644 index 00000000..7240024c --- /dev/null +++ b/crates/agent-gui/src/lib/chat/messages/toolPreview.ts @@ -0,0 +1,199 @@ +// Streaming preview protocol for file-writing tools (Write / Edit / +// NotebookEdit) — the single derivation shared by summaries, arg displays and +// the streaming preview cards. +// +// The desktop app is the sole producer: bridge events and runtime snapshots +// both carry field text truncated to a bounded preview plus a +// __liveagent_stream_preview meta block holding the true metrics. `progress` +// (sum of true field chars) is a monotonic revision — streamed arguments only +// grow — used to reject stale writers (late deltas, lagging snapshots) +// wherever tool args are merged. Args without meta (local live rounds, +// persisted history) fall back to raw string lengths, which at any stream +// position are >= the truncated preview's metrics, so the guard composes +// across representations. + +export const LIVE_TOOL_PREVIEW_META_KEY = "__liveagent_stream_preview"; + +export const FILE_TOOL_TEXT_FIELDS: Readonly> = { + Write: ["content"], + Edit: ["old_string", "new_string"], + NotebookEdit: ["new_source"], +}; + +export type PreviewFieldMetrics = { + chars: number; + lines: number; + truncated: boolean; +}; + +export type StreamPreviewMeta = { + v: 2; + progress: number; + fields: Record; +}; + +export function countTextLines(input: string) { + if (input.length === 0) return 0; + let lines = 1; + for (let index = 0; index < input.length; index += 1) { + const code = input.charCodeAt(index); + if (code === 13) { + lines += 1; + if (input.charCodeAt(index + 1) === 10) { + index += 1; + } + } else if (code === 10) { + lines += 1; + } + } + return lines; +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function finiteNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +// Tolerant reader: accepts metas missing v/progress (older producers) — +// per-field chars still read, the progress guard just stays inert. +export function readStreamPreviewMeta( + args: Record, +): { progress?: number; fields: Record> } | null { + const meta = asRecord(args[LIVE_TOOL_PREVIEW_META_KEY]); + const rawFields = asRecord(meta.fields); + const fields: Record> = {}; + for (const [name, value] of Object.entries(rawFields)) { + const field = asRecord(value); + if (Object.keys(field).length === 0) continue; + fields[name] = { + chars: finiteNumber(field.chars), + lines: finiteNumber(field.lines), + truncated: typeof field.truncated === "boolean" ? field.truncated : undefined, + }; + } + if (Object.keys(fields).length === 0) return null; + return { progress: finiteNumber(meta.progress), fields }; +} + +// True char count of a preview field: producer meta when present, else the +// raw string length. +export function fileToolFieldChars(args: Record, field: string) { + const metaChars = readStreamPreviewMeta(args)?.fields[field]?.chars; + if (metaChars !== undefined) return metaChars; + const value = args[field]; + return typeof value === "string" ? value.length : undefined; +} + +// Monotonic progress of a tool call's args; undefined for untracked tools +// (merge behavior unchanged there). +export function toolArgsProgress(name: string, args: Record) { + const fields = FILE_TOOL_TEXT_FIELDS[name]; + if (!fields) return undefined; + const meta = readStreamPreviewMeta(args); + if (meta?.progress !== undefined) return meta.progress; + let progress = 0; + for (const field of fields) { + progress += fileToolFieldChars(args, field) ?? 0; + } + return progress; +} + +const FILE_TOOL_DISPLAY_MAX_CHARS = 4000; + +export type FileToolFieldPreview = { + has: boolean; + text: string; + chars: number; + lines: number; + truncated: boolean; +}; + +export type FileToolPreview = + | { + kind: "write"; + name: string; + path: string; + field: string; + content: FileToolFieldPreview; + } + | { + kind: "edit"; + name: string; + path: string; + oldString: FileToolFieldPreview; + newString: FileToolFieldPreview; + expectedReplacements?: number; + replaceAll: boolean; + }; + +function deriveFieldPreview(args: Record, field: string): FileToolFieldPreview { + const raw = args[field]; + const has = typeof raw === "string"; + const text = has ? (raw as string) : ""; + const meta = readStreamPreviewMeta(args)?.fields[field]; + if (meta) { + // Producer-truncated text: the meta carries the true metrics. + return { + has, + text, + chars: meta.chars ?? text.length, + lines: meta.lines ?? countTextLines(text), + truncated: meta.truncated ?? false, + }; + } + // Raw full args (local live rounds, persisted history): cap the display. + const truncated = text.length > FILE_TOOL_DISPLAY_MAX_CHARS; + return { + has, + text: truncated + ? `${text.slice(0, FILE_TOOL_DISPLAY_MAX_CHARS)}\n...[truncated ${ + text.length - FILE_TOOL_DISPLAY_MAX_CHARS + } chars]...` + : text, + chars: text.length, + lines: countTextLines(text), + truncated, + }; +} + +export function deriveFileToolPreview(toolCall: { + name: string; + arguments?: Record; +}): FileToolPreview | null { + const name = toolCall.name; + const fields = FILE_TOOL_TEXT_FIELDS[name]; + if (!fields) return null; + const args = toolCall.arguments || {}; + const path = + typeof args.path === "string" + ? args.path + : typeof args.notebook_path === "string" + ? args.notebook_path + : ""; + + if (name === "Edit") { + return { + kind: "edit", + name, + path, + oldString: deriveFieldPreview(args, "old_string"), + newString: deriveFieldPreview(args, "new_string"), + expectedReplacements: + typeof args.expected_replacements === "number" ? args.expected_replacements : undefined, + replaceAll: args.replace_all === true, + }; + } + const field = fields[0]; + return { + kind: "write", + name, + path, + field, + content: deriveFieldPreview(args, field), + }; +} diff --git a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts index e2d4025d..0b3d53fe 100644 --- a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts +++ b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts @@ -20,6 +20,7 @@ import { resolveHostedSearchTextBoundary, splitTextAroundHostedSearch, } from "./hostedSearch"; +import { fileToolFieldChars, LIVE_TOOL_PREVIEW_META_KEY } from "./toolPreview"; import { getUserMessageAttachments, getUserMessageDisplayText, @@ -340,10 +341,7 @@ export function summarizeToolCall( path ? `path=${path}` : null, "mode=rewrite", typeof args.content === "string" - ? `contentChars=${ - streamingPreviewFieldChars(args, "content") ?? - args.content.length - }` + ? `contentChars=${fileToolFieldChars(args, "content")}` : null, ] : name === "Edit" @@ -354,16 +352,10 @@ export function summarizeToolCall( : null, args.replace_all === true ? "replaceAll=true" : null, typeof args.old_string === "string" - ? `oldChars=${ - streamingPreviewFieldChars(args, "old_string") ?? - args.old_string.length - }` + ? `oldChars=${fileToolFieldChars(args, "old_string")}` : null, typeof args.new_string === "string" - ? `newChars=${ - streamingPreviewFieldChars(args, "new_string") ?? - args.new_string.length - }` + ? `newChars=${fileToolFieldChars(args, "new_string")}` : null, ] : name === "List" @@ -470,24 +462,15 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { return { path: args.path, mode: "rewrite", - contentChars: - typeof args.content === "string" - ? (streamingPreviewFieldChars(args, "content") ?? args.content.length) - : undefined, + contentChars: fileToolFieldChars(args, "content"), }; case "Edit": return { path: args.path, expected_replacements: args.expected_replacements, replace_all: args.replace_all, - oldChars: - typeof args.old_string === "string" - ? (streamingPreviewFieldChars(args, "old_string") ?? args.old_string.length) - : undefined, - newChars: - typeof args.new_string === "string" - ? (streamingPreviewFieldChars(args, "new_string") ?? args.new_string.length) - : undefined, + oldChars: fileToolFieldChars(args, "old_string"), + newChars: fileToolFieldChars(args, "new_string"), }; case "Image": { const out: Record = {}; @@ -521,6 +504,7 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { default: { const out: Record = {}; for (const [key, value] of Object.entries(args)) { + if (key === LIVE_TOOL_PREVIEW_META_KEY) continue; if (typeof value === "string" && value.length > 800) { out[key] = `${value.slice(0, 800)}...(len=${value.length})`; } else { @@ -589,116 +573,6 @@ export function previewText(input: string, maxChars = 1200) { return `${text.slice(0, maxChars)}\n...(已截断预览,len=${text.length})...`; } -export const LIVE_TOOL_PREVIEW_META_KEY = "__liveagent_stream_preview"; - -type StreamingPreviewFieldMetrics = { - chars?: number; - lines?: number; - truncated?: boolean; -}; - -function asPlainRecord(value: unknown): Record { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -function finiteNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function readStreamingPreviewFieldMetrics( - args: Record, - fieldName: string, -): StreamingPreviewFieldMetrics | undefined { - const metadata = asPlainRecord(args[LIVE_TOOL_PREVIEW_META_KEY]); - const fields = asPlainRecord(metadata.fields); - const field = asPlainRecord(fields[fieldName]); - if (Object.keys(field).length === 0) return undefined; - return { - chars: finiteNumber(field.chars), - lines: finiteNumber(field.lines), - truncated: typeof field.truncated === "boolean" ? field.truncated : undefined, - }; -} - -function streamingPreviewFieldChars(args: Record, fieldName: string) { - return readStreamingPreviewFieldMetrics(args, fieldName)?.chars; -} - -export function countTextLines(input: string) { - if (input.length === 0) return 0; - let lines = 1; - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - if (code === 13) { - lines += 1; - if (input.charCodeAt(index + 1) === 10) { - index += 1; - } - } else if (code === 10) { - lines += 1; - } - } - return lines; -} - -export function buildStreamingToolTextPreview( - input: string, - maxChars = 4000, - metrics?: StreamingPreviewFieldMetrics, -) { - const text = input || ""; - const renderedText = previewText(text, maxChars); - return { - text: renderedText, - chars: metrics?.chars ?? text.length, - lines: metrics?.lines ?? countTextLines(text), - truncated: metrics?.truncated ?? text.length > maxChars, - }; -} - -export function getStreamingWriteToolPreview(toolCall: { - name: string; - arguments?: Record; -}) { - if (toolCall.name !== "Write") return null; - const args = toolCall.arguments || {}; - const hasContent = typeof args.content === "string"; - const content = hasContent ? (args.content as string) : ""; - const contentMetrics = readStreamingPreviewFieldMetrics(args, "content"); - return { - path: typeof args.path === "string" ? (args.path as string) : "", - mode: "rewrite" as const, - hasContent, - content: buildStreamingToolTextPreview(content, 4000, contentMetrics), - }; -} - -export function getStreamingEditToolPreview(toolCall: { - name: string; - arguments?: Record; -}) { - if (toolCall.name !== "Edit") return null; - const args = toolCall.arguments || {}; - const hasOldString = typeof args.old_string === "string"; - const hasNewString = typeof args.new_string === "string"; - const oldString = hasOldString ? (args.old_string as string) : ""; - const newString = hasNewString ? (args.new_string as string) : ""; - const oldStringMetrics = readStreamingPreviewFieldMetrics(args, "old_string"); - const newStringMetrics = readStreamingPreviewFieldMetrics(args, "new_string"); - return { - path: typeof args.path === "string" ? (args.path as string) : "", - hasOldString, - hasNewString, - oldString: buildStreamingToolTextPreview(oldString, 4000, oldStringMetrics), - newString: buildStreamingToolTextPreview(newString, 4000, newStringMetrics), - expectedReplacements: - typeof args.expected_replacements === "number" ? args.expected_replacements : undefined, - replaceAll: args.replace_all === true, - }; -} - function appendTextLikeBlock( blocks: UiRoundContentBlock[], kind: "thinking" | "text", diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx new file mode 100644 index 00000000..e9d76083 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx @@ -0,0 +1,147 @@ +import type { + FileToolFieldPreview, + FileToolPreview, +} from "../../../../lib/chat/messages/toolPreview"; +import { EditDiffView } from "./EditDiffView"; +import { + MetaTags, + PathDisplay, + ToolScrollablePre, + ToolSurface, + ToolSurfaceLabel, +} from "./ToolResultDisplay"; + +// Streaming args display for the file-writing tools (Write / Edit / +// NotebookEdit): live-updating path, true char/line counts and a bounded +// content preview, all derived once by deriveFileToolPreview. + +function StreamingArgPlaceholder({ label }: { label: string }) { + return ( + +
{label}
+
+ ); +} + +function StreamingTextPreviewSurface({ + label, + emptyLabel, + preview, +}: { + label: string; + emptyLabel: string; + preview: FileToolFieldPreview; +}) { + return ( + +
+ +
+ {preview.has ? ( + preview.text ? ( + + {preview.text} + + ) : ( +
+ {emptyLabel} +
+ ) + ) : ( +
+ Waiting for {label}... +
+ )} +
+ ); +} + +function PathSurface({ path }: { path: string }) { + return ( + + + + + ); +} + +export function FileToolArgsDisplay({ preview }: { preview: FileToolPreview }) { + if (preview.kind === "write") { + if (!preview.path && !preview.content.has) { + return ; + } + const fieldLabel = preview.field === "new_source" ? "new source" : "content"; + return ( +
+ {preview.path ? : null} + {preview.content.has ? ( + + ) : null} + +
+ ); + } + + if (!preview.path && !preview.oldString.has && !preview.newString.has) { + return ; + } + return ( +
+ {preview.path ? : null} + + {preview.oldString.has && preview.newString.has ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx index c7ba460d..12baca5a 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx @@ -4,8 +4,10 @@ import { memo, useEffect, useState } from "react"; import { ChevronRight, Search } from "../../../../components/icons"; import { useLocale } from "../../../../i18n"; import { - getStreamingEditToolPreview, - getStreamingWriteToolPreview, + deriveFileToolPreview, + FILE_TOOL_TEXT_FIELDS, +} from "../../../../lib/chat/messages/toolPreview"; +import { previewText, safeStringify, summarizeToolCall, @@ -24,6 +26,7 @@ import { isDelegateAgentCardToolCall, type MetaTag, } from "./assistantBubbleUtils"; +import { FileToolArgsDisplay } from "./FileToolArgs"; import { MetaTags, PathDisplay, @@ -34,7 +37,6 @@ import { ToolSurface, ToolSurfaceLabel, } from "./ToolResultDisplay"; -import { EditDiffView } from "./EditDiffView"; function getToolDisplay(toolCall: { name: string; arguments?: Record }) { const args = toolCall.arguments || {}; @@ -93,20 +95,6 @@ function getToolDisplay(toolCall: { name: string; arguments?: Record -
{label}
- - ); -} - -function StreamingTextPreviewSurface({ - label, - hasValue, - emptyLabel, - preview, -}: { - label: string; - hasValue: boolean; - emptyLabel: string; - preview: { text: string; chars: number; lines: number; truncated: boolean }; -}) { - return ( - -
- -
- {hasValue ? ( - preview.text ? ( - - {preview.text} - - ) : ( -
- {emptyLabel} -
- ) - ) : ( -
- Waiting for {label}... -
- )} -
- ); -} - /** Expanded args display — tool-aware layout */ function ToolArgsDisplay({ item }: { item: ToolTraceItem }) { const toolCall = item.toolCall; - const display = getToolDisplay(toolCall); - const writePreview = getStreamingWriteToolPreview(toolCall); - if (writePreview) { - const hasAnyArg = Boolean(writePreview.path || writePreview.hasContent); - if (!hasAnyArg) { - return ; - } - return ( -
- {writePreview.path ? ( - - - - - ) : null} - {writePreview.hasContent ? ( - - ) : null} - -
- ); + const filePreview = deriveFileToolPreview(toolCall); + if (filePreview) { + return ; } - const editPreview = getStreamingEditToolPreview(toolCall); - if (editPreview) { - const hasAnyArg = - Boolean(editPreview.path) || editPreview.hasOldString || editPreview.hasNewString; - if (!hasAnyArg) { - return ; - } - return ( -
- {editPreview.path ? ( - - - - - ) : null} - - {editPreview.hasOldString && editPreview.hasNewString ? ( - - ) : ( - <> - - - - )} -
- ); - } + const display = getToolDisplay(toolCall); if (isDelegateAgentCardToolCall(toolCall)) { const args = toolCall.arguments || {}; @@ -429,8 +279,7 @@ function ToolCallItem({ const [open, setOpen] = useState(shouldAutoOpen); const isDelegateAgentCard = isDelegateAgentCardToolCall(item.toolCall); const hasArgs = Object.keys(item.toolCall.arguments || {}).length > 0; - const isStreamingFilePreviewTool = - item.toolCall.name === "Write" || item.toolCall.name === "Edit"; + const isStreamingFilePreviewTool = FILE_TOOL_TEXT_FIELDS[item.toolCall.name] !== undefined; const shouldShowArgs = (!isDelegateAgentCard || !result) && (isStreamingFilePreviewTool ? !result : hasArgs); const isBash = item.toolCall.name === "Bash"; diff --git a/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts b/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts index 015d6ded..30174c20 100644 --- a/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts +++ b/crates/agent-gui/src/pages/chat/gateway/chatRuntimeSnapshot.ts @@ -2,17 +2,18 @@ import type { Message, ToolCall, ToolResultMessage, Usage } from "@earendil-work import type { LiveTranscriptState } from "../../../lib/chat/conversation/liveTranscriptStore"; import type { HostedSearchBlock } from "../../../lib/chat/messages/hostedSearch"; -import { - getUserMessageAttachments, - getUserMessageDisplayText, - type PendingUploadedFile, -} from "../../../lib/chat/messages/uploadedFiles"; import { safeStringify, summarizeToolCall, toolResultMessageToText, type UiRound, } from "../../../lib/chat/messages/uiMessages"; +import { + getUserMessageAttachments, + getUserMessageDisplayText, + type PendingUploadedFile, +} from "../../../lib/chat/messages/uploadedFiles"; +import { buildGatewayToolCallPreviewArguments } from "../turns/gatewayToolPreview"; export type GatewayRuntimeSnapshotState = "running" | "completed" | "failed" | "cancelled"; @@ -80,10 +81,11 @@ function normalizeToolArguments(value: unknown): Record { } function normalizeToolCall(toolCall: ToolCall | undefined, fallbackId: string): ToolCall { - const source = toolCall as (ToolCall & { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown }) | undefined; + const source = toolCall as + | (ToolCall & { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown }) + | undefined; const id = typeof source?.id === "string" && source.id.trim() ? source.id.trim() : fallbackId; - const name = - typeof source?.name === "string" && source.name.trim() ? source.name.trim() : "Tool"; + const name = typeof source?.name === "string" && source.name.trim() ? source.name.trim() : "Tool"; return { ...(toolCall ?? {}), type: "toolCall", @@ -127,13 +129,20 @@ function buildToolCallEntry( toolCall: ToolCall | undefined, ): GatewayRuntimeSnapshotEntry { const normalized = normalizeToolCall(toolCall, `${prefix}-tool-${round ?? 0}-${index}`); + // Snapshot entries must carry the same preview shape (truncated text + + // meta + monotonic progress) as bridge deltas, so remote consumers can + // order the two writers and never regress a streaming preview. + const streamed = { + ...normalized, + arguments: buildGatewayToolCallPreviewArguments(normalized), + } as ToolCall; return { - id: `${prefix}-tool-call-${round ?? 0}-${normalized.id}-${index}`, + id: `${prefix}-tool-call-${round ?? 0}-${streamed.id}-${index}`, kind: "tool_call", round, - toolCall: normalized, - summary: summarizeToolCall(normalized), - text: safeStringify(normalized.arguments), + toolCall: streamed, + summary: summarizeToolCall(streamed), + text: safeStringify(streamed.arguments), }; } @@ -211,7 +220,7 @@ function appendRoundEntries( block.item.toolCall, `${prefix}-tool-${round.round}-${toolIndex}`, ); - entries.push(buildToolCallEntry(prefix, round.round, toolIndex, toolCall)); + entries.push(buildToolCallEntry(prefix, round.round, toolIndex, block.item.toolCall)); if (block.item.toolResult) { entries.push( buildToolResultEntry(prefix, round.round, toolIndex, toolCall, block.item.toolResult), diff --git a/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts b/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts index fb6407ff..71f9b531 100644 --- a/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts +++ b/crates/agent-gui/src/pages/chat/turns/gatewayToolPreview.ts @@ -1,34 +1,15 @@ import type { ToolCall } from "@earendil-works/pi-ai"; -export const LIVE_TOOL_PREVIEW_META_KEY = "__liveagent_stream_preview"; +import { + countTextLines, + FILE_TOOL_TEXT_FIELDS, + LIVE_TOOL_PREVIEW_META_KEY, + type PreviewFieldMetrics, + type StreamPreviewMeta, +} from "../../../lib/chat/messages/toolPreview"; const GATEWAY_TOOL_TEXT_PREVIEW_MAX_CHARS = 4000; -type PreviewFieldMetrics = { - chars: number; - lines: number; - previewChars: number; - truncated: boolean; - strategy: "full" | "head-tail"; -}; - -function countTextLines(input: string) { - if (input.length === 0) return 0; - let lines = 1; - for (let index = 0; index < input.length; index += 1) { - const code = input.charCodeAt(index); - if (code === 13) { - lines += 1; - if (input.charCodeAt(index + 1) === 10) { - index += 1; - } - } else if (code === 10) { - lines += 1; - } - } - return lines; -} - function buildHeadTailPreview(input: string, maxChars = GATEWAY_TOOL_TEXT_PREVIEW_MAX_CHARS) { if (input.length <= maxChars) { return { @@ -36,10 +17,8 @@ function buildHeadTailPreview(input: string, maxChars = GATEWAY_TOOL_TEXT_PREVIE metrics: { chars: input.length, lines: countTextLines(input), - previewChars: input.length, truncated: false, - strategy: "full" as const, - }, + } satisfies PreviewFieldMetrics, }; } @@ -58,48 +37,39 @@ function buildHeadTailPreview(input: string, maxChars = GATEWAY_TOOL_TEXT_PREVIE metrics: { chars: input.length, lines: countTextLines(input), - previewChars: text.length, truncated: true, - strategy: "head-tail" as const, - }, + } satisfies PreviewFieldMetrics, }; } -function previewStringField( - args: Record, - fieldName: string, - fields: Record, +// The canonical producer of streaming tool previews: bridge events +// (tool_call / tool_call_delta / tool_result) and runtime snapshot entries +// all pass through here, so every remote representation of a file tool's +// args carries the same truncated text + true metrics + monotonic progress. +export function buildGatewayToolCallPreviewArguments( + toolCall: Pick, ) { - const value = args[fieldName]; - if (typeof value !== "string") { - return; - } - const preview = buildHeadTailPreview(value); - args[fieldName] = preview.text; - fields[fieldName] = preview.metrics; -} - -export function buildGatewayToolCallPreviewArguments(toolCall: Pick) { + const fieldsToPreview = FILE_TOOL_TEXT_FIELDS[toolCall.name]; const sourceArgs = toolCall.arguments || {}; - if (toolCall.name !== "Write" && toolCall.name !== "Edit") { + if (!fieldsToPreview) { return sourceArgs; } const args: Record = { ...sourceArgs }; const fields: Record = {}; + let progress = 0; - if (toolCall.name === "Write") { - previewStringField(args, "content", fields); - } else if (toolCall.name === "Edit") { - previewStringField(args, "old_string", fields); - previewStringField(args, "new_string", fields); + for (const field of fieldsToPreview) { + const value = args[field]; + if (typeof value !== "string") continue; + const preview = buildHeadTailPreview(value); + args[field] = preview.text; + fields[field] = preview.metrics; + progress += preview.metrics.chars; } if (Object.keys(fields).length > 0) { - args[LIVE_TOOL_PREVIEW_META_KEY] = { - version: 1, - fields, - }; + args[LIVE_TOOL_PREVIEW_META_KEY] = { v: 2, progress, fields } satisfies StreamPreviewMeta; } return args; diff --git a/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs b/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs index b737d5f0..832a9d65 100644 --- a/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs +++ b/crates/agent-gui/test/chat/gateway-runtime-snapshot.test.mjs @@ -8,6 +8,10 @@ const loader = createTsModuleLoader(); const { buildGatewayRuntimeSnapshotEntries } = loader.loadModule( "src/pages/chat/gateway/chatRuntimeSnapshot.ts", ); +const { buildGatewayToolCallPreviewArguments } = loader.loadModule( + "src/pages/chat/turns/gatewayToolPreview.ts", +); +const toolPreview = loader.loadModule("src/lib/chat/messages/toolPreview.ts"); test("gateway runtime snapshot projects live rounds into chat entries", () => { const entries = buildGatewayRuntimeSnapshotEntries({ @@ -64,6 +68,40 @@ test("gateway runtime snapshot projects live rounds into chat entries", () => { assert.equal(entries[5].text, " Next step is ready."); }); +test("gateway runtime snapshot carries the same tool preview shape as bridge deltas", () => { + const content = "z".repeat(9000); + const toolCall = { + type: "toolCall", + id: "tool-write", + name: "Write", + arguments: { path: "big.txt", content }, + }; + const entries = buildGatewayRuntimeSnapshotEntries({ + userMessage: null, + liveTranscript: { + draftAssistantText: "", + toolStatus: null, + liveRounds: [ + { + key: "round-1", + round: 1, + runningToolCallIds: ["tool-write"], + thinkingOpen: false, + blocks: [{ kind: "tool", item: { toolCall } }], + }, + ], + }, + }); + + const entry = entries.find((candidate) => candidate.kind === "tool_call"); + assert.ok(entry, "expected a tool_call entry"); + assert.deepEqual(entry.toolCall.arguments, buildGatewayToolCallPreviewArguments(toolCall)); + assert.ok(entry.toolCall.arguments.content.length <= 4000); + const metadata = entry.toolCall.arguments[toolPreview.LIVE_TOOL_PREVIEW_META_KEY]; + assert.equal(metadata.progress, content.length); + assert.equal(metadata.fields.content.chars, content.length); +}); + test("gateway runtime snapshot falls back to draft assistant text", () => { const entries = buildGatewayRuntimeSnapshotEntries({ userMessage: { diff --git a/crates/agent-gui/test/chat/messages.test.mjs b/crates/agent-gui/test/chat/messages.test.mjs index a51a1ea4..7f76d30e 100644 --- a/crates/agent-gui/test/chat/messages.test.mjs +++ b/crates/agent-gui/test/chat/messages.test.mjs @@ -10,6 +10,7 @@ const hostedSearch = loader.loadModule("src/lib/chat/messages/hostedSearch.ts"); const seedToolCalls = loader.loadModule("src/lib/chat/runner/seedToolCalls.ts"); const chatHelpers = loader.loadModule("src/lib/chat/page/chatPageHelpers.ts"); const gatewayToolPreview = loader.loadModule("src/pages/chat/turns/gatewayToolPreview.ts"); +const toolPreview = loader.loadModule("src/lib/chat/messages/toolPreview.ts"); const fileA = { relativePath: "src/App.tsx", @@ -43,21 +44,23 @@ test("gateway tool preview keeps Write payloads small while preserving full metr content, }, }); - const metadata = args[gatewayToolPreview.LIVE_TOOL_PREVIEW_META_KEY]; + const metadata = args[toolPreview.LIVE_TOOL_PREVIEW_META_KEY]; const contentMetrics = metadata.fields.content; assert.equal(args.path, "src/generated.txt"); assert.notEqual(args.content, content); assert.ok(args.content.length <= 4000, `preview length ${args.content.length} should be capped`); + assert.equal(metadata.v, 2); + assert.equal(metadata.progress, content.length); assert.equal(contentMetrics.chars, content.length); assert.equal(contentMetrics.lines, 700); assert.equal(contentMetrics.truncated, true); - assert.equal(contentMetrics.strategy, "head-tail"); - const preview = uiMessages.getStreamingWriteToolPreview({ + const preview = toolPreview.deriveFileToolPreview({ name: "Write", arguments: args, }); + assert.equal(preview.kind, "write"); assert.equal(preview.content.chars, content.length); assert.equal(preview.content.lines, 700); assert.equal(preview.content.truncated, true); @@ -65,11 +68,11 @@ test("gateway tool preview keeps Write payloads small while preserving full metr }); test("gateway tool preview handles empty and short Write content without false truncation", () => { - const partialPreview = uiMessages.getStreamingWriteToolPreview({ + const partialPreview = toolPreview.deriveFileToolPreview({ name: "Write", arguments: {}, }); - assert.equal(partialPreview.hasContent, false); + assert.equal(partialPreview.content.has, false); assert.equal(partialPreview.content.chars, 0); assert.equal(partialPreview.content.lines, 0); assert.equal(partialPreview.content.truncated, false); @@ -81,17 +84,18 @@ test("gateway tool preview handles empty and short Write content without false t content: "hello\nworld", }, }); - const metadata = args[gatewayToolPreview.LIVE_TOOL_PREVIEW_META_KEY]; + const metadata = args[toolPreview.LIVE_TOOL_PREVIEW_META_KEY]; assert.equal(args.content, "hello\nworld"); + assert.equal(metadata.progress, 11); assert.equal(metadata.fields.content.chars, 11); assert.equal(metadata.fields.content.lines, 2); assert.equal(metadata.fields.content.truncated, false); - const preview = uiMessages.getStreamingWriteToolPreview({ + const preview = toolPreview.deriveFileToolPreview({ name: "Write", arguments: args, }); - assert.equal(preview.hasContent, true); + assert.equal(preview.content.has, true); assert.equal(preview.content.text, "hello\nworld"); assert.equal(preview.content.lines, 2); }); @@ -113,21 +117,23 @@ test("gateway tool preview keeps Edit old/new payloads small with independent me expected_replacements: 1, }, }); - const metadata = args[gatewayToolPreview.LIVE_TOOL_PREVIEW_META_KEY]; + const metadata = args[toolPreview.LIVE_TOOL_PREVIEW_META_KEY]; assert.notEqual(args.old_string, oldString); assert.notEqual(args.new_string, newString); assert.ok(args.old_string.length <= 4000); assert.ok(args.new_string.length <= 4000); + assert.equal(metadata.progress, oldString.length + newString.length); assert.equal(metadata.fields.old_string.chars, oldString.length); assert.equal(metadata.fields.old_string.lines, 520); assert.equal(metadata.fields.new_string.chars, newString.length); assert.equal(metadata.fields.new_string.lines, 480); - const preview = uiMessages.getStreamingEditToolPreview({ + const preview = toolPreview.deriveFileToolPreview({ name: "Edit", arguments: args, }); + assert.equal(preview.kind, "edit"); assert.equal(preview.oldString.chars, oldString.length); assert.equal(preview.oldString.lines, 520); assert.equal(preview.newString.chars, newString.length); @@ -135,6 +141,60 @@ test("gateway tool preview keeps Edit old/new payloads small with independent me assert.equal(preview.expectedReplacements, 1); }); +test("gateway tool preview covers NotebookEdit new_source", () => { + const newSource = Array.from({ length: 400 }, (_, index) => `cell-${index} ${"c".repeat(20)}`).join( + "\n", + ); + const args = gatewayToolPreview.buildGatewayToolCallPreviewArguments({ + name: "NotebookEdit", + arguments: { + notebook_path: "notebooks/analysis.ipynb", + new_source: newSource, + }, + }); + const metadata = args[toolPreview.LIVE_TOOL_PREVIEW_META_KEY]; + assert.ok(args.new_source.length <= 4000); + assert.equal(metadata.progress, newSource.length); + assert.equal(metadata.fields.new_source.chars, newSource.length); + + const preview = toolPreview.deriveFileToolPreview({ + name: "NotebookEdit", + arguments: args, + }); + assert.equal(preview.kind, "write"); + assert.equal(preview.field, "new_source"); + assert.equal(preview.path, "notebooks/analysis.ipynb"); + assert.equal(preview.content.chars, newSource.length); +}); + +test("tool args progress is monotonic across streaming prefixes and representations", () => { + const fullContent = "x".repeat(9000); + const prefixArgs = gatewayToolPreview.buildGatewayToolCallPreviewArguments({ + name: "Write", + arguments: { path: "a.txt", content: fullContent.slice(0, 4500) }, + }); + const fullArgs = gatewayToolPreview.buildGatewayToolCallPreviewArguments({ + name: "Write", + arguments: { path: "a.txt", content: fullContent }, + }); + + const prefixProgress = toolPreview.toolArgsProgress("Write", prefixArgs); + const fullProgress = toolPreview.toolArgsProgress("Write", fullArgs); + const rawProgress = toolPreview.toolArgsProgress("Write", { + path: "a.txt", + content: fullContent, + }); + + assert.equal(prefixProgress, 4500); + assert.equal(fullProgress, 9000); + // Raw full args (no meta) must compare equal to the built preview so the + // merge guard composes across snapshot and delta representations. + assert.equal(rawProgress, fullProgress); + assert.ok(prefixProgress < fullProgress); + // Untracked tools stay outside the guard. + assert.equal(toolPreview.toolArgsProgress("Bash", { command: "ls" }), undefined); +}); + test("uploaded file helpers preserve display text and strip model-hidden metadata", () => { const merged = uploadedFiles.mergePendingUploadedFiles([fileA], [{ ...fileA, sizeBytes: 4096 }, fileB]); assert.deepEqual(merged.map((file) => [file.relativePath, file.sizeBytes]), [ @@ -1664,31 +1724,32 @@ After`, }); test("streaming Write and Edit tool previews expose bounded live argument previews", () => { - const missingWrite = uiMessages.getStreamingWriteToolPreview({ + const missingWrite = toolPreview.deriveFileToolPreview({ type: "toolCall", id: "write-missing", name: "Write", arguments: { path: "report.md" }, }); assert.equal(missingWrite.path, "report.md"); - assert.equal(missingWrite.hasContent, false); + assert.equal(missingWrite.content.has, false); assert.equal(missingWrite.content.chars, 0); assert.equal(missingWrite.content.lines, 0); const longContent = `line 1\n${"x".repeat(4100)}`; - const writePreview = uiMessages.getStreamingWriteToolPreview({ + const writePreview = toolPreview.deriveFileToolPreview({ type: "toolCall", id: "write-live", name: "Write", arguments: { path: "report.md", content: longContent }, }); - assert.equal(writePreview.hasContent, true); + assert.equal(writePreview.content.has, true); assert.equal(writePreview.content.chars, longContent.length); assert.equal(writePreview.content.lines, 2); assert.equal(writePreview.content.truncated, true); - assert.match(writePreview.content.text, /已截断预览/); + assert.match(writePreview.content.text, /truncated/); + assert.ok(writePreview.content.text.length <= 4100); - const editPreview = uiMessages.getStreamingEditToolPreview({ + const editPreview = toolPreview.deriveFileToolPreview({ type: "toolCall", id: "edit-live", name: "Edit", @@ -1701,9 +1762,9 @@ test("streaming Write and Edit tool previews expose bounded live argument previe }, }); assert.equal(editPreview.path, "src/App.tsx"); - assert.equal(editPreview.hasOldString, true); + assert.equal(editPreview.oldString.has, true); assert.equal(editPreview.oldString.chars, 22); - assert.equal(editPreview.hasNewString, true); + assert.equal(editPreview.newString.has, true); assert.equal(editPreview.newString.chars, 0); assert.equal(editPreview.expectedReplacements, 1); assert.equal(editPreview.replaceAll, true); From 401eff90a7d3d26e087dce0f73f95925c31b9927 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 08:16:40 +0800 Subject: [PATCH 24/64] feat(chat): show live +N/-N line-change badges on Write/Edit previews Replace the char-count text summary (contentChars=/oldChars=/newChars=) in the collapsed Write/Edit tool bar with a green +N/red -N line-diff badge (FileChangeBadge + animated OdometerNumber), mirrored across agent-gateway/web and agent-gui. Falls back to total line counts when the streaming preview meta reports truncation or the diff exceeds the 200k-char budget. Co-Authored-By: Claude Sonnet 5 --- .../src/components/chat/FileChangeBadge.tsx | 34 ++++ .../src/components/chat/OdometerNumber.tsx | 39 ++++ .../web/src/lib/chat/fileChangeStats.ts | 67 ++++++ .../web/src/lib/chat/toolPreview.ts | 9 + .../web/src/lib/chat/uiMessages.ts | 15 +- .../web/src/pages/chat/AssistantBubble.tsx | 10 + .../web/test/chatUi-agent.test.mjs | 101 +++++++++ .../src/components/chat/FileChangeBadge.tsx | 34 ++++ .../src/components/chat/OdometerNumber.tsx | 39 ++++ .../src/lib/chat/messages/fileChangeStats.ts | 67 ++++++ .../src/lib/chat/messages/toolPreview.ts | 9 + .../src/lib/chat/messages/uiMessages.ts | 14 +- .../assistant-bubble/ToolCallItem.tsx | 9 +- .../test/chat/file-change-stats.test.mjs | 191 ++++++++++++++++++ crates/agent-gui/test/chat/messages.test.mjs | 11 +- 15 files changed, 620 insertions(+), 29 deletions(-) create mode 100644 crates/agent-gateway/web/src/components/chat/FileChangeBadge.tsx create mode 100644 crates/agent-gateway/web/src/components/chat/OdometerNumber.tsx create mode 100644 crates/agent-gateway/web/src/lib/chat/fileChangeStats.ts create mode 100644 crates/agent-gui/src/components/chat/FileChangeBadge.tsx create mode 100644 crates/agent-gui/src/components/chat/OdometerNumber.tsx create mode 100644 crates/agent-gui/src/lib/chat/messages/fileChangeStats.ts create mode 100644 crates/agent-gui/test/chat/file-change-stats.test.mjs diff --git a/crates/agent-gateway/web/src/components/chat/FileChangeBadge.tsx b/crates/agent-gateway/web/src/components/chat/FileChangeBadge.tsx new file mode 100644 index 00000000..ed2b73ec --- /dev/null +++ b/crates/agent-gateway/web/src/components/chat/FileChangeBadge.tsx @@ -0,0 +1,34 @@ +import { cn } from "../../lib/shared/utils"; +import { OdometerNumber } from "./OdometerNumber"; + +// Green +N / red -N line-change badge for the collapsed Write/Edit tool bar. +export function FileChangeBadge({ + added, + removed, + className, +}: { + added?: number; + removed?: number; + className?: string; +}) { + if (added === undefined && removed === undefined) return null; + return ( + + {added !== undefined ? ( + + + + + ) : null} + {removed !== undefined ? ( + + - + + ) : null} + + ); +} diff --git a/crates/agent-gateway/web/src/components/chat/OdometerNumber.tsx b/crates/agent-gateway/web/src/components/chat/OdometerNumber.tsx new file mode 100644 index 00000000..286af9ba --- /dev/null +++ b/crates/agent-gateway/web/src/components/chat/OdometerNumber.tsx @@ -0,0 +1,39 @@ +import { cn } from "../../lib/shared/utils"; + +const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +// Rolling-digit odometer. Each digit is a 1em-high viewport over a vertical +// 0-9 strip translated to the current digit; CSS transitions animate value +// changes (both directions) but not the initial paint, so freshly mounted +// digits — including new most-significant columns — appear in place without +// rolling. Columns are keyed by place value from the least-significant end so +// 99 -> 100 keeps the ones/tens columns' identity. +export function OdometerNumber({ value, className }: { value: number; className?: string }) { + const safe = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + const text = String(safe); + return ( + + {text} + + + ); +} diff --git a/crates/agent-gateway/web/src/lib/chat/fileChangeStats.ts b/crates/agent-gateway/web/src/lib/chat/fileChangeStats.ts new file mode 100644 index 00000000..5a36e80f --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/fileChangeStats.ts @@ -0,0 +1,67 @@ +// Line-level change stats for the collapsed Write/Edit tool bar. Edit uses the +// same diff engine as the expanded EditDiffView (generateDiffFile), so the +// collapsed +N/-N matches the expanded diff; when a field was truncated by the +// streaming preview protocol (or is too large to diff on every delta), we fall +// back to total line counts from the preview meta. +import { generateDiffFile } from "@git-diff-view/file"; +import { fileToolFieldLines, readStreamPreviewMeta } from "./toolPreview"; + +export type FileChangeStats = { + added?: number; + removed?: number; +}; + +// Combined old+new char budget for the real line diff. The diff reruns on +// every streaming delta of a local round (full raw strings, no producer +// truncation), so bound the worst case; above the cap we show total line +// counts instead. +const MAX_DIFF_CHARS = 200_000; + +function diffLineCounts(oldText: string, newText: string): FileChangeStats | undefined { + if (!oldText && !newText) return { added: 0, removed: 0 }; + try { + const file = generateDiffFile("old", oldText, "new", newText, "txt", "txt"); + file.initRaw(); + return { added: file.additionLength, removed: file.deletionLength }; + } catch { + return undefined; + } +} + +export function deriveFileChangeStats(toolCall: { + name: string; + arguments?: Record; +}): FileChangeStats | undefined { + const name = toolCall.name; + if (name !== "Write" && name !== "Edit") return undefined; + const args = toolCall.arguments ?? {}; + + if (name === "Write") { + // Write rewrites the whole file: added = total content lines, no removed. + const added = fileToolFieldLines(args, "content"); + return added === undefined ? undefined : { added }; + } + + const addedTotal = fileToolFieldLines(args, "new_string"); + const removedTotal = fileToolFieldLines(args, "old_string"); + if (addedTotal === undefined && removedTotal === undefined) return undefined; + + const meta = readStreamPreviewMeta(args); + const oldRaw = typeof args.old_string === "string" ? args.old_string : undefined; + const newRaw = typeof args.new_string === "string" ? args.new_string : undefined; + const truncated = + meta?.fields.old_string?.truncated === true || meta?.fields.new_string?.truncated === true; + + if ( + oldRaw !== undefined && + newRaw !== undefined && + !truncated && + oldRaw.length + newRaw.length <= MAX_DIFF_CHARS + ) { + const counts = diffLineCounts(oldRaw, newRaw); + if (counts) return counts; + } + // Fallback: total lines per side (truncated stream / oversized / mid-stream + // with only one field present — the present side still ticks live). + return { added: addedTotal, removed: removedTotal }; +} diff --git a/crates/agent-gateway/web/src/lib/chat/toolPreview.ts b/crates/agent-gateway/web/src/lib/chat/toolPreview.ts index 7240024c..15fb00a1 100644 --- a/crates/agent-gateway/web/src/lib/chat/toolPreview.ts +++ b/crates/agent-gateway/web/src/lib/chat/toolPreview.ts @@ -89,6 +89,15 @@ export function fileToolFieldChars(args: Record, field: string) return typeof value === "string" ? value.length : undefined; } +// True line count of a preview field: producer meta when present, else the +// raw string's line count. +export function fileToolFieldLines(args: Record, field: string) { + const metaLines = readStreamPreviewMeta(args)?.fields[field]?.lines; + if (metaLines !== undefined) return metaLines; + const value = args[field]; + return typeof value === "string" ? countTextLines(value) : undefined; +} + // Monotonic progress of a tool call's args; undefined for untracked tools // (merge behavior unchanged there). export function toolArgsProgress(name: string, args: Record) { diff --git a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts index 068d632b..be20ad68 100644 --- a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts +++ b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts @@ -328,14 +328,7 @@ export function summarizeToolCall( typeof args.message === "string" ? `messageChars=${args.message.length}` : null, ] : name === "Write" - ? [ - root, - path ? `path=${path}` : null, - "mode=rewrite", - typeof args.content === "string" - ? `contentChars=${fileToolFieldChars(args, "content")}` - : null, - ] + ? [root, path ? `path=${path}` : null, "mode=rewrite"] : name === "Edit" ? [ root, @@ -344,12 +337,6 @@ export function summarizeToolCall( ? `expected=${args.expected_replacements}` : null, args.replace_all === true ? "replaceAll=true" : null, - typeof args.old_string === "string" - ? `oldChars=${fileToolFieldChars(args, "old_string")}` - : null, - typeof args.new_string === "string" - ? `newChars=${fileToolFieldChars(args, "new_string")}` - : null, ] : name === "List" ? [ diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index ac9f694e..c5d32b96 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -38,6 +38,8 @@ import { type UiRound, } from "../../lib/chat/uiMessages"; import { deriveFileToolPreview, FILE_TOOL_TEXT_FIELDS } from "../../lib/chat/toolPreview"; +import { deriveFileChangeStats } from "../../lib/chat/fileChangeStats"; +import { FileChangeBadge } from "../../components/chat/FileChangeBadge"; import { EditDiffView } from "./EditDiffView"; import { FileToolArgsDisplay } from "./FileToolArgs"; import { @@ -2428,6 +2430,10 @@ function ToolCallItem({ includeName: false, includeManagerAction: false, }); + const fileChangeStats = useMemo( + () => (isRedactedToolContent ? undefined : deriveFileChangeStats(item.toolCall)), + [isRedactedToolContent, item.toolCall], + ); const meta = getToolMeta(item.toolCall.name); const ToolIcon = meta.Icon; const title = isRedactedToolContent @@ -2505,6 +2511,10 @@ function ToolCallItem({ {toolArgsSummary} ) : null} + + {fileChangeStats ? ( + + ) : null}
diff --git a/crates/agent-gateway/web/test/chatUi-agent.test.mjs b/crates/agent-gateway/web/test/chatUi-agent.test.mjs index b2703ef2..4669b138 100644 --- a/crates/agent-gateway/web/test/chatUi-agent.test.mjs +++ b/crates/agent-gateway/web/test/chatUi-agent.test.mjs @@ -8,6 +8,7 @@ const rootDir = fileURLToPath(new URL("../", import.meta.url)); const uiMessagesLoader = createWebModuleLoader({ rootDir }); const uiMessages = uiMessagesLoader.loadModule("src/lib/chat/uiMessages.ts"); const toolPreview = uiMessagesLoader.loadModule("src/lib/chat/toolPreview.ts"); +const fileChangeStats = uiMessagesLoader.loadModule("src/lib/chat/fileChangeStats.ts"); const hostedSearch = uiMessagesLoader.loadModule("src/lib/chat/hostedSearch.ts"); const uploadedImagePreview = uiMessagesLoader.loadModule("src/lib/chat/uploadedImagePreview.ts"); const loader = createWebModuleLoader({ @@ -167,6 +168,106 @@ test("web uiMessages summarizes SendMessage calls", () => { }); }); +test("web uiMessages keeps char counts out of Write/Edit summaries", () => { + assert.equal( + uiMessages.summarizeToolCall({ + type: "toolCall", + id: "write-1", + name: "Write", + arguments: { path: "src/App.tsx", content: "line-1\nline-2" }, + }), + "Write path=src/App.tsx mode=rewrite", + ); + assert.equal( + uiMessages.summarizeToolCall({ + type: "toolCall", + id: "edit-1", + name: "Edit", + arguments: { + path: "src/App.tsx", + old_string: "a".repeat(20), + new_string: "b".repeat(35), + expected_replacements: 1, + replace_all: true, + }, + }), + "Edit path=src/App.tsx expected=1 replaceAll=true", + ); +}); + +test("deriveFileChangeStats derives collapsed-bar line counts", () => { + const oldLines = Array.from({ length: 50 }, (_, index) => `line-${index}`); + const newLines = oldLines.slice(); + newLines[25] = "line-25-changed"; + assert.deepEqual( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "edit-1", + name: "Edit", + arguments: { old_string: oldLines.join("\n"), new_string: newLines.join("\n") }, + }), + { added: 1, removed: 1 }, + ); + + assert.deepEqual( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "edit-2", + name: "Edit", + arguments: { + old_string: "o".repeat(4000), + new_string: "n".repeat(4000), + __liveagent_stream_preview: { + v: 2, + progress: 17_000, + fields: { + old_string: { chars: 9000, lines: 300, truncated: true }, + new_string: { chars: 8000, lines: 280, truncated: false }, + }, + }, + }, + }), + { added: 280, removed: 300 }, + ); + + assert.deepEqual( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "edit-3", + name: "Edit", + arguments: { old_string: "a\nb\nc" }, + }), + { added: undefined, removed: 3 }, + ); + + assert.deepEqual( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "write-1", + name: "Write", + arguments: { + content: "preview…", + __liveagent_stream_preview: { + v: 2, + progress: 12_000, + fields: { content: { chars: 12_000, lines: 800, truncated: true } }, + }, + }, + }), + { added: 800 }, + ); + + assert.equal( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "bash-1", + name: "Bash", + arguments: { command: "ls" }, + }), + undefined, + ); +}); + test("buildRowsFromEntries emits user rows keyed by entry id", () => { const rows = buildRowsFromEntries( [ diff --git a/crates/agent-gui/src/components/chat/FileChangeBadge.tsx b/crates/agent-gui/src/components/chat/FileChangeBadge.tsx new file mode 100644 index 00000000..ed2b73ec --- /dev/null +++ b/crates/agent-gui/src/components/chat/FileChangeBadge.tsx @@ -0,0 +1,34 @@ +import { cn } from "../../lib/shared/utils"; +import { OdometerNumber } from "./OdometerNumber"; + +// Green +N / red -N line-change badge for the collapsed Write/Edit tool bar. +export function FileChangeBadge({ + added, + removed, + className, +}: { + added?: number; + removed?: number; + className?: string; +}) { + if (added === undefined && removed === undefined) return null; + return ( + + {added !== undefined ? ( + + + + + ) : null} + {removed !== undefined ? ( + + - + + ) : null} + + ); +} diff --git a/crates/agent-gui/src/components/chat/OdometerNumber.tsx b/crates/agent-gui/src/components/chat/OdometerNumber.tsx new file mode 100644 index 00000000..286af9ba --- /dev/null +++ b/crates/agent-gui/src/components/chat/OdometerNumber.tsx @@ -0,0 +1,39 @@ +import { cn } from "../../lib/shared/utils"; + +const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +// Rolling-digit odometer. Each digit is a 1em-high viewport over a vertical +// 0-9 strip translated to the current digit; CSS transitions animate value +// changes (both directions) but not the initial paint, so freshly mounted +// digits — including new most-significant columns — appear in place without +// rolling. Columns are keyed by place value from the least-significant end so +// 99 -> 100 keeps the ones/tens columns' identity. +export function OdometerNumber({ value, className }: { value: number; className?: string }) { + const safe = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; + const text = String(safe); + return ( + + {text} + + + ); +} diff --git a/crates/agent-gui/src/lib/chat/messages/fileChangeStats.ts b/crates/agent-gui/src/lib/chat/messages/fileChangeStats.ts new file mode 100644 index 00000000..5a36e80f --- /dev/null +++ b/crates/agent-gui/src/lib/chat/messages/fileChangeStats.ts @@ -0,0 +1,67 @@ +// Line-level change stats for the collapsed Write/Edit tool bar. Edit uses the +// same diff engine as the expanded EditDiffView (generateDiffFile), so the +// collapsed +N/-N matches the expanded diff; when a field was truncated by the +// streaming preview protocol (or is too large to diff on every delta), we fall +// back to total line counts from the preview meta. +import { generateDiffFile } from "@git-diff-view/file"; +import { fileToolFieldLines, readStreamPreviewMeta } from "./toolPreview"; + +export type FileChangeStats = { + added?: number; + removed?: number; +}; + +// Combined old+new char budget for the real line diff. The diff reruns on +// every streaming delta of a local round (full raw strings, no producer +// truncation), so bound the worst case; above the cap we show total line +// counts instead. +const MAX_DIFF_CHARS = 200_000; + +function diffLineCounts(oldText: string, newText: string): FileChangeStats | undefined { + if (!oldText && !newText) return { added: 0, removed: 0 }; + try { + const file = generateDiffFile("old", oldText, "new", newText, "txt", "txt"); + file.initRaw(); + return { added: file.additionLength, removed: file.deletionLength }; + } catch { + return undefined; + } +} + +export function deriveFileChangeStats(toolCall: { + name: string; + arguments?: Record; +}): FileChangeStats | undefined { + const name = toolCall.name; + if (name !== "Write" && name !== "Edit") return undefined; + const args = toolCall.arguments ?? {}; + + if (name === "Write") { + // Write rewrites the whole file: added = total content lines, no removed. + const added = fileToolFieldLines(args, "content"); + return added === undefined ? undefined : { added }; + } + + const addedTotal = fileToolFieldLines(args, "new_string"); + const removedTotal = fileToolFieldLines(args, "old_string"); + if (addedTotal === undefined && removedTotal === undefined) return undefined; + + const meta = readStreamPreviewMeta(args); + const oldRaw = typeof args.old_string === "string" ? args.old_string : undefined; + const newRaw = typeof args.new_string === "string" ? args.new_string : undefined; + const truncated = + meta?.fields.old_string?.truncated === true || meta?.fields.new_string?.truncated === true; + + if ( + oldRaw !== undefined && + newRaw !== undefined && + !truncated && + oldRaw.length + newRaw.length <= MAX_DIFF_CHARS + ) { + const counts = diffLineCounts(oldRaw, newRaw); + if (counts) return counts; + } + // Fallback: total lines per side (truncated stream / oversized / mid-stream + // with only one field present — the present side still ticks live). + return { added: addedTotal, removed: removedTotal }; +} diff --git a/crates/agent-gui/src/lib/chat/messages/toolPreview.ts b/crates/agent-gui/src/lib/chat/messages/toolPreview.ts index 7240024c..15fb00a1 100644 --- a/crates/agent-gui/src/lib/chat/messages/toolPreview.ts +++ b/crates/agent-gui/src/lib/chat/messages/toolPreview.ts @@ -89,6 +89,15 @@ export function fileToolFieldChars(args: Record, field: string) return typeof value === "string" ? value.length : undefined; } +// True line count of a preview field: producer meta when present, else the +// raw string's line count. +export function fileToolFieldLines(args: Record, field: string) { + const metaLines = readStreamPreviewMeta(args)?.fields[field]?.lines; + if (metaLines !== undefined) return metaLines; + const value = args[field]; + return typeof value === "string" ? countTextLines(value) : undefined; +} + // Monotonic progress of a tool call's args; undefined for untracked tools // (merge behavior unchanged there). export function toolArgsProgress(name: string, args: Record) { diff --git a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts index 0b3d53fe..f8a2558d 100644 --- a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts +++ b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts @@ -337,13 +337,7 @@ export function summarizeToolCall( : null, ] : name === "Write" - ? [ - path ? `path=${path}` : null, - "mode=rewrite", - typeof args.content === "string" - ? `contentChars=${fileToolFieldChars(args, "content")}` - : null, - ] + ? [path ? `path=${path}` : null, "mode=rewrite"] : name === "Edit" ? [ path ? `path=${path}` : null, @@ -351,12 +345,6 @@ export function summarizeToolCall( ? `expected=${args.expected_replacements}` : null, args.replace_all === true ? "replaceAll=true" : null, - typeof args.old_string === "string" - ? `oldChars=${fileToolFieldChars(args, "old_string")}` - : null, - typeof args.new_string === "string" - ? `newChars=${fileToolFieldChars(args, "new_string")}` - : null, ] : name === "List" ? [ diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx index 12baca5a..3e760dbb 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolCallItem.tsx @@ -1,8 +1,10 @@ import type { ToolResultMessage } from "@earendil-works/pi-ai"; -import { memo, useEffect, useState } from "react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { FileChangeBadge } from "../../../../components/chat/FileChangeBadge"; import { ChevronRight, Search } from "../../../../components/icons"; import { useLocale } from "../../../../i18n"; +import { deriveFileChangeStats } from "../../../../lib/chat/messages/fileChangeStats"; import { deriveFileToolPreview, FILE_TOOL_TEXT_FIELDS, @@ -296,6 +298,7 @@ function ToolCallItem({ includeName: false, includeManagerAction: false, }); + const fileChangeStats = useMemo(() => deriveFileChangeStats(item.toolCall), [item.toolCall]); const meta = getToolMeta(item.toolCall.name); const ToolIcon = meta.Icon; const title = getToolDisplayTitle(item.toolCall); @@ -403,6 +406,10 @@ function ToolCallItem({ {toolArgsSummary} ) : null} + + {fileChangeStats ? ( + + ) : null}
{/* Status badge + dot + chevron */} diff --git a/crates/agent-gui/test/chat/file-change-stats.test.mjs b/crates/agent-gui/test/chat/file-change-stats.test.mjs new file mode 100644 index 00000000..d3c1f209 --- /dev/null +++ b/crates/agent-gui/test/chat/file-change-stats.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const fileChangeStats = loader.loadModule("src/lib/chat/messages/fileChangeStats.ts"); +const odometer = loader.loadModule("src/components/chat/OdometerNumber.tsx"); +const badge = loader.loadModule("src/components/chat/FileChangeBadge.tsx"); + +const PREVIEW_META_KEY = "__liveagent_stream_preview"; + +function editCall(args) { + return { type: "toolCall", id: "edit-1", name: "Edit", arguments: args }; +} + +function writeCall(args) { + return { type: "toolCall", id: "write-1", name: "Write", arguments: args }; +} + +test("deriveFileChangeStats reports real diff counts for Edit", () => { + const oldLines = Array.from({ length: 50 }, (_, index) => `line-${index}`); + const newLines = oldLines.slice(); + newLines[25] = "line-25-changed"; + + assert.deepEqual( + fileChangeStats.deriveFileChangeStats( + editCall({ old_string: oldLines.join("\n"), new_string: newLines.join("\n") }), + ), + { added: 1, removed: 1 }, + ); + assert.deepEqual( + fileChangeStats.deriveFileChangeStats( + editCall({ old_string: "a\nb\n", new_string: "a\nb\nc\n" }), + ), + { added: 1, removed: 0 }, + ); + // Without a trailing newline the engine applies git's no-newline-at-eof + // semantics (the last old line counts as rewritten) — same as EditDiffView. + assert.deepEqual( + fileChangeStats.deriveFileChangeStats(editCall({ old_string: "a\nb", new_string: "a\nb\nc" })), + { added: 2, removed: 1 }, + ); + assert.deepEqual( + fileChangeStats.deriveFileChangeStats(editCall({ old_string: "x\ny", new_string: "x\ny" })), + { added: 0, removed: 0 }, + ); + assert.deepEqual( + fileChangeStats.deriveFileChangeStats(editCall({ old_string: "", new_string: "" })), + { added: 0, removed: 0 }, + ); +}); + +test("deriveFileChangeStats falls back to meta line totals for truncated streams", () => { + const stats = fileChangeStats.deriveFileChangeStats( + editCall({ + old_string: "o".repeat(4000), + new_string: "n".repeat(4000), + [PREVIEW_META_KEY]: { + v: 2, + progress: 17_000, + fields: { + old_string: { chars: 9000, lines: 300, truncated: true }, + new_string: { chars: 8000, lines: 280, truncated: false }, + }, + }, + }), + ); + assert.deepEqual(stats, { added: 280, removed: 300 }); +}); + +test("deriveFileChangeStats shows the streamed side while the other is missing", () => { + assert.deepEqual(fileChangeStats.deriveFileChangeStats(editCall({ old_string: "a\nb\nc" })), { + added: undefined, + removed: 3, + }); + assert.equal(fileChangeStats.deriveFileChangeStats(editCall({ path: "src/App.tsx" })), undefined); +}); + +test("deriveFileChangeStats falls back to totals for oversized edits", () => { + const body = Array.from({ length: 3500 }, (_, index) => `shared-${index}-${"x".repeat(28)}`); + const stats = fileChangeStats.deriveFileChangeStats( + editCall({ + old_string: [...body, "only-old-line"].join("\n"), + new_string: [...body, "only-new-line"].join("\n"), + }), + ); + // A real diff would report {added: 1, removed: 1}; the cap keeps the hot + // streaming path away from huge diffs and reports totals instead. + assert.deepEqual(stats, { added: 3501, removed: 3501 }); +}); + +test("deriveFileChangeStats counts Write content lines", () => { + assert.deepEqual(fileChangeStats.deriveFileChangeStats(writeCall({ content: "l1\nl2\nl3" })), { + added: 3, + }); + assert.deepEqual( + fileChangeStats.deriveFileChangeStats( + writeCall({ + content: "preview…", + [PREVIEW_META_KEY]: { + v: 2, + progress: 12_000, + fields: { content: { chars: 12_000, lines: 800, truncated: true } }, + }, + }), + ), + { added: 800 }, + ); + assert.equal( + fileChangeStats.deriveFileChangeStats(writeCall({ path: "src/App.tsx" })), + undefined, + ); +}); + +test("deriveFileChangeStats ignores non-file tools", () => { + assert.equal( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "bash-1", + name: "Bash", + arguments: { command: "ls" }, + }), + undefined, + ); + assert.equal( + fileChangeStats.deriveFileChangeStats({ + type: "toolCall", + id: "nb-1", + name: "NotebookEdit", + arguments: { new_source: "a\nb" }, + }), + undefined, + ); +}); + +function childrenOf(node) { + const children = node?.props?.children; + if (children === undefined || children === null) return []; + return Array.isArray(children) ? children : [children]; +} + +test("OdometerNumber renders one rolling column per digit", () => { + const tree = odometer.OdometerNumber({ value: 205 }); + const [srOnly, strip] = childrenOf(tree); + assert.equal(srOnly.props.className, "sr-only"); + assert.equal(srOnly.props.children, "205"); + assert.equal(strip.props["aria-hidden"], "true"); + + const columns = childrenOf(strip); + assert.equal(columns.length, 3); + assert.deepEqual( + columns.map((column) => column.key), + ["p2", "p1", "p0"], + ); + const transforms = columns.map((column) => childrenOf(column)[0].props.style.transform); + assert.deepEqual(transforms, ["translateY(-2em)", "translateY(-0em)", "translateY(-5em)"]); + for (const column of columns) { + const reel = childrenOf(column)[0]; + assert.ok(reel.props.className.includes("transition-transform")); + assert.ok(reel.props.className.includes("motion-reduce:transition-none")); + assert.equal(childrenOf(reel).length, 10); + } +}); + +test("OdometerNumber clamps invalid values to zero", () => { + for (const value of [-3, Number.NaN, Number.POSITIVE_INFINITY]) { + const [srOnly] = childrenOf(odometer.OdometerNumber({ value })); + assert.equal(srOnly.props.children, "0"); + } +}); + +test("FileChangeBadge renders green added and red removed counts", () => { + assert.equal(badge.FileChangeBadge({}), null); + + const addedOnly = badge.FileChangeBadge({ added: 2 }); + const [addedSpan, removedSpan] = childrenOf(addedOnly); + assert.ok(addedSpan.props.className.includes("--chat-success")); + assert.equal(removedSpan, null); + const [addedSign, addedNumber] = childrenOf(addedSpan); + assert.equal(addedSign, "+"); + assert.equal(addedNumber.type, odometer.OdometerNumber); + assert.equal(addedNumber.props.value, 2); + + const both = badge.FileChangeBadge({ added: 2, removed: 1 }); + const [green, red] = childrenOf(both); + assert.ok(green.props.className.includes("--chat-success")); + assert.ok(red.props.className.includes("--chat-error")); + const [removedSign, removedNumber] = childrenOf(red); + assert.equal(removedSign, "-"); + assert.equal(removedNumber.props.value, 1); +}); diff --git a/crates/agent-gui/test/chat/messages.test.mjs b/crates/agent-gui/test/chat/messages.test.mjs index 7f76d30e..ebeaae40 100644 --- a/crates/agent-gui/test/chat/messages.test.mjs +++ b/crates/agent-gui/test/chat/messages.test.mjs @@ -1454,7 +1454,16 @@ test("tool call summaries and argument display avoid dumping large payloads", () assert.equal( uiMessages.summarizeToolCall(editCall), - "Edit path=src/App.tsx expected=1 replaceAll=true oldChars=20 newChars=35", + "Edit path=src/App.tsx expected=1 replaceAll=true", + ); + assert.equal( + uiMessages.summarizeToolCall({ + type: "toolCall", + id: "write-1", + name: "Write", + arguments: { path: "src/App.tsx", content: "line-1\nline-2" }, + }), + "Write path=src/App.tsx mode=rewrite", ); assert.deepEqual(uiMessages.toolCallArgsForDisplay(editCall), { path: "src/App.tsx", From d010e47d95bb1585031300a33e7f9612a6705d86 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 10:22:16 +0800 Subject: [PATCH 25/64] fix(chat): sync edit resend rebases across gateway clients --- .../internal/server/chat_commands.go | 2 +- .../internal/session/conversation_ingress.go | 23 + .../internal/session/conversation_stream.go | 9 + .../session/conversation_stream_test.go | 161 ++++ .../agent-gateway/web/src/app/GatewayApp.tsx | 28 + .../web/src/components/GatewayTranscript.tsx | 247 ++++-- .../WorkspaceImagePreviewOverlay.tsx | 194 ----- .../web/src/lib/chat/chatHistory.ts | 440 +--------- .../web/src/lib/chat/conversationState.ts | 804 +----------------- .../src/lib/chat/requestContextSanitizer.ts | 156 ---- .../web/src/lib/chat/systemPrompt.ts | 8 - .../conversation/run/gatewayBridgeEvents.ts | 31 +- crates/agent-gui/src/pages/ChatPage.tsx | 5 +- .../pages/chat/gateway/gatewayBridgeTypes.ts | 4 + .../src/pages/chat/hooks/useEditResend.ts | 3 + .../test/chat/gateway-bridge-events.test.mjs | 52 ++ 16 files changed, 488 insertions(+), 1679 deletions(-) delete mode 100644 crates/agent-gateway/web/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx delete mode 100644 crates/agent-gateway/web/src/lib/chat/requestContextSanitizer.ts delete mode 100644 crates/agent-gateway/web/src/lib/chat/systemPrompt.ts diff --git a/crates/agent-gateway/internal/server/chat_commands.go b/crates/agent-gateway/internal/server/chat_commands.go index 2b015c36..f1ed95d9 100644 --- a/crates/agent-gateway/internal/server/chat_commands.go +++ b/crates/agent-gateway/internal/server/chat_commands.go @@ -184,7 +184,7 @@ func buildAcceptedChatCommandPayloads( payloads := make([]map[string]any, 0, 2) if baseMessageRef != nil { payloads = append(payloads, map[string]any{ - "type": "rebased", + "type": session.StreamEventRebased, "base_message_ref": baseMessageRef, "reason": "edit_resend", }) diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go index c333ef66..e2e6d7d8 100644 --- a/crates/agent-gateway/internal/session/conversation_ingress.go +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -70,6 +70,29 @@ func (m *Manager) ingestChatEvent(requestID string, event *gatewayv1.ChatEvent) return } + if event.GetType() == gatewayv1.ChatEvent_USER_MESSAGE { + // A GUI-local edit-resend: the desktop truncated its own history and + // stamped the truncation base onto its user_message. Broadcast the + // same rebased event the webui edit path seeds, so every subscriber + // truncates before the new user message renders (webui commands never + // reach here — their echo was swallowed above). + if ref, ok := payload["base_message_ref"].(map[string]any); ok { + messageID, _ := ref["message_id"].(string) + contentHash, _ := ref["content_hash"].(string) + if strings.TrimSpace(messageID) != "" || strings.TrimSpace(contentHash) != "" { + record := s.runRecordLocked(runID, conversationID) + if !record.rebaseSeeded { + record.rebaseSeeded = true + s.appendSeededPayloadsLocked(stream, runID, record.clientRequestID, []map[string]any{{ + "type": StreamEventRebased, + "base_message_ref": ref, + "reason": "edit_resend", + }}, now) + } + } + } + } + workdir, _ := payload["workdir"].(string) s.runStartedLocked(stream, runID, strings.TrimSpace(workdir), now) if stream.activity == nil || stream.activity.RunID != runID { diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go index 3b5df8f3..b17d8f65 100644 --- a/crates/agent-gateway/internal/session/conversation_stream.go +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -47,6 +47,11 @@ const ( StreamEventRunFinished = "run_finished" StreamEventRunQueued = "run_queued" StreamEventSnapshot = "snapshot" + // StreamEventRebased signals an edit-resend truncation: subscribers drop + // the edited user message and everything after it before the new + // user_message arrives. Seeded by the gateway for webui edit_resend + // commands and synthesized on ingress for GUI-local edits. + StreamEventRebased = "rebased" ) // RunActivity describes the current run of a conversation. A nil activity @@ -165,6 +170,10 @@ type chatRunRecord struct { // queuedInGUI marks commands the desktop app parked in its prompt queue; // the startup watchdog must leave them alone. queuedInGUI bool + // rebaseSeeded marks runs whose rebased event was already appended from + // the agent's ref-bearing user_message, so a reconnect replay of the same + // event cannot seed a second truncation. + rebaseSeeded bool } type pendingChatRun struct { diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go index 2de48416..a250349f 100644 --- a/crates/agent-gateway/internal/session/conversation_stream_test.go +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -769,3 +769,164 @@ func TestDeferredSeedsFlushWhenRunStartsDirectly(t *testing.T) { t.Fatalf("user_message count = %d, want 1 (echo swallowed)", userMessages) } } + +func editResendUserMessageEvent(conversationID string, message string, ref map[string]any) *gatewayv1.ChatEvent { + payload := map[string]any{"message": message} + if ref != nil { + payload["base_message_ref"] = ref + payload["reason"] = "edit_resend" + } + data, _ := json.Marshal(payload) + return &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: conversationID, + Data: string(data), + } +} + +func testBaseMessageRef() map[string]any { + return map[string]any{ + "segment_index": 0, + "message_index": 2, + "segment_id": "seg-1", + "message_id": "msg-2", + "role": "user", + "content_hash": "hash-2", + } +} + +func countEventType(events []*ConversationEvent, eventType string) int { + count := 0 + for _, event := range events { + if event.Type == eventType { + count++ + } + } + return count +} + +// GUI-local edit-resend, "started" control first (the usual desktop order): +// the ref-bearing user_message seeds one rebased event after run_started. +func TestGUIEditResendSeedsRebasedAfterRunStarted(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{StreamEventRunStarted, StreamEventRebased, "user_message", "token"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } + rebased := sub.Events[1] + ref, ok := rebased.Payload["base_message_ref"].(map[string]any) + if !ok || ref["message_id"] != "msg-2" || ref["content_hash"] != "hash-2" { + t.Fatalf("rebased base_message_ref = %#v", rebased.Payload["base_message_ref"]) + } + if rebased.Payload["reason"] != "edit_resend" { + t.Fatalf("rebased reason = %#v, want edit_resend", rebased.Payload["reason"]) + } +} + +// No prior control signal: the rebased seed still lands, before the +// synthesized run_started. +func TestGUIEditResendSeedsRebasedBeforeRunStarted(t *testing.T) { + m := NewManager() + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + types := eventTypes(sub.Events) + want := []string{StreamEventRebased, StreamEventRunStarted, "user_message"} + if len(types) != len(want) { + t.Fatalf("replay = %v, want %v", types, want) + } + for i := range want { + if types[i] != want[i] { + t.Fatalf("replay = %v, want %v", types, want) + } + } +} + +// A reconnect replay redelivers the same ref-bearing user_message: exactly +// one rebased event is seeded for the run. +func TestGUIEditResendRebasedSeededOnce(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", testBaseMessageRef())) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 1 { + t.Fatalf("rebased count = %d (types %v), want 1", got, eventTypes(sub.Events)) + } +} + +// Plain sends (no ref, a null ref — the desktop bridge always serializes the +// key, as null when unset — or an empty ref) must not seed a truncation. +func TestUserMessageWithoutRefSeedsNoRebased(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "plain prompt", nil)) + + nullRef, _ := json.Marshal(map[string]any{ + "message": "null ref prompt", + "base_message_ref": nil, + }) + m.ingestChatControl("run-1", completedControl("run-1", "conv-1")) + m.ingestChatControl("run-2", startedControl("run-2", "conv-1")) + m.ingestChatEvent("run-2", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(nullRef), + }) + + emptyRef, _ := json.Marshal(map[string]any{ + "message": "empty ref prompt", + "base_message_ref": map[string]any{"message_id": "", "content_hash": " "}, + }) + m.ingestChatControl("run-2", completedControl("run-2", "conv-1")) + m.ingestChatControl("run-3", startedControl("run-3", "conv-1")) + m.ingestChatEvent("run-3", &gatewayv1.ChatEvent{ + Type: gatewayv1.ChatEvent_USER_MESSAGE, + ConversationId: "conv-1", + Data: string(emptyRef), + }) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 0 { + t.Fatalf("rebased count = %d (types %v), want 0", got, eventTypes(sub.Events)) + } +} + +// A webui-initiated edit_resend already seeds its rebased at accept time; +// the agent's ref-bearing echo is swallowed and must not seed a second one. +func TestWebuiEditResendEchoSeedsNoSecondRebased(t *testing.T) { + m := NewManager() + ref := testBaseMessageRef() + m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": StreamEventRebased, "base_message_ref": ref, "reason": "edit_resend"}, + {"type": "user_message", "message": "edited prompt", "base_message_ref": ref, "reason": "edit_resend"}, + }) + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + m.ingestChatEvent("run-1", editResendUserMessageEvent("conv-1", "edited prompt", ref)) + m.ingestChatEvent("run-1", tokenEvent("conv-1", "reply")) + + sub := m.SubscribeConversationStream("conv-1", 0, "") + defer sub.Cleanup() + if got := countEventType(sub.Events, StreamEventRebased); got != 1 { + t.Fatalf("rebased count = %d (types %v), want 1", got, eventTypes(sub.Events)) + } + if got := countEventType(sub.Events, "user_message"); got != 1 { + t.Fatalf("user_message count = %d (types %v), want 1 (echo swallowed)", got, eventTypes(sub.Events)) + } +} diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index e1687b5a..842fb758 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -1619,6 +1619,34 @@ export default function GatewayApp() { }); displayedConversationBusyRef.current = displayedConversationBusy; + // Deterministic messageRef attachment: when the displayed conversation + // transitions busy → idle (run finished), run the quiet enrich refresh so + // the settled tail's user bubbles gain their persisted messageRef (edit + // affordance) without waiting for a history upsert to race the idle gate. + // The upsert-while-idle path below stays as the backstop for the + // persist-after-done ordering. + const previousDisplayedBusyRef = useRef({ id: "", busy: false }); + useEffect(() => { + const prev = previousDisplayedBusyRef.current; + previousDisplayedBusyRef.current = { + id: displayedConversationId, + busy: displayedConversationBusy, + }; + if ( + prev.id === displayedConversationId && + prev.busy && + !displayedConversationBusy && + displayedConversationId + ) { + void refreshDisplayedConversationHistorySnapshot(displayedConversationId, api); + } + }, [ + api, + displayedConversationBusy, + displayedConversationId, + refreshDisplayedConversationHistorySnapshot, + ]); + useEffect(() => { if (!api) { return; diff --git a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx index fd69a5e4..ede146db 100644 --- a/crates/agent-gateway/web/src/components/GatewayTranscript.tsx +++ b/crates/agent-gateway/web/src/components/GatewayTranscript.tsx @@ -6,6 +6,8 @@ import { useMemo, useRef, useState, + type Dispatch, + type SetStateAction, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Check, CheckCircle2, ChevronDown, Copy, File, FileText, Loader2, Pencil, Settings, X } from "./icons"; @@ -796,6 +798,121 @@ function useGatewayCommitDetailsLoader( ); } +// Shared user-row body: the bubble plus hover actions (copy / edit), or the +// inline editor while this row is being edited. Both transcript regions +// render it; the per-row copied/editing state lives in the owning region so +// folds and conversation switches reset it there. +function GatewayUserMessageRowBody(props: { + row: Extract; + isStreaming: boolean; + readOnly?: boolean; + copiedMessageId: string | null; + editingMessageId: string | null; + setCopiedMessageId: Dispatch>; + setEditingMessageId: Dispatch>; + workspaceRoot?: string; + onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + loadCommitDetails?: CommitDetailsLoader; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; +}) { + const { + row, + isStreaming, + readOnly = false, + copiedMessageId, + editingMessageId, + setCopiedMessageId, + setEditingMessageId, + workspaceRoot, + onLoadUploadedImagePreview, + loadCommitDetails, + onResendFromEdit, + } = props; + const { locale, t } = useLocale(); + const isCopied = copiedMessageId === row.key; + const isEditing = editingMessageId === row.key; + const effectiveMessageRef = row.messageRef; + const missingStableRef = !effectiveMessageRef; + const editDisabled = readOnly || isStreaming || !onResendFromEdit || missingStableRef; + const editTitle = missingStableRef + ? locale === "en-US" + ? "This older message cannot be edited because it has no stable message identifier." + : "旧历史缺少稳定消息标识,无法编辑重发" + : t("chat.edit"); + + if (isEditing && effectiveMessageRef) { + return ( + setEditingMessageId(null)} + onSubmit={(text, attachments) => { + setEditingMessageId(null); + onResendFromEdit?.(effectiveMessageRef, text, attachments); + }} + /> + ); + } + + return ( +
+ + {!readOnly ? ( +
+ + +
+ ) : null} +
+ ); +} + const GatewayTranscriptFoldedRegion = memo(function GatewayTranscriptFoldedRegion(props: { conversationId?: string; rows: readonly TranscriptRow[]; @@ -834,7 +951,7 @@ const GatewayTranscriptFoldedRegion = memo(function GatewayTranscriptFoldedRegio readOnly = false, redactToolContent = false, } = props; - const { locale, t } = useLocale(); + const { locale } = useLocale(); const [copiedMessageId, setCopiedMessageId] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const historyIdentityKey = `${conversationId ?? ""}\n${rows[0]?.key ?? ""}`; @@ -928,16 +1045,6 @@ const GatewayTranscriptFoldedRegion = memo(function GatewayTranscriptFoldedRegio const row = virtualItem.row; if (row.kind === "user") { - const isCopied = copiedMessageId === row.key; - const isEditing = editingMessageId === row.key; - const effectiveMessageRef = row.messageRef; - const missingStableRef = !effectiveMessageRef; - const editDisabled = readOnly || isStreaming || !onResendFromEdit || missingStableRef; - const editTitle = missingStableRef - ? locale === "en-US" - ? "This older message cannot be edited because it has no stable message identifier." - : "旧历史缺少稳定消息标识,无法编辑重发" - : t("chat.edit"); return (
- {isEditing && effectiveMessageRef ? ( - setEditingMessageId(null)} - onSubmit={(text, attachments) => { - setEditingMessageId(null); - onResendFromEdit?.(effectiveMessageRef, text, attachments); - }} - /> - ) : ( -
- - {!readOnly ? ( -
- - -
- ) : null} -
- )} +
); } @@ -1084,6 +1141,11 @@ const GatewayTranscriptLiveRegion = memo(function GatewayTranscriptLiveRegion(pr workspaceRoot?: string; gitClient?: GitClient | null; onLoadUploadedImagePreview?: UploadedImagePreviewLoader; + onResendFromEdit?: ( + messageRef: HistoryMessageRef, + text: string, + uploadedFiles: PendingUploadedFile[], + ) => void; toolStatus?: string | null; toolStatusIsCompaction: boolean; }) { @@ -1098,10 +1160,25 @@ const GatewayTranscriptLiveRegion = memo(function GatewayTranscriptLiveRegion(pr workspaceRoot, gitClient, onLoadUploadedImagePreview, + onResendFromEdit, toolStatus, toolStatusIsCompaction, } = props; + const [copiedMessageId, setCopiedMessageId] = useState(null); + const [editingMessageId, setEditingMessageId] = useState(null); const loadCommitDetails = useGatewayCommitDetailsLoader(workspaceRoot, gitClient); + + useEffect(() => { + if (!editingMessageId) { + return; + } + const hasEditingRow = rows.some( + (row) => row.kind === "user" && row.key === editingMessageId, + ); + if (!hasEditingRow) { + setEditingMessageId(null); + } + }, [editingMessageId, rows]); // The live article: the streaming turn's trailing assistant row while a // run is active, else the trailing assistant row of the flow. It keeps its // in-flight structural state regardless of `isStreaming`: it stays in the @@ -1150,15 +1227,18 @@ const GatewayTranscriptLiveRegion = memo(function GatewayTranscriptLiveRegion(pr if (row.kind === "user") { return (
-
- -
+
); } @@ -1390,6 +1470,7 @@ export function GatewayTranscript({ workspaceRoot={workspaceRoot} gitClient={gitClient} onLoadUploadedImagePreview={onLoadUploadedImagePreview} + onResendFromEdit={onResendFromEdit} toolStatus={toolStatus} toolStatusIsCompaction={toolStatusIsCompaction} /> diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx deleted file mode 100644 index 83f40fa5..00000000 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocale } from "@/i18n"; -import { cn } from "@/lib/shared/utils"; -import { AlertTriangle, ImageIcon, ImageOff, Loader2, RefreshCw, X } from "../icons"; - -export type WorkspaceImagePreviewOpenRequest = { - id: number; - projectPathKey: string; - workdir: string; - path: string; -}; - -type ReadWorkspaceImageResponse = { - path: string; - mimeType: string; - data: string; - sizeBytes: number; -}; - -type WorkspaceImagePreviewOverlayProps = { - openRequest: WorkspaceImagePreviewOpenRequest | null; - isOpen: boolean; - onRequestClose: () => void; - onClose: () => void; -}; - -const IMAGE_PREVIEW_OVERLAY_ANIMATION_MS = 180; - -function basename(path: string) { - const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); - const index = normalized.lastIndexOf("/"); - return index >= 0 ? normalized.slice(index + 1) : normalized; -} - -function formatBytes(bytes: number) { - if (!Number.isFinite(bytes) || bytes < 0) return ""; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function toMessage(error: unknown, fallback: string) { - if (error instanceof Error && error.message.trim()) return error.message; - const text = String(error ?? "").trim(); - return text || fallback; -} - -export function WorkspaceImagePreviewOverlay(props: WorkspaceImagePreviewOverlayProps) { - const { openRequest, isOpen, onRequestClose, onClose } = props; - const { t } = useLocale(); - const closeAnimationTimeoutRef = useRef(null); - const loadSequenceRef = useRef(0); - const [image, setImage] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - if (isOpen) { - if (closeAnimationTimeoutRef.current !== null) { - window.clearTimeout(closeAnimationTimeoutRef.current); - closeAnimationTimeoutRef.current = null; - } - const animationFrame = window.requestAnimationFrame(() => setIsVisible(true)); - return () => window.cancelAnimationFrame(animationFrame); - } - - setIsVisible(false); - closeAnimationTimeoutRef.current = window.setTimeout(() => { - closeAnimationTimeoutRef.current = null; - onClose(); - }, IMAGE_PREVIEW_OVERLAY_ANIMATION_MS); - }, [isOpen, onClose]); - - useEffect( - () => () => { - if (closeAnimationTimeoutRef.current !== null) { - window.clearTimeout(closeAnimationTimeoutRef.current); - } - }, - [], - ); - - const loadImage = useCallback( - async (request: WorkspaceImagePreviewOpenRequest) => { - const sequence = loadSequenceRef.current + 1; - loadSequenceRef.current = sequence; - setLoading(true); - setError(null); - setImage(null); - try { - const response = await invoke("fs_read_workspace_image", { - workdir: request.workdir, - path: request.path, - }); - if (loadSequenceRef.current !== sequence) return; - setImage(response); - } catch (loadError) { - if (loadSequenceRef.current !== sequence) return; - setImage(null); - setError(toMessage(loadError, t("workspaceImagePreview.openFailed"))); - } finally { - if (loadSequenceRef.current === sequence) { - setLoading(false); - } - } - }, - [t], - ); - - useEffect(() => { - if (!openRequest) return; - void loadImage(openRequest); - }, [loadImage, openRequest]); - - const source = image ? `data:${image.mimeType};base64,${image.data}` : ""; - const activePath = image?.path ?? openRequest?.path ?? ""; - - return ( -
-
- -
-
- {t("workspaceImagePreview.title")} -
-
{activePath}
-
-
- - -
-
- - {error ? ( -
- -
{error}
-
- ) : null} - -
- {loading ? ( - - ) : source ? ( - {basename(activePath)} - ) : ( -
- - {t("workspaceImagePreview.empty")} -
- )} -
- -
- {activePath} - {image ? ( - - {image.mimeType} · {formatBytes(image.sizeBytes)} - - ) : null} -
-
- ); -} diff --git a/crates/agent-gateway/web/src/lib/chat/chatHistory.ts b/crates/agent-gateway/web/src/lib/chat/chatHistory.ts index e2fb97f1..904c9566 100644 --- a/crates/agent-gateway/web/src/lib/chat/chatHistory.ts +++ b/crates/agent-gateway/web/src/lib/chat/chatHistory.ts @@ -1,15 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; -import type { Message } from "../agentTypes"; - -import { - normalizeConversationState, - type ConversationViewState, - type StoredChatContextMeta, - type StoredContextSegment, - type StoredSummaryMessage, -} from "./conversationState"; -import { normalizeConversationSystemPrompt } from "./systemPrompt"; - +// Conversation summary shape shared with the gateway's history.list / +// history.event payloads. The webui only consumes summaries — transcript +// content arrives as messages_json parsed by lib/chatUi. The full history +// store (persistence, wire records, Tauri commands) lives in agent-gui. export type ChatHistorySummary = { id: string; title: string; @@ -25,427 +17,3 @@ export type ChatHistorySummary = { isShared?: boolean; isPending?: boolean; }; - -export type ChatHistoryListPage = { - items: ChatHistorySummary[]; - totalCount: number; -}; - -export type ChatHistoryListFilter = { - cwd?: string; - cwdEmpty?: boolean; -}; - -export type ChatHistoryWorkdirSummary = { - path: string; - conversationCount: number; - updatedAt: number; -}; - -export type ChatHistoryWorkdirsResponse = { - workdirs: ChatHistoryWorkdirSummary[]; -}; - -type ChatHistorySegmentWireRecord = { - segmentIndex: number; - segmentId: string; - summaryJson?: string | null; - messagesJson: string; - messageCount: number; - startMessageId?: string; - endMessageId?: string; - createdAt: number; - updatedAt: number; -}; - -type ChatHistoryWireRecord = ChatHistorySummary & { - contextMetaJson: string; - activeSegmentIndex: number; - totalSegmentCount: number; - totalMessageCount: number; - segments: ChatHistorySegmentWireRecord[]; -}; - -type ChatHistoryActiveSegmentWireRecord = ChatHistorySummary & { - contextMetaJson: string; - activeSegmentIndex: number; - totalSegmentCount: number; - totalMessageCount: number; - activeSegment: ChatHistorySegmentWireRecord; -}; - -export type ChatHistoryRecord = ChatHistorySummary & { - state: ConversationViewState; -}; - -export type ChatHistoryActiveSegmentRecord = ChatHistorySummary & { - meta: StoredChatContextMeta; - activeSegment: StoredContextSegment; -}; - -const conversationWriteQueues = new Map>(); - -type ChatHistoryUpsertInput = { - id: string; - title: string; - providerId: string; - model: string; - sessionId?: string; - cwd?: string; - contextMetaJson: string; - activeSegmentIndex: number; - totalSegmentCount: number; - totalMessageCount: number; - segments: ChatHistorySegmentWireRecord[]; - createdAt?: number; - updatedAt: number; -}; - -type ChatHistoryConversationInput = Omit; - -type ChatHistorySegmentMutationInput = { - conversation: ChatHistoryConversationInput; - segment: ChatHistorySegmentWireRecord; -}; - -function isMessageArray(value: unknown): value is Message[] { - return Array.isArray(value); -} - -function parseStoredSummaryMessage(raw: string): StoredSummaryMessage { - const parsed = JSON.parse(raw) as StoredSummaryMessage | null; - if ( - !parsed || - parsed.role !== "summary" || - typeof parsed.id !== "string" || - typeof parsed.content !== "string" - ) { - throw new Error("历史摘要数据格式无效"); - } - return parsed; -} - -function parseStoredChatContextMeta( - raw: string, - fallbackSystemPrompt?: string, -): StoredChatContextMeta { - const parsed = JSON.parse(raw) as Partial | null; - if (!parsed || typeof parsed !== "object") { - throw new Error("历史上下文元数据格式无效"); - } - - const systemPrompt = normalizeConversationSystemPrompt( - typeof parsed.systemPrompt === "string" ? parsed.systemPrompt : fallbackSystemPrompt, - ); - - return { - schemaVersion: 3, - systemPrompt, - tools: Array.isArray(parsed.tools) ? parsed.tools : undefined, - activeSegmentIndex: - typeof parsed.activeSegmentIndex === "number" ? parsed.activeSegmentIndex : 0, - totalSegmentCount: - typeof parsed.totalSegmentCount === "number" ? parsed.totalSegmentCount : 1, - totalMessageCount: - typeof parsed.totalMessageCount === "number" ? parsed.totalMessageCount : 0, - }; -} - -function parseStoredSegment(record: ChatHistorySegmentWireRecord): StoredContextSegment { - const parsedMessages = JSON.parse(record.messagesJson) as unknown; - if (!isMessageArray(parsedMessages)) { - throw new Error("历史分段消息格式无效"); - } - - return { - segmentIndex: record.segmentIndex, - segmentId: record.segmentId, - summary: record.summaryJson ? parseStoredSummaryMessage(record.summaryJson) : undefined, - messages: parsedMessages, - messageCount: record.messageCount, - startMessageId: record.startMessageId, - endMessageId: record.endMessageId, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }; -} - -function normalizeWireRecord( - record: ChatHistoryWireRecord, - fallbackSystemPrompt?: string, -): ChatHistoryRecord { - if (record.segments.length === 0) { - throw new Error("历史对话缺少分段数据"); - } - const meta = parseStoredChatContextMeta(record.contextMetaJson, fallbackSystemPrompt); - const segments = record.segments.map(parseStoredSegment); - const state: ConversationViewState = normalizeConversationState({ meta, segments }); - - return { - id: record.id, - title: record.title, - providerId: record.providerId, - model: record.model, - sessionId: record.sessionId, - cwd: record.cwd, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - isPinned: record.isPinned, - pinnedAt: record.pinnedAt, - state, - }; -} - -export async function listChatHistory( - page: number, - pageSize: number, - filter?: ChatHistoryListFilter, -) { - return invoke("chat_history_list", { - page, - pageSize, - cwd: filter?.cwd, - cwdEmpty: filter?.cwdEmpty, - }); -} - -export async function listChatHistoryWorkdirs() { - return invoke("chat_history_workdirs"); -} - -export async function getChatHistory(id: string, fallbackSystemPrompt?: string) { - const record = await invoke("chat_history_get", { id }); - return normalizeWireRecord(record, fallbackSystemPrompt); -} - -export async function getChatHistoryActiveSegment( - id: string, - fallbackSystemPrompt?: string, -) { - const record = await invoke( - "chat_history_get_active_segment", - { id }, - ); - if (!record.activeSegment) { - throw new Error("历史对话缺少活跃分段"); - } - - return { - id: record.id, - title: record.title, - providerId: record.providerId, - model: record.model, - sessionId: record.sessionId, - cwd: record.cwd, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - isPinned: record.isPinned, - pinnedAt: record.pinnedAt, - meta: parseStoredChatContextMeta(record.contextMetaJson, fallbackSystemPrompt), - activeSegment: parseStoredSegment(record.activeSegment), - } satisfies ChatHistoryActiveSegmentRecord; -} - -function enqueueConversationWrite( - conversationId: string, - task: () => Promise, -): Promise { - const key = conversationId.trim(); - if (!key) { - return task(); - } - - const previous = conversationWriteQueues.get(key) ?? Promise.resolve(); - const next = previous - .catch(() => undefined) - .then(task); - const tail = next.then( - () => undefined, - () => undefined, - ); - conversationWriteQueues.set(key, tail); - return next.finally(() => { - if (conversationWriteQueues.get(key) === tail) { - conversationWriteQueues.delete(key); - } - }); -} - -function buildChatHistoryConversationInput(params: { - conversationId: string; - providerId: string; - model: string; - sessionId?: string; - cwd?: string; - title: string; - createdAt?: number; - updatedAt: number; - state: ConversationViewState; -}): ChatHistoryConversationInput { - const { - conversationId, - providerId, - model, - sessionId, - cwd, - title, - createdAt, - updatedAt, - state, - } = params; - - return { - id: conversationId, - title, - providerId, - model, - sessionId, - cwd, - contextMetaJson: JSON.stringify(state.meta), - activeSegmentIndex: state.activeSegmentIndex, - totalSegmentCount: state.meta.totalSegmentCount, - totalMessageCount: state.meta.totalMessageCount, - createdAt, - updatedAt, - }; -} - -function buildChatHistorySegmentInput(segment: StoredContextSegment): ChatHistorySegmentWireRecord { - return { - segmentIndex: segment.segmentIndex, - segmentId: segment.segmentId, - summaryJson: segment.summary ? JSON.stringify(segment.summary) : undefined, - messagesJson: JSON.stringify(segment.messages), - messageCount: segment.messageCount, - startMessageId: segment.startMessageId, - endMessageId: segment.endMessageId, - createdAt: segment.createdAt, - updatedAt: segment.updatedAt, - }; -} - -export async function upsertChatHistory(input: ChatHistoryUpsertInput) { - return enqueueConversationWrite(input.id, () => - invoke("chat_history_upsert", { input }), - ); -} - -async function upsertChatHistoryActiveSegment(input: ChatHistorySegmentMutationInput) { - return enqueueConversationWrite(input.conversation.id, () => - invoke("chat_history_upsert_active_segment", { input }), - ); -} - -async function appendChatHistorySegment(input: ChatHistorySegmentMutationInput) { - return enqueueConversationWrite(input.conversation.id, () => - invoke("chat_history_append_segment", { input }), - ); -} - -export async function renameChatHistory(id: string, title: string) { - return enqueueConversationWrite(id, () => - invoke("chat_history_rename", { id, title }), - ); -} - -export async function deleteChatHistory(id: string) { - return enqueueConversationWrite(id, async () => { - await invoke("chat_history_delete", { id }); - }); -} - -function segmentPrefixMatches( - previous: StoredContextSegment[], - next: StoredContextSegment[], - count: number, -) { - if (count < 0 || previous.length < count || next.length < count) return false; - for (let index = 0; index < count; index += 1) { - const prevSegment = previous[index]; - const nextSegment = next[index]; - if ( - prevSegment.segmentIndex !== nextSegment.segmentIndex || - prevSegment.segmentId !== nextSegment.segmentId || - prevSegment.messageCount !== nextSegment.messageCount || - prevSegment.startMessageId !== nextSegment.startMessageId || - prevSegment.endMessageId !== nextSegment.endMessageId || - prevSegment.createdAt !== nextSegment.createdAt || - prevSegment.updatedAt !== nextSegment.updatedAt || - prevSegment.summary?.id !== nextSegment.summary?.id - ) { - return false; - } - } - return true; -} - -type PersistConversationStateParams = { - conversationId: string; - providerId: string; - model: string; - sessionId?: string; - cwd?: string; - title: string; - createdAt?: number; - updatedAt: number; - state: ConversationViewState; - previousState?: ConversationViewState | null; -}; - -export async function persistConversationState(params: PersistConversationStateParams) { - const conversation = buildChatHistoryConversationInput(params); - const previousState = params.previousState ?? null; - const nextState = params.state; - - if (!previousState) { - return upsertChatHistory({ - ...conversation, - segments: nextState.segments.map(buildChatHistorySegmentInput), - }); - } - - const sameShape = - previousState.activeSegmentIndex === nextState.activeSegmentIndex && - previousState.segments.length === nextState.segments.length; - if ( - sameShape && - segmentPrefixMatches( - previousState.segments, - nextState.segments, - Math.max(0, nextState.activeSegmentIndex), - ) - ) { - const activeSegment = nextState.segments[nextState.activeSegmentIndex]; - if (activeSegment) { - return upsertChatHistoryActiveSegment({ - conversation, - segment: buildChatHistorySegmentInput(activeSegment), - }); - } - } - - const appendShape = - nextState.activeSegmentIndex === previousState.activeSegmentIndex + 1 && - nextState.segments.length === previousState.segments.length + 1; - if ( - appendShape && - segmentPrefixMatches( - previousState.segments, - nextState.segments, - previousState.segments.length, - ) - ) { - const appendedSegment = nextState.segments[nextState.activeSegmentIndex]; - if (appendedSegment) { - return appendChatHistorySegment({ - conversation, - segment: buildChatHistorySegmentInput(appendedSegment), - }); - } - } - - return upsertChatHistory({ - ...conversation, - segments: nextState.segments.map(buildChatHistorySegmentInput), - }); -} diff --git a/crates/agent-gateway/web/src/lib/chat/conversationState.ts b/crates/agent-gateway/web/src/lib/chat/conversationState.ts index 98bebd22..547bb368 100644 --- a/crates/agent-gateway/web/src/lib/chat/conversationState.ts +++ b/crates/agent-gateway/web/src/lib/chat/conversationState.ts @@ -1,67 +1,9 @@ -import type { AssistantMessage, Context, Message } from "../agentTypes"; - -import { assistantMessageToText } from "../providers/llm"; -import { normalizeConversationSystemPrompt } from "./systemPrompt"; -import { - sanitizeMessagesForContinuation, - sanitizeMessagesForModelContext, -} from "./requestContextSanitizer"; -import { buildUiMessages, type UiRound } from "./uiMessages"; -import { - getUserMessageAttachments, - getUserMessageDisplayText, - stripUploadedFilesMessageMetadata, - type PendingUploadedFile, -} from "./uploadedFiles"; - -const INTERNAL_RESUME_MESSAGE_TEXT = - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."; - -export type StoredSummaryMessage = { - role: "summary"; - id: string; - timestamp: number; - content: string; - summaryMeta: { - format: "plain-text-v1"; - strategy: "cumulative-checkpoint"; - coversThroughMessageId: string; - coveredMessageCount: number; - basedOnSummaryMessageId?: string; - generatedBy: { - providerId: string; - model: string; - promptVersion?: string; - }; - stats?: { - sourceMessageCount: number; - estimatedInputTokens?: number; - outputTokens?: number; - }; - }; -}; - -export type StoredChatContextMeta = { - schemaVersion: 3; - systemPrompt?: string; - tools?: Context["tools"]; - activeSegmentIndex: number; - totalSegmentCount: number; - totalMessageCount: number; -}; - -export type StoredContextSegment = { - segmentIndex: number; - segmentId: string; - summary?: StoredSummaryMessage; - messages: Message[]; - messageCount: number; - startMessageId?: string; - endMessageId?: string; - createdAt: number; - updatedAt: number; -}; - +// Stable identity of a persisted user message, used to anchor edit-resend +// (`chat.edit_resend` base_message_ref, `rebased` truncation events). Wire +// form is snake_case (see gatewaySocket buildHistoryMessageRefPayload); the +// desktop validates segment_id + message_id + role + content_hash before +// truncating. The full segmented conversation state machine lives in +// agent-gui — the webui renders from its TranscriptStore instead. export type HistoryMessageRef = { segmentIndex: number; messageIndex: number; @@ -70,737 +12,3 @@ export type HistoryMessageRef = { role: string; contentHash: string; }; - -export type RenderSummaryCard = { - kind: "summary"; - key: string; - segmentIndex: number; - summaryId: string; - content: string; - coveredMessageCount: number; - coversThroughMessageId: string; - generatedBy: { - providerId: string; - model: string; - promptVersion?: string; - }; - timestamp: number; - collapsed: boolean; -}; - -export type RenderUserMessage = { - kind: "user"; - key: string; - segmentIndex: number; - messageRef?: HistoryMessageRef; - text: string; - attachments: PendingUploadedFile[]; - timestamp: number; - isFromCompactedSegment: boolean; -}; - -export type RenderAssistantGroup = { - kind: "assistant"; - key: string; - segmentIndex: number; - rounds: UiRound[]; - timestamp: number; - isFromCompactedSegment: boolean; -}; - -export type RenderTimelineItem = - | RenderSummaryCard - | RenderUserMessage - | RenderAssistantGroup; - -export type ConversationViewState = { - meta: StoredChatContextMeta; - segments: StoredContextSegment[]; - historyRenderItems: RenderTimelineItem[]; - activeSegmentIndex: number; -}; - -function createEmptySegment(index: number, timestamp = Date.now()): StoredContextSegment { - return { - segmentIndex: index, - segmentId: crypto.randomUUID(), - messages: [], - messageCount: 0, - createdAt: timestamp, - updatedAt: timestamp, - }; -} - -function isCompactionAssistantMessage(message: Message): message is AssistantMessage { - return ( - message.role === "assistant" && - (message.api === "liveagent-compaction" || - (message.provider === "liveagent" && message.model === "summary")) - ); -} - -function isRuntimeHistoryMessage(message: Message) { - if (message.role === "assistant") return !isCompactionAssistantMessage(message); - return message.role === "user" || message.role === "toolResult"; -} - -function getMessageTimestamp(message: Message | undefined) { - if (!message) return Date.now(); - return typeof message.timestamp === "number" ? message.timestamp : Date.now(); -} - -function readMessageStringId(message: Message | undefined) { - if (!message) return undefined; - const rawId = (message as { id?: unknown }).id; - if (typeof rawId === "string" && rawId.trim()) { - return rawId.trim(); - } - if (message.role === "assistant" && typeof message.responseId === "string") { - const responseId = message.responseId.trim(); - if (responseId) return responseId; - } - return undefined; -} - -function getMessageStableId( - message: Message | undefined, - segmentIndex: number, - messageIndex: number, -) { - const candidate = readMessageStringId(message); - if (candidate) return candidate; - return `segment-${segmentIndex}-message-${messageIndex}-${getMessageTimestamp(message)}`; -} - -function appendHashPart(parts: string[], value: unknown) { - const text = String(value ?? ""); - const byteLength = - typeof TextEncoder === "function" - ? new TextEncoder().encode(text).length - : text.length; - parts.push(`${byteLength}:${text}`); -} - -function hashFnv1a32(input: string) { - const bytes = - typeof TextEncoder === "function" - ? new TextEncoder().encode(input) - : Uint8Array.from(input, (char) => char.charCodeAt(0) & 0xff); - let hash = 0x811c9dc5; - for (const byte of bytes) { - hash ^= byte; - hash = Math.imul(hash, 0x01000193) >>> 0; - } - return `fnv1a32:${hash.toString(16).padStart(8, "0")}`; -} - -export function getHistoryMessageContentHash(message: Message): string { - const parts = ["liveagent-history-ref-v1"]; - appendHashPart(parts, message.role); - if (message.role === "user") { - appendHashPart(parts, getUserMessageDisplayText(message as Message & Record)); - const attachments = getUserMessageAttachments(message as Message & Record); - appendHashPart(parts, attachments.length); - for (const file of attachments) { - appendHashPart(parts, file.relativePath); - appendHashPart(parts, file.fileName); - appendHashPart(parts, file.kind); - appendHashPart(parts, file.sizeBytes); - } - } else { - appendHashPart(parts, JSON.stringify(message.content ?? null)); - } - return hashFnv1a32(parts.join("|")); -} - -function buildHistoryMessageRef(params: { - segment: StoredContextSegment; - message: Message | undefined; - messageIndex: number; -}): HistoryMessageRef | undefined { - const { segment, message, messageIndex } = params; - if (!message) return undefined; - const segmentId = segment.segmentId?.trim(); - const messageId = readMessageStringId(message); - const role = typeof message.role === "string" ? message.role.trim() : ""; - if (!segmentId || !messageId || !role) return undefined; - return { - segmentIndex: segment.segmentIndex, - messageIndex, - segmentId, - messageId, - role, - contentHash: getHistoryMessageContentHash(message), - }; -} - -function messageMatchesHistoryRef( - segment: StoredContextSegment, - message: Message | undefined, - messageIndex: number, - ref: HistoryMessageRef, -) { - if (!message || segment.segmentId !== ref.segmentId) return false; - const messageId = readMessageStringId(message); - if (!messageId || messageId !== ref.messageId) return false; - if (message.role !== ref.role) return false; - if (getHistoryMessageContentHash(message) !== ref.contentHash) return false; - return messageIndex >= 0; -} - -function locateHistoryMessageRef(state: ConversationViewState, ref: HistoryMessageRef) { - if (ref.role !== "user") { - throw new Error("edit-resend only supports user message refs."); - } - const hintedSegment = state.segments[ref.segmentIndex]; - const targetSegment = - hintedSegment?.segmentId === ref.segmentId - ? hintedSegment - : state.segments.find((segment) => segment.segmentId === ref.segmentId); - if (!targetSegment) { - throw new Error("edit-resend base_message_ref segment was not found."); - } - const segmentArrayIndex = state.segments.indexOf(targetSegment); - const hintedMessage = targetSegment.messages[ref.messageIndex]; - if (messageMatchesHistoryRef(targetSegment, hintedMessage, ref.messageIndex, ref)) { - return { segmentArrayIndex, messageIndex: ref.messageIndex }; - } - const messageIndex = targetSegment.messages.findIndex((message, index) => - messageMatchesHistoryRef(targetSegment, message, index, ref), - ); - if (messageIndex < 0) { - throw new Error("edit-resend base_message_ref message failed stable identity validation."); - } - return { segmentArrayIndex, messageIndex }; -} - -function getSummaryId(summary: StoredSummaryMessage | undefined) { - return summary?.id; -} - -function countMessages(segments: StoredContextSegment[]) { - return segments.reduce((sum, segment) => sum + segment.messages.length, 0); -} - -function buildConversationMeta(params: { - systemPrompt?: string; - tools?: Context["tools"]; - segments: StoredContextSegment[]; - activeSegmentIndex?: number; -}): StoredChatContextMeta { - const activeSegmentIndex = - typeof params.activeSegmentIndex === "number" - ? Math.max(0, Math.min(params.activeSegmentIndex, Math.max(0, params.segments.length - 1))) - : Math.max(0, params.segments.length - 1); - const systemPrompt = normalizeConversationSystemPrompt(params.systemPrompt); - return { - schemaVersion: 3, - systemPrompt, - tools: params.tools, - activeSegmentIndex, - totalSegmentCount: params.segments.length, - totalMessageCount: countMessages(params.segments), - }; -} - -function getAssistantPromptVersion(assistant: AssistantMessage) { - const candidate = (assistant as AssistantMessage & { promptVersion?: unknown }).promptVersion; - return typeof candidate === "string" && candidate.trim() ? candidate.trim() : "summary-v1"; -} - -function appendCompactionCheckpointToSegments( - segments: StoredContextSegment[], - activeSegmentIndex: number, - checkpointMessage: AssistantMessage, -) { - const coveredMessageCount = countMessages(segments); - if (coveredMessageCount === 0) { - return { - activeSegmentIndex, - appended: false, - }; - } - - const previousSegment = segments[activeSegmentIndex]; - if (!previousSegment || previousSegment.messages.length === 0) { - return { - activeSegmentIndex, - appended: false, - }; - } - - const previousMessageIndex = Math.max(0, previousSegment.messages.length - 1); - const coversThroughMessageId = - previousSegment.endMessageId || - getMessageStableId( - previousSegment.messages[previousMessageIndex], - activeSegmentIndex, - previousMessageIndex, - ); - const nextSegmentIndex = segments.length; - const nextSegment = createEmptySegment( - nextSegmentIndex, - checkpointMessage.timestamp ?? Date.now(), - ); - nextSegment.summary = createSummaryFromAssistant(checkpointMessage, { - segmentIndex: nextSegmentIndex, - coveredMessageCount, - coversThroughMessageId, - basedOnSummaryMessageId: getSummaryId(previousSegment.summary), - sourceMessageCount: previousSegment.messages.length, - }); - segments.push(nextSegment); - - return { - activeSegmentIndex: nextSegmentIndex, - appended: true, - }; -} - -function createSummaryFromAssistant( - assistant: AssistantMessage, - params: { - segmentIndex: number; - coveredMessageCount: number; - coversThroughMessageId: string; - basedOnSummaryMessageId?: string; - sourceMessageCount: number; - }, -): StoredSummaryMessage { - const content = assistantMessageToText(assistant).trim(); - const summaryId = - (typeof assistant.responseId === "string" && assistant.responseId.trim()) || - `summary-${params.segmentIndex}-${assistant.timestamp ?? Date.now()}`; - - return { - role: "summary", - id: summaryId, - timestamp: assistant.timestamp ?? Date.now(), - content, - summaryMeta: { - format: "plain-text-v1", - strategy: "cumulative-checkpoint", - coversThroughMessageId: params.coversThroughMessageId, - coveredMessageCount: params.coveredMessageCount, - basedOnSummaryMessageId: params.basedOnSummaryMessageId, - generatedBy: { - providerId: - typeof assistant.provider === "string" && assistant.provider.trim() - ? assistant.provider.trim() - : "liveagent", - model: - typeof assistant.model === "string" && assistant.model.trim() - ? assistant.model.trim() - : "summary", - promptVersion: getAssistantPromptVersion(assistant), - }, - stats: { - sourceMessageCount: params.sourceMessageCount, - estimatedInputTokens: - typeof assistant.usage?.input === "number" ? assistant.usage.input : undefined, - outputTokens: - typeof assistant.usage?.output === "number" ? assistant.usage.output : undefined, - }, - }, - }; -} - -function normalizeSegment( - segment: StoredContextSegment, - segmentIndex: number, -): StoredContextSegment { - const messages = segment.messages.filter((message, messageIndex) => { - if (!isRuntimeHistoryMessage(message)) return false; - if ( - segment.summary && - messageIndex === 0 && - message.role === "user" && - typeof message.content === "string" && - message.content.trim() === INTERNAL_RESUME_MESSAGE_TEXT - ) { - return false; - } - return true; - }); - const messageCount = messages.length; - const startMessageId = - messageCount > 0 ? getMessageStableId(messages[0], segmentIndex, 0) : undefined; - const endMessageId = - messageCount > 0 - ? getMessageStableId(messages[messageCount - 1], segmentIndex, messageCount - 1) - : undefined; - const updatedAt = - messageCount > 0 - ? getMessageTimestamp(messages[messageCount - 1]) - : segment.updatedAt || segment.createdAt || Date.now(); - - return { - segmentIndex, - segmentId: segment.segmentId || crypto.randomUUID(), - summary: segment.summary, - messages, - messageCount, - startMessageId, - endMessageId, - createdAt: segment.createdAt || updatedAt, - updatedAt, - }; -} - -export function appendSummaryToSystemPrompt( - baseSystemPrompt: string | undefined, - summaryContent: string | undefined, -) { - if (!summaryContent?.trim()) return baseSystemPrompt; - - const summaryBlock = [ - "", - "## Previous Conversation Summary", - "", - "The following is a compressed summary of the earlier conversation. Use it to understand the context,", - "but do not repeat work that has already been completed.", - "", - summaryContent.trim(), - "", - ].join("\n"); - - const base = (baseSystemPrompt || "").trim(); - return base ? `${base}\n${summaryBlock}` : summaryBlock.trim(); -} - -export function flattenSegmentsToTimeline( - segments: StoredContextSegment[], - activeSegmentIndex: number, -): RenderTimelineItem[] { - const items: RenderTimelineItem[] = []; - - for (const segment of segments) { - items.push(...buildTimelineItemsForSegment(segment, segment.segmentIndex < activeSegmentIndex)); - } - - return items; -} - -function buildTimelineItemsForSegment( - segment: StoredContextSegment, - isCompacted: boolean, -): RenderTimelineItem[] { - const items: RenderTimelineItem[] = []; - - if (segment.summary) { - items.push({ - kind: "summary", - key: `summary-${segment.segmentIndex}-${segment.summary.id}`, - segmentIndex: segment.segmentIndex, - summaryId: segment.summary.id, - content: segment.summary.content, - coveredMessageCount: segment.summary.summaryMeta.coveredMessageCount, - coversThroughMessageId: segment.summary.summaryMeta.coversThroughMessageId, - generatedBy: segment.summary.summaryMeta.generatedBy, - timestamp: segment.summary.timestamp, - collapsed: true, - }); - } - - const uiMessages = buildUiMessages(segment.messages); - for (const uiMessage of uiMessages) { - if (uiMessage.role === "user") { - const localMessageIndex = uiMessage.messageIndex ?? 0; - const source = segment.messages[localMessageIndex]; - const messageRef = buildHistoryMessageRef({ - segment, - message: source, - messageIndex: localMessageIndex, - }); - items.push({ - kind: "user", - key: `segment-${segment.segmentIndex}-${uiMessage.key}`, - segmentIndex: segment.segmentIndex, - messageRef, - text: uiMessage.text, - attachments: uiMessage.attachments ?? [], - timestamp: getMessageTimestamp(source), - isFromCompactedSegment: isCompacted, - }); - continue; - } - - items.push({ - kind: "assistant", - key: `segment-${segment.segmentIndex}-${uiMessage.key}`, - segmentIndex: segment.segmentIndex, - rounds: uiMessage.rounds ?? [], - timestamp: getMessageTimestamp(segment.messages[segment.messages.length - 1]), - isFromCompactedSegment: isCompacted, - }); - } - - return items; -} - -function markTimelineItemCompacted(item: RenderTimelineItem): RenderTimelineItem { - if (item.kind === "summary" || item.isFromCompactedSegment) { - return item; - } - - return { - ...item, - isFromCompactedSegment: true, - }; -} - -function buildUpdatedTimelineForActiveSegment(params: { - previousItems: RenderTimelineItem[]; - segments: StoredContextSegment[]; - activeSegmentIndex: number; -}) { - const { previousItems, segments, activeSegmentIndex } = params; - const preserved = previousItems - .filter((item) => item.segmentIndex < activeSegmentIndex) - .map(markTimelineItemCompacted); - const activeSegment = segments[activeSegmentIndex]; - const activeItems = activeSegment - ? buildTimelineItemsForSegment(activeSegment, false) - : []; - return [...preserved, ...activeItems]; -} - -function rebuildTimelineFromSegment(params: { - previousItems: RenderTimelineItem[]; - segments: StoredContextSegment[]; - activeSegmentIndex: number; - startSegmentIndex: number; -}) { - const { previousItems, segments, activeSegmentIndex, startSegmentIndex } = params; - const preserved = previousItems - .filter((item) => item.segmentIndex < startSegmentIndex) - .map((item) => (item.segmentIndex < activeSegmentIndex ? markTimelineItemCompacted(item) : item)); - const rebuilt = segments - .filter((segment) => segment.segmentIndex >= startSegmentIndex) - .flatMap((segment) => - buildTimelineItemsForSegment(segment, segment.segmentIndex < activeSegmentIndex), - ); - return [...preserved, ...rebuilt]; -} - -export function normalizeConversationState(input: { - meta: Pick & - Partial>; - segments: StoredContextSegment[]; -}): ConversationViewState { - const rawSegments = input.segments.length > 0 ? input.segments : [createEmptySegment(0)]; - const segments = rawSegments - .slice() - .sort((a, b) => a.segmentIndex - b.segmentIndex) - .map((segment, index) => normalizeSegment(segment, index)); - const activeSegmentIndex = Math.max(0, segments.length - 1); - const meta = buildConversationMeta({ - systemPrompt: input.meta.systemPrompt, - tools: input.meta.tools, - segments, - activeSegmentIndex, - }); - - return { - meta, - segments, - activeSegmentIndex, - historyRenderItems: flattenSegmentsToTimeline(segments, activeSegmentIndex), - }; -} - -export function createConversationStateFromContext(context: Context): ConversationViewState { - const seed = normalizeConversationState({ - meta: { - systemPrompt: context.systemPrompt, - tools: context.tools, - }, - segments: [createEmptySegment(0)], - }); - - return appendMessagesToConversation(seed, context.messages); -} - -export function buildRequestContext( - state: ConversationViewState, - options?: { includeAbortedMessages?: boolean; includeUploadedFilesMetadata?: boolean }, -): Context { - const activeSegment = state.segments[state.activeSegmentIndex] ?? createEmptySegment(0); - const runtimeMessages = options?.includeUploadedFilesMetadata - ? activeSegment.messages - : activeSegment.messages.map(stripUploadedFilesMessageMetadata); - const next: Context = { - messages: options?.includeAbortedMessages - ? sanitizeMessagesForModelContext(runtimeMessages) - : sanitizeMessagesForContinuation(runtimeMessages), - }; - - const systemPrompt = appendSummaryToSystemPrompt( - state.meta.systemPrompt, - activeSegment.summary?.content, - ); - if (typeof systemPrompt === "string") { - next.systemPrompt = systemPrompt; - } - if (Array.isArray(state.meta.tools)) { - next.tools = state.meta.tools; - } - - return next; -} - -export function getActiveSegment(state: ConversationViewState) { - return state.segments[state.activeSegmentIndex] ?? state.segments[state.segments.length - 1]; -} - -export function applyCompactionCheckpoint( - state: ConversationViewState, - checkpointMessage: AssistantMessage, -): ConversationViewState { - if (!isCompactionAssistantMessage(checkpointMessage)) { - return state; - } - return appendMessagesToConversation(state, [checkpointMessage]); -} - -export function appendMessagesToConversation( - state: ConversationViewState, - incomingMessages: Message[], -): ConversationViewState { - if (incomingMessages.length === 0) return state; - - const segments = state.segments.map((segment) => ({ - ...segment, - messages: segment.messages.slice(), - })); - let activeSegmentIndex = Math.min( - Math.max(0, state.activeSegmentIndex), - Math.max(0, segments.length - 1), - ); - const changedSegmentIndexes = new Set(); - - for (const message of incomingMessages) { - if (isCompactionAssistantMessage(message)) { - const checkpoint = appendCompactionCheckpointToSegments(segments, activeSegmentIndex, message); - if (checkpoint.appended) { - activeSegmentIndex = checkpoint.activeSegmentIndex; - changedSegmentIndexes.add(activeSegmentIndex); - } - continue; - } - - if (!isRuntimeHistoryMessage(message)) continue; - segments[activeSegmentIndex].messages.push(message); - segments[activeSegmentIndex].updatedAt = getMessageTimestamp(message); - changedSegmentIndexes.add(activeSegmentIndex); - } - - if (changedSegmentIndexes.size === 0) return state; - - const normalizedSegments = segments.map((segment, index) => - changedSegmentIndexes.has(index) ? normalizeSegment(segment, index) : segment, - ); - const meta = buildConversationMeta({ - systemPrompt: state.meta.systemPrompt, - tools: state.meta.tools, - segments: normalizedSegments, - activeSegmentIndex, - }); - - return { - meta, - segments: normalizedSegments, - activeSegmentIndex, - historyRenderItems: buildUpdatedTimelineForActiveSegment({ - previousItems: state.historyRenderItems, - segments: normalizedSegments, - activeSegmentIndex, - }), - }; -} - -export function truncateConversationFromMessage( - state: ConversationViewState, - ref: HistoryMessageRef, -): ConversationViewState { - const targetLocation = locateHistoryMessageRef(state, ref); - const targetSegment = state.segments[targetLocation.segmentArrayIndex]; - if (!targetSegment) return state; - - const segments = state.segments - .slice(0, targetLocation.segmentArrayIndex + 1) - .map((segment) => ({ - ...segment, - messages: segment.messages.slice(), - })); - const target = segments[targetLocation.segmentArrayIndex]; - const cutoff = Math.max(0, Math.min(targetLocation.messageIndex, target.messages.length)); - target.messages = target.messages.slice(0, cutoff); - target.updatedAt = - cutoff > 0 - ? getMessageTimestamp(target.messages[cutoff - 1]) - : target.createdAt; - const normalizedSegments = segments.map((segment, index) => - index === targetLocation.segmentArrayIndex ? normalizeSegment(segment, index) : segment, - ); - const activeSegmentIndex = Math.max(0, normalizedSegments.length - 1); - const meta = buildConversationMeta({ - systemPrompt: state.meta.systemPrompt, - tools: state.meta.tools, - segments: normalizedSegments, - activeSegmentIndex, - }); - - return { - meta, - segments: normalizedSegments, - activeSegmentIndex, - historyRenderItems: rebuildTimelineFromSegment({ - previousItems: state.historyRenderItems, - segments: normalizedSegments, - activeSegmentIndex, - startSegmentIndex: targetLocation.segmentArrayIndex, - }), - }; -} - -export function replaceActiveSegmentMessages( - state: ConversationViewState, - messages: Message[], -): ConversationViewState { - const activeSegment = state.segments[state.activeSegmentIndex]; - if (!activeSegment) return state; - - const segments = state.segments.map((segment, index) => - index === state.activeSegmentIndex - ? { - ...segment, - messages: messages.slice(), - updatedAt: - messages.length > 0 - ? getMessageTimestamp(messages[messages.length - 1]) - : segment.createdAt, - } - : segment, - ); - const normalizedSegments = segments.map((segment, index) => - index === state.activeSegmentIndex ? normalizeSegment(segment, index) : segment, - ); - const meta = buildConversationMeta({ - systemPrompt: state.meta.systemPrompt, - tools: state.meta.tools, - segments: normalizedSegments, - activeSegmentIndex: state.activeSegmentIndex, - }); - - return { - meta, - segments: normalizedSegments, - activeSegmentIndex: state.activeSegmentIndex, - historyRenderItems: buildUpdatedTimelineForActiveSegment({ - previousItems: state.historyRenderItems, - segments: normalizedSegments, - activeSegmentIndex: state.activeSegmentIndex, - }), - }; -} diff --git a/crates/agent-gateway/web/src/lib/chat/requestContextSanitizer.ts b/crates/agent-gateway/web/src/lib/chat/requestContextSanitizer.ts deleted file mode 100644 index c5c3383e..00000000 --- a/crates/agent-gateway/web/src/lib/chat/requestContextSanitizer.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { Context, Message, TextContent, ToolResultMessage } from "../agentTypes"; - -import type { DisplayImageItemDetails, DisplayImageResultDetails } from "../tools/builtinTypes"; -import { - hostedSearchBlockToContextText, - normalizeHostedSearchBlock, -} from "./hostedSearch"; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object"; -} - -function normalizeDisplayImageItem(value: unknown): DisplayImageItemDetails | null { - if (!isRecord(value)) return null; - const { path, sourceType, renderMode, sourceUrl, mimeType, sizeBytes, mtimeMs, contentHash } = value; - if (typeof path !== "string") { - return null; - } - return { - path, - ...(typeof sourceType === "string" ? { sourceType: sourceType as DisplayImageItemDetails["sourceType"] } : {}), - ...(typeof renderMode === "string" ? { renderMode: renderMode as DisplayImageItemDetails["renderMode"] } : {}), - ...(typeof sourceUrl === "string" ? { sourceUrl } : {}), - ...(typeof mimeType === "string" ? { mimeType } : {}), - ...(typeof sizeBytes === "number" ? { sizeBytes } : {}), - ...(typeof mtimeMs === "number" ? { mtimeMs } : {}), - ...(typeof contentHash === "string" ? { contentHash } : {}), - }; -} - -function getDisplayImageItems(details: unknown): DisplayImageItemDetails[] { - if (!isRecord(details) || details.kind !== "display_image" || !Array.isArray(details.images)) { - return []; - } - return details.images.flatMap((item) => { - const normalized = normalizeDisplayImageItem(item); - return normalized ? [normalized] : []; - }); -} - -function isDisplayImageToolResult(message: Message): message is ToolResultMessage { - return ( - message.role === "toolResult" && - !message.isError && - (message.toolName === "Image" || - (isRecord(message.details) && message.details.kind === "display_image")) - ); -} - -function getToolResultText(message: ToolResultMessage) { - return message.content - .flatMap((block) => (block.type === "text" && block.text.trim() ? [block.text.trim()] : [])) - .join("\n\n"); -} - -function buildDisplayImageContextText(message: ToolResultMessage) { - const images = getDisplayImageItems(message.details); - if (images.length === 0) { - const text = getToolResultText(message); - return [ - text || "Image tool displayed image content in the chat UI.", - "Inline image bytes are omitted from model context because Image is a display-only UI tool.", - ].join("\n\n"); - } - - const noun = images.length === 1 ? "image" : "images"; - return [ - `Displayed ${images.length} ${noun} in the chat UI successfully.`, - ...images.map( - (image, index) => { - const facts = [ - image.sourceType ? `sourceType=${image.sourceType}` : null, - image.renderMode ? `renderMode=${image.renderMode}` : null, - image.mimeType ? `mime=${image.mimeType}` : null, - typeof image.sizeBytes === "number" ? `sizeBytes=${image.sizeBytes}` : null, - ].filter(Boolean); - return `${index + 1}. ${image.path}${facts.length > 0 ? ` (${facts.join(", ")})` : ""}`; - }, - ), - "Inline image bytes are omitted from model context because Image is a display-only UI tool.", - ].join("\n"); -} - -export function sanitizeMessageForModelContext(message: Message): Message { - let nextMessage = message; - - if (message.role === "assistant") { - const nextContent: unknown[] = []; - let changed = false; - for (const block of message.content as unknown[]) { - const hostedSearch = normalizeHostedSearchBlock(block); - if (hostedSearch) { - nextContent.push({ - type: "text", - text: hostedSearchBlockToContextText(hostedSearch), - } satisfies TextContent); - changed = true; - continue; - } - nextContent.push(block); - } - if (changed) { - nextMessage = { - ...message, - content: nextContent as Message["content"], - } as Message; - } - } - - if (!isDisplayImageToolResult(nextMessage)) return nextMessage; - const hasInlineImages = nextMessage.content.some((block) => block.type === "image"); - const hasDisplayImageDetails = getDisplayImageItems(nextMessage.details).length > 0; - if (!hasInlineImages && !hasDisplayImageDetails) return nextMessage; - - const text: TextContent = { - type: "text", - text: buildDisplayImageContextText(nextMessage), - }; - - return { - ...nextMessage, - content: [text], - }; -} - -export function sanitizeMessagesForModelContext(messages: Message[]): Message[] { - return messages.map(sanitizeMessageForModelContext); -} - -export function stripAbortedMessagesForModelContext(messages: Message[]): Message[] { - const sanitized: Message[] = []; - - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (message.role === "assistant" && message.stopReason === "aborted") { - while (index + 1 < messages.length && messages[index + 1]?.role === "toolResult") { - index += 1; - } - continue; - } - sanitized.push(message); - } - - return sanitized; -} - -export function sanitizeMessagesForContinuation(messages: Message[]): Message[] { - return sanitizeMessagesForModelContext(stripAbortedMessagesForModelContext(messages)); -} - -export function sanitizeContextForModelRequest(context: Context): Context { - return { - ...context, - messages: sanitizeMessagesForContinuation(context.messages), - }; -} diff --git a/crates/agent-gateway/web/src/lib/chat/systemPrompt.ts b/crates/agent-gateway/web/src/lib/chat/systemPrompt.ts deleted file mode 100644 index c90746de..00000000 --- a/crates/agent-gateway/web/src/lib/chat/systemPrompt.ts +++ /dev/null @@ -1,8 +0,0 @@ -export function normalizeConversationSystemPrompt(systemPrompt: string | undefined) { - if (typeof systemPrompt !== "string") return undefined; - - const trimmed = systemPrompt.trim(); - if (!trimmed) return undefined; - - return trimmed; -} diff --git a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts index 13f0b0b2..6011fa86 100644 --- a/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts +++ b/crates/agent-gui/src/lib/chat/conversation/run/gatewayBridgeEvents.ts @@ -1,9 +1,29 @@ -import type { ConversationViewState } from "../conversationState"; +import type { ConversationViewState, HistoryMessageRef } from "../conversationState"; type QueueEventOptions = { allowAfterClose?: boolean; }; +type QueueUserMessageOptions = { + // Edit-resend: the edited (truncation-base) user message. The gateway + // broadcasts a `rebased` event from it so every other connected client + // truncates its transcript at the same point. + baseMessageRef?: HistoryMessageRef; +}; + +// Wire shape mirror of the gateway's ChatMessageRef (snake_case), matching +// the webui's buildHistoryMessageRefPayload byte for byte. +function buildGatewayBaseMessageRefPayload(ref: HistoryMessageRef): Record { + return { + segment_index: ref.segmentIndex, + message_index: ref.messageIndex, + segment_id: ref.segmentId, + message_id: ref.messageId, + role: ref.role, + content_hash: ref.contentHash, + }; +} + type GatewayBridgeSendResult = Promise | void; type GatewayBridgeEventControllerParams = { @@ -27,6 +47,7 @@ export type GatewayBridgeEventController = { queueUserMessage: ( message: string, uploadedFiles?: readonly unknown[], + options?: QueueUserMessageOptions, ) => GatewayBridgeSendResult; queueToken: (delta: string, extra?: Record) => void; queueTitle: (nextTitle: string, allowAfterClose?: boolean) => void; @@ -66,7 +87,7 @@ export function createGatewayBridgeEventController( return { queueEvent, - queueUserMessage(message: string, uploadedFiles = []) { + queueUserMessage(message: string, uploadedFiles = [], options?: QueueUserMessageOptions) { if (!message.trim() && uploadedFiles.length === 0) return; return queueEvent({ type: "user_message", @@ -75,6 +96,12 @@ export function createGatewayBridgeEventController( file && typeof file === "object" ? { ...(file as Record) } : file, ), conversation_id: params.conversationId, + ...(options?.baseMessageRef + ? { + base_message_ref: buildGatewayBaseMessageRefPayload(options.baseMessageRef), + reason: "edit_resend", + } + : {}), }); }, queueToken(delta: string, extra?: Record) { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 2ac31c9f..c32a754c 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -3638,6 +3638,7 @@ export function ChatPage(props: ChatPageProps) { preserveComposerOnStart?: boolean; beforeRuntimeStart?: () => Promise; afterInitialHistoryPersist?: () => Promise; + editResendBaseMessageRef?: HistoryMessageRef; }) { const overrideConversationId = overrides?.conversationIdOverride?.trim() ?? ""; const conversationId = overrideConversationId || currentConversationIdRef.current; @@ -4106,7 +4107,9 @@ export function ChatPage(props: ChatPageProps) { console.warn("gateway stream started before initial user turn was persisted"); } } - await gatewayBridgeEvents.queueUserMessage(text, uploadedFiles); + await gatewayBridgeEvents.queueUserMessage(text, uploadedFiles, { + baseMessageRef: overrides?.editResendBaseMessageRef, + }); acknowledgeGatewayRunStarted(); let activeCompactionRollback: { state: ConversationViewState; diff --git a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts index 1983c6ec..5e3b78f4 100644 --- a/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts +++ b/crates/agent-gui/src/pages/chat/gateway/gatewayBridgeTypes.ts @@ -89,6 +89,10 @@ export type SendChatAction = (overrides?: { preserveComposerOnStart?: boolean; beforeRuntimeStart?: () => Promise; afterInitialHistoryPersist?: () => Promise; + // Edit-resend: the edited (truncation-base) user message. Forwarded on the + // mirrored user_message event so the gateway can broadcast the truncation + // (`rebased`) to every other connected client. + editResendBaseMessageRef?: HistoryMessageRef; }) => Promise; export type GatewayBridgeRuntimeRefs = { diff --git a/crates/agent-gui/src/pages/chat/hooks/useEditResend.ts b/crates/agent-gui/src/pages/chat/hooks/useEditResend.ts index b2a06a77..61971725 100644 --- a/crates/agent-gui/src/pages/chat/hooks/useEditResend.ts +++ b/crates/agent-gui/src/pages/chat/hooks/useEditResend.ts @@ -34,6 +34,7 @@ type PendingEditResend = { expectedState: ConversationViewState; text: string; uploadedFiles: PendingUploadedFile[]; + baseMessageRef: HistoryMessageRef; afterInitialHistoryPersist: () => Promise; }; @@ -68,6 +69,7 @@ export function useEditResend(params: UseEditResendParams) { expectedState: nextState, text: normalized, uploadedFiles, + baseMessageRef: messageRef, afterInitialHistoryPersist: () => { invalidateSubagentsForConversation?.(parentConversationId); return pruneSubagentRunsForConversation({ @@ -113,6 +115,7 @@ export function useEditResend(params: UseEditResendParams) { void sendActionRef.current({ textOverride: pending.text, uploadedFilesOverride: pending.uploadedFiles, + editResendBaseMessageRef: pending.baseMessageRef, afterInitialHistoryPersist: pending.afterInitialHistoryPersist, }); }, [conversationState, sendActionRef]); diff --git a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs index 05027b94..ee1c234c 100644 --- a/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs +++ b/crates/agent-gui/test/chat/gateway-bridge-events.test.mjs @@ -255,6 +255,58 @@ test("gateway bridge checkpoint emits compaction summary payload", () => { ]); }); +test("gateway bridge user message carries the edit-resend truncation base", () => { + const { controller, sent } = createController(); + + controller.queueUserMessage("edited prompt", [], { + baseMessageRef: { + segmentIndex: 0, + messageIndex: 2, + segmentId: "segment-1", + messageId: "message-2", + role: "user", + contentHash: "hash-2", + }, + }); + + assert.deepEqual(sent, [ + { + requestId: "request-1", + event: { + type: "user_message", + message: "edited prompt", + uploaded_files: [], + conversation_id: "conversation-1", + base_message_ref: { + segment_index: 0, + message_index: 2, + segment_id: "segment-1", + message_id: "message-2", + role: "user", + content_hash: "hash-2", + }, + reason: "edit_resend", + }, + }, + ]); +}); + +test("gateway bridge user message omits the truncation base for plain sends", () => { + const { controller, sent } = createController(); + + controller.queueUserMessage("plain prompt"); + + assert.equal(sent.length, 1); + assert.deepEqual(sent[0].event, { + type: "user_message", + message: "plain prompt", + uploaded_files: [], + conversation_id: "conversation-1", + }); + assert.equal("base_message_ref" in sent[0].event, false); + assert.equal("reason" in sent[0].event, false); +}); + test("gateway bridge error can resolve the latest conversation id", () => { const { controller, sent } = createController({ conversationId: "conversation-initial", From a936a3d4ebe78f6ad13c68e7bf10263d993f3fdc Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 12:18:52 +0800 Subject: [PATCH 26/64] feat(tunnel): add persistent desired-state relay --- .../internal/proto/v1/gateway.pb.go | 2227 ++++++++++------- .../server/{tunnel.go => tunnel_proxy.go} | 492 ++-- .../internal/server/tunnel_proxy_test.go | 207 ++ .../internal/server/tunnel_rewrite.go | 135 +- .../internal/server/tunnel_rewrite_test.go | 15 +- .../internal/server/websocket.go | 8 + .../internal/server/websocket_routes.go | 3 +- .../internal/server/websocket_routes_test.go | 51 +- .../server/websocket_tunnel_handlers.go | 428 +--- .../agent-gateway/internal/session/manager.go | 6 +- .../internal/session/manager_dispatch.go | 13 +- .../internal/session/manager_registry.go | 3 + .../internal/session/manager_tunnel.go | 904 ------- .../internal/session/manager_tunnel_test.go | 265 -- .../internal/session/tunnel_probe.go | 221 -- .../internal/session/tunnel_probe_test.go | 42 - .../internal/session/tunnel_state.go | 626 +++++ .../internal/session/tunnel_state_test.go | 275 ++ crates/agent-gateway/proto/v1/gateway.proto | 141 +- .../test/tunnel/tunnel_e2e_test.go | 373 +++ .../test/websocket/terminal_test.go | 28 +- .../test/websocket/websocket_helpers_test.go | 21 +- .../agent-gateway/web/src/app/GatewayApp.tsx | 16 +- .../project-tools/LocalTunnelPanel.tsx | 671 ++--- .../project-tools/RightDockContent.tsx | 6 +- .../project-tools/RightDockPanel.tsx | 6 +- crates/agent-gateway/web/src/i18n/config.ts | 30 +- .../web/src/lib/gatewaySocket.ts | 294 +-- .../web/src/lib/tunnels/constants.ts | 109 + .../web/src/pages/StatusDashboardPage.tsx | 22 +- .../src/commands/config/settings/db.rs | 5 + .../src/commands/integration/gateway.rs | 30 +- crates/agent-gui/src-tauri/src/lib.rs | 4 +- .../src-tauri/src/services/gateway.rs | 1773 +------------ .../agent-gui/src-tauri/src/services/mod.rs | 1 + .../src-tauri/src/services/tunnel/mod.rs | 589 +++++ .../src-tauri/src/services/tunnel/proxy.rs | 737 ++++++ .../src-tauri/src/services/tunnel/store.rs | 708 ++++++ .../project-tools/LocalTunnelPanel.tsx | 720 +++--- .../project-tools/RightDockContent.tsx | 10 +- .../project-tools/RightDockPanel.tsx | 6 +- crates/agent-gui/src/i18n/config.ts | 29 +- .../src/lib/chat/messages/uiMessages.ts | 1 - .../src/lib/tools/builtinRegistry.ts | 33 +- .../src/lib/tools/tunnelManagerTools.ts | 279 ++- crates/agent-gui/src/lib/tunnels/constants.ts | 109 + crates/agent-gui/src/pages/ChatPage.tsx | 85 +- .../chat/turns/runAgentConversationTurn.ts | 18 +- .../test/tools/tunnel-manager-tools.test.mjs | 149 +- 49 files changed, 6962 insertions(+), 5962 deletions(-) rename crates/agent-gateway/internal/server/{tunnel.go => tunnel_proxy.go} (59%) create mode 100644 crates/agent-gateway/internal/server/tunnel_proxy_test.go delete mode 100644 crates/agent-gateway/internal/session/manager_tunnel.go delete mode 100644 crates/agent-gateway/internal/session/manager_tunnel_test.go delete mode 100644 crates/agent-gateway/internal/session/tunnel_probe.go delete mode 100644 crates/agent-gateway/internal/session/tunnel_probe_test.go create mode 100644 crates/agent-gateway/internal/session/tunnel_state.go create mode 100644 crates/agent-gateway/internal/session/tunnel_state_test.go create mode 100644 crates/agent-gateway/test/tunnel/tunnel_e2e_test.go create mode 100644 crates/agent-gateway/web/src/lib/tunnels/constants.ts create mode 100644 crates/agent-gui/src-tauri/src/services/tunnel/mod.rs create mode 100644 crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs create mode 100644 crates/agent-gui/src-tauri/src/services/tunnel/store.rs create mode 100644 crates/agent-gui/src/lib/tunnels/constants.ts diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index ff61e307..cadd47d4 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -38,6 +38,8 @@ const ( TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE TunnelFrameKind = 11 TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR TunnelFrameKind = 12 TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL TunnelFrameKind = 13 + TunnelFrameKind_TUNNEL_FRAME_KIND_PING TunnelFrameKind = 14 + TunnelFrameKind_TUNNEL_FRAME_KIND_PONG TunnelFrameKind = 15 ) // Enum value maps for TunnelFrameKind. @@ -57,6 +59,8 @@ var ( 11: "TUNNEL_FRAME_KIND_WS_CLOSE", 12: "TUNNEL_FRAME_KIND_ERROR", 13: "TUNNEL_FRAME_KIND_CANCEL", + 14: "TUNNEL_FRAME_KIND_PING", + 15: "TUNNEL_FRAME_KIND_PONG", } TunnelFrameKind_value = map[string]int32{ "TUNNEL_FRAME_KIND_UNSPECIFIED": 0, @@ -73,6 +77,8 @@ var ( "TUNNEL_FRAME_KIND_WS_CLOSE": 11, "TUNNEL_FRAME_KIND_ERROR": 12, "TUNNEL_FRAME_KIND_CANCEL": 13, + "TUNNEL_FRAME_KIND_PING": 14, + "TUNNEL_FRAME_KIND_PONG": 15, } ) @@ -103,6 +109,55 @@ func (TunnelFrameKind) EnumDescriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{0} } +type TunnelWsMessageType int32 + +const ( + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED TunnelWsMessageType = 0 + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT TunnelWsMessageType = 1 + TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_BINARY TunnelWsMessageType = 2 +) + +// Enum value maps for TunnelWsMessageType. +var ( + TunnelWsMessageType_name = map[int32]string{ + 0: "TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED", + 1: "TUNNEL_WS_MESSAGE_TYPE_TEXT", + 2: "TUNNEL_WS_MESSAGE_TYPE_BINARY", + } + TunnelWsMessageType_value = map[string]int32{ + "TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED": 0, + "TUNNEL_WS_MESSAGE_TYPE_TEXT": 1, + "TUNNEL_WS_MESSAGE_TYPE_BINARY": 2, + } +) + +func (x TunnelWsMessageType) Enum() *TunnelWsMessageType { + p := new(TunnelWsMessageType) + *p = x + return p +} + +func (x TunnelWsMessageType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TunnelWsMessageType) Descriptor() protoreflect.EnumDescriptor { + return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() +} + +func (TunnelWsMessageType) Type() protoreflect.EnumType { + return &file_proto_v1_gateway_proto_enumTypes[1] +} + +func (x TunnelWsMessageType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TunnelWsMessageType.Descriptor instead. +func (TunnelWsMessageType) EnumDescriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{1} +} + type ChatEvent_ChatEventType int32 const ( @@ -154,11 +209,11 @@ func (x ChatEvent_ChatEventType) String() string { } func (ChatEvent_ChatEventType) Descriptor() protoreflect.EnumDescriptor { - return file_proto_v1_gateway_proto_enumTypes[1].Descriptor() + return file_proto_v1_gateway_proto_enumTypes[2].Descriptor() } func (ChatEvent_ChatEventType) Type() protoreflect.EnumType { - return &file_proto_v1_gateway_proto_enumTypes[1] + return &file_proto_v1_gateway_proto_enumTypes[2] } func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { @@ -167,7 +222,7 @@ func (x ChatEvent_ChatEventType) Number() protoreflect.EnumNumber { // Deprecated: Use ChatEvent_ChatEventType.Descriptor instead. func (ChatEvent_ChatEventType) EnumDescriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44, 0} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49, 0} } type AuthRequest struct { @@ -333,12 +388,12 @@ type GatewayEnvelope struct { // *GatewayEnvelope_FsReadEditableText // *GatewayEnvelope_FsReadWorkspaceImage // *GatewayEnvelope_SftpRequest - // *GatewayEnvelope_TunnelControl - // *GatewayEnvelope_TunnelControlResp - // *GatewayEnvelope_TunnelFrame // *GatewayEnvelope_SettingsResetSshKnownHost // *GatewayEnvelope_ChatQueue // *GatewayEnvelope_ChatEventReplay + // *GatewayEnvelope_TunnelState + // *GatewayEnvelope_TunnelMutation + // *GatewayEnvelope_TunnelFrame Payload isGatewayEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -728,55 +783,55 @@ func (x *GatewayEnvelope) GetSftpRequest() *SftpRequest { return nil } -func (x *GatewayEnvelope) GetTunnelControl() *TunnelControlRequest { +func (x *GatewayEnvelope) GetSettingsResetSshKnownHost() *SettingsResetSshKnownHostRequest { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelControl); ok { - return x.TunnelControl + if x, ok := x.Payload.(*GatewayEnvelope_SettingsResetSshKnownHost); ok { + return x.SettingsResetSshKnownHost } } return nil } -func (x *GatewayEnvelope) GetTunnelControlResp() *TunnelControlResponse { +func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelControlResp); ok { - return x.TunnelControlResp + if x, ok := x.Payload.(*GatewayEnvelope_ChatQueue); ok { + return x.ChatQueue } } return nil } -func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { +func (x *GatewayEnvelope) GetChatEventReplay() *ChatEventReplayRequest { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { - return x.TunnelFrame + if x, ok := x.Payload.(*GatewayEnvelope_ChatEventReplay); ok { + return x.ChatEventReplay } } return nil } -func (x *GatewayEnvelope) GetSettingsResetSshKnownHost() *SettingsResetSshKnownHostRequest { +func (x *GatewayEnvelope) GetTunnelState() *TunnelStateSnapshot { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_SettingsResetSshKnownHost); ok { - return x.SettingsResetSshKnownHost + if x, ok := x.Payload.(*GatewayEnvelope_TunnelState); ok { + return x.TunnelState } } return nil } -func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { +func (x *GatewayEnvelope) GetTunnelMutation() *TunnelMutation { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_ChatQueue); ok { - return x.ChatQueue + if x, ok := x.Payload.(*GatewayEnvelope_TunnelMutation); ok { + return x.TunnelMutation } } return nil } -func (x *GatewayEnvelope) GetChatEventReplay() *ChatEventReplayRequest { +func (x *GatewayEnvelope) GetTunnelFrame() *TunnelFrame { if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_ChatEventReplay); ok { - return x.ChatEventReplay + if x, ok := x.Payload.(*GatewayEnvelope_TunnelFrame); ok { + return x.TunnelFrame } } return nil @@ -934,18 +989,6 @@ type GatewayEnvelope_SftpRequest struct { SftpRequest *SftpRequest `protobuf:"bytes,64,opt,name=sftp_request,json=sftpRequest,proto3,oneof"` } -type GatewayEnvelope_TunnelControl struct { - TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` -} - -type GatewayEnvelope_TunnelControlResp struct { - TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` -} - -type GatewayEnvelope_TunnelFrame struct { - TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` -} - type GatewayEnvelope_SettingsResetSshKnownHost struct { SettingsResetSshKnownHost *SettingsResetSshKnownHostRequest `protobuf:"bytes,72,opt,name=settings_reset_ssh_known_host,json=settingsResetSshKnownHost,proto3,oneof"` } @@ -958,6 +1001,18 @@ type GatewayEnvelope_ChatEventReplay struct { ChatEventReplay *ChatEventReplayRequest `protobuf:"bytes,74,opt,name=chat_event_replay,json=chatEventReplay,proto3,oneof"` } +type GatewayEnvelope_TunnelState struct { + TunnelState *TunnelStateSnapshot `protobuf:"bytes,80,opt,name=tunnel_state,json=tunnelState,proto3,oneof"` +} + +type GatewayEnvelope_TunnelMutation struct { + TunnelMutation *TunnelMutation `protobuf:"bytes,81,opt,name=tunnel_mutation,json=tunnelMutation,proto3,oneof"` +} + +type GatewayEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,82,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + func (*GatewayEnvelope_ChatCommand) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_CronManage) isGatewayEnvelope_Payload() {} @@ -1032,18 +1087,18 @@ func (*GatewayEnvelope_FsReadWorkspaceImage) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_SftpRequest) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_TunnelControl) isGatewayEnvelope_Payload() {} - -func (*GatewayEnvelope_TunnelControlResp) isGatewayEnvelope_Payload() {} - -func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} - func (*GatewayEnvelope_SettingsResetSshKnownHost) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_ChatQueue) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_ChatEventReplay) isGatewayEnvelope_Payload() {} +func (*GatewayEnvelope_TunnelState) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelMutation) isGatewayEnvelope_Payload() {} + +func (*GatewayEnvelope_TunnelFrame) isGatewayEnvelope_Payload() {} + type AgentEnvelope struct { state protoimpl.MessageState `protogen:"open.v1"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` @@ -1093,14 +1148,15 @@ type AgentEnvelope struct { // *AgentEnvelope_SftpEvent // *AgentEnvelope_ChatQueueResp // *AgentEnvelope_ChatQueueEvent - // *AgentEnvelope_TunnelControl - // *AgentEnvelope_TunnelControlResp - // *AgentEnvelope_TunnelFrame // *AgentEnvelope_ChatControl // *AgentEnvelope_RuntimeStatus // *AgentEnvelope_SettingsResetSshKnownHostResp // *AgentEnvelope_ChatRuntimeSnapshot // *AgentEnvelope_ChatEventReplayResp + // *AgentEnvelope_TunnelDesired + // *AgentEnvelope_TunnelMutationResult + // *AgentEnvelope_TunnelFrame + // *AgentEnvelope_TunnelProbeReport // *AgentEnvelope_Error Payload isAgentEnvelope_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields @@ -1545,73 +1601,82 @@ func (x *AgentEnvelope) GetChatQueueEvent() *ChatQueueEvent { return nil } -func (x *AgentEnvelope) GetTunnelControl() *TunnelControlRequest { +func (x *AgentEnvelope) GetChatControl() *ChatControlEvent { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelControl); ok { - return x.TunnelControl + if x, ok := x.Payload.(*AgentEnvelope_ChatControl); ok { + return x.ChatControl } } return nil } -func (x *AgentEnvelope) GetTunnelControlResp() *TunnelControlResponse { +func (x *AgentEnvelope) GetRuntimeStatus() *RuntimeStatusEvent { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelControlResp); ok { - return x.TunnelControlResp + if x, ok := x.Payload.(*AgentEnvelope_RuntimeStatus); ok { + return x.RuntimeStatus } } return nil } -func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { +func (x *AgentEnvelope) GetSettingsResetSshKnownHostResp() *SettingsResetSshKnownHostResponse { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { - return x.TunnelFrame + if x, ok := x.Payload.(*AgentEnvelope_SettingsResetSshKnownHostResp); ok { + return x.SettingsResetSshKnownHostResp } } return nil } -func (x *AgentEnvelope) GetChatControl() *ChatControlEvent { +func (x *AgentEnvelope) GetChatRuntimeSnapshot() *ChatRuntimeSnapshot { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_ChatControl); ok { - return x.ChatControl + if x, ok := x.Payload.(*AgentEnvelope_ChatRuntimeSnapshot); ok { + return x.ChatRuntimeSnapshot } } return nil } -func (x *AgentEnvelope) GetRuntimeStatus() *RuntimeStatusEvent { +func (x *AgentEnvelope) GetChatEventReplayResp() *ChatEventReplayResponse { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_RuntimeStatus); ok { - return x.RuntimeStatus + if x, ok := x.Payload.(*AgentEnvelope_ChatEventReplayResp); ok { + return x.ChatEventReplayResp } } return nil } -func (x *AgentEnvelope) GetSettingsResetSshKnownHostResp() *SettingsResetSshKnownHostResponse { +func (x *AgentEnvelope) GetTunnelDesired() *TunnelDesiredState { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_SettingsResetSshKnownHostResp); ok { - return x.SettingsResetSshKnownHostResp + if x, ok := x.Payload.(*AgentEnvelope_TunnelDesired); ok { + return x.TunnelDesired } } return nil } -func (x *AgentEnvelope) GetChatRuntimeSnapshot() *ChatRuntimeSnapshot { +func (x *AgentEnvelope) GetTunnelMutationResult() *TunnelMutationResult { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_ChatRuntimeSnapshot); ok { - return x.ChatRuntimeSnapshot + if x, ok := x.Payload.(*AgentEnvelope_TunnelMutationResult); ok { + return x.TunnelMutationResult } } return nil } -func (x *AgentEnvelope) GetChatEventReplayResp() *ChatEventReplayResponse { +func (x *AgentEnvelope) GetTunnelFrame() *TunnelFrame { if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_ChatEventReplayResp); ok { - return x.ChatEventReplayResp + if x, ok := x.Payload.(*AgentEnvelope_TunnelFrame); ok { + return x.TunnelFrame + } + } + return nil +} + +func (x *AgentEnvelope) GetTunnelProbeReport() *TunnelProbeReport { + if x != nil { + if x, ok := x.Payload.(*AgentEnvelope_TunnelProbeReport); ok { + return x.TunnelProbeReport } } return nil @@ -1802,18 +1867,6 @@ type AgentEnvelope_ChatQueueEvent struct { ChatQueueEvent *ChatQueueEvent `protobuf:"bytes,76,opt,name=chat_queue_event,json=chatQueueEvent,proto3,oneof"` } -type AgentEnvelope_TunnelControl struct { - TunnelControl *TunnelControlRequest `protobuf:"bytes,67,opt,name=tunnel_control,json=tunnelControl,proto3,oneof"` -} - -type AgentEnvelope_TunnelControlResp struct { - TunnelControlResp *TunnelControlResponse `protobuf:"bytes,68,opt,name=tunnel_control_resp,json=tunnelControlResp,proto3,oneof"` -} - -type AgentEnvelope_TunnelFrame struct { - TunnelFrame *TunnelFrame `protobuf:"bytes,69,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` -} - type AgentEnvelope_ChatControl struct { ChatControl *ChatControlEvent `protobuf:"bytes,70,opt,name=chat_control,json=chatControl,proto3,oneof"` } @@ -1834,6 +1887,22 @@ type AgentEnvelope_ChatEventReplayResp struct { ChatEventReplayResp *ChatEventReplayResponse `protobuf:"bytes,78,opt,name=chat_event_replay_resp,json=chatEventReplayResp,proto3,oneof"` } +type AgentEnvelope_TunnelDesired struct { + TunnelDesired *TunnelDesiredState `protobuf:"bytes,80,opt,name=tunnel_desired,json=tunnelDesired,proto3,oneof"` +} + +type AgentEnvelope_TunnelMutationResult struct { + TunnelMutationResult *TunnelMutationResult `protobuf:"bytes,81,opt,name=tunnel_mutation_result,json=tunnelMutationResult,proto3,oneof"` +} + +type AgentEnvelope_TunnelFrame struct { + TunnelFrame *TunnelFrame `protobuf:"bytes,82,opt,name=tunnel_frame,json=tunnelFrame,proto3,oneof"` +} + +type AgentEnvelope_TunnelProbeReport struct { + TunnelProbeReport *TunnelProbeReport `protobuf:"bytes,83,opt,name=tunnel_probe_report,json=tunnelProbeReport,proto3,oneof"` +} + type AgentEnvelope_Error struct { Error *ErrorResponse `protobuf:"bytes,99,opt,name=error,proto3,oneof"` } @@ -1924,12 +1993,6 @@ func (*AgentEnvelope_ChatQueueResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_ChatQueueEvent) isAgentEnvelope_Payload() {} -func (*AgentEnvelope_TunnelControl) isAgentEnvelope_Payload() {} - -func (*AgentEnvelope_TunnelControlResp) isAgentEnvelope_Payload() {} - -func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} - func (*AgentEnvelope_ChatControl) isAgentEnvelope_Payload() {} func (*AgentEnvelope_RuntimeStatus) isAgentEnvelope_Payload() {} @@ -1940,6 +2003,14 @@ func (*AgentEnvelope_ChatRuntimeSnapshot) isAgentEnvelope_Payload() {} func (*AgentEnvelope_ChatEventReplayResp) isAgentEnvelope_Payload() {} +func (*AgentEnvelope_TunnelDesired) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelMutationResult) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelFrame) isAgentEnvelope_Payload() {} + +func (*AgentEnvelope_TunnelProbeReport) isAgentEnvelope_Payload() {} + func (*AgentEnvelope_Error) isAgentEnvelope_Payload() {} type ChatSelectedModel struct { @@ -2406,36 +2477,32 @@ func (x *UploadedImagePreviewResponse) GetData() string { return "" } -type TunnelControlRequest struct { +type TunnelSpec struct { state protoimpl.MessageState `protogen:"open.v1"` - Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` - Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` - TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` - Name string `protobuf:"bytes,5,opt,name=name,proto3" json:"name,omitempty"` - TtlSeconds uint32 `protobuf:"varint,6,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` - ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` - PublicUrl string `protobuf:"bytes,8,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` - PublicBaseUrl string `protobuf:"bytes,9,opt,name=public_base_url,json=publicBaseUrl,proto3" json:"public_base_url,omitempty"` - ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // agent-generated, stable across restarts + SlugHint string `protobuf:"bytes,2,opt,name=slug_hint,json=slugHint,proto3" json:"slug_hint,omitempty"` // last allocated slug; gateway honors when valid and unused + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` // http://localhost:PORT[/base] + ExpiresAt int64 `protobuf:"varint,5,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` // unix seconds, 0 = never + ProjectPathKey string `protobuf:"bytes,6,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelControlRequest) Reset() { - *x = TunnelControlRequest{} +func (x *TunnelSpec) Reset() { + *x = TunnelSpec{} mi := &file_proto_v1_gateway_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelControlRequest) String() string { +func (x *TunnelSpec) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelControlRequest) ProtoMessage() {} +func (*TunnelSpec) ProtoMessage() {} -func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { +func (x *TunnelSpec) ProtoReflect() protoreflect.Message { mi := &file_proto_v1_gateway_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -2447,107 +2514,131 @@ func (x *TunnelControlRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelControlRequest.ProtoReflect.Descriptor instead. -func (*TunnelControlRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use TunnelSpec.ProtoReflect.Descriptor instead. +func (*TunnelSpec) Descriptor() ([]byte, []int) { return file_proto_v1_gateway_proto_rawDescGZIP(), []int{12} } -func (x *TunnelControlRequest) GetAction() string { +func (x *TunnelSpec) GetId() string { if x != nil { - return x.Action + return x.Id } return "" } -func (x *TunnelControlRequest) GetTunnelId() string { +func (x *TunnelSpec) GetSlugHint() string { if x != nil { - return x.TunnelId + return x.SlugHint } return "" } -func (x *TunnelControlRequest) GetSlug() string { +func (x *TunnelSpec) GetName() string { if x != nil { - return x.Slug + return x.Name } return "" } -func (x *TunnelControlRequest) GetTargetUrl() string { +func (x *TunnelSpec) GetTargetUrl() string { if x != nil { return x.TargetUrl } return "" } -func (x *TunnelControlRequest) GetName() string { +func (x *TunnelSpec) GetExpiresAt() int64 { if x != nil { - return x.Name + return x.ExpiresAt } - return "" + return 0 } -func (x *TunnelControlRequest) GetTtlSeconds() uint32 { +func (x *TunnelSpec) GetProjectPathKey() string { if x != nil { - return x.TtlSeconds + return x.ProjectPathKey } - return 0 + return "" } -func (x *TunnelControlRequest) GetExpiresAt() int64 { - if x != nil { - return x.ExpiresAt - } - return 0 +type TunnelDesiredState struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tunnels []*TunnelSpec `protobuf:"bytes,1,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Revision uint64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` // agent-side monotonic + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelDesiredState) Reset() { + *x = TunnelDesiredState{} + mi := &file_proto_v1_gateway_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -func (x *TunnelControlRequest) GetPublicUrl() string { +func (x *TunnelDesiredState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelDesiredState) ProtoMessage() {} + +func (x *TunnelDesiredState) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[13] if x != nil { - return x.PublicUrl + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms } - return "" + return mi.MessageOf(x) } -func (x *TunnelControlRequest) GetPublicBaseUrl() string { +// Deprecated: Use TunnelDesiredState.ProtoReflect.Descriptor instead. +func (*TunnelDesiredState) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} +} + +func (x *TunnelDesiredState) GetTunnels() []*TunnelSpec { if x != nil { - return x.PublicBaseUrl + return x.Tunnels } - return "" + return nil } -func (x *TunnelControlRequest) GetProjectPathKey() string { +func (x *TunnelDesiredState) GetRevision() uint64 { if x != nil { - return x.ProjectPathKey + return x.Revision } - return "" + return 0 } -type TunnelControlResponse struct { +type TunnelHealth struct { state protoimpl.MessageState `protogen:"open.v1"` - Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` - Tunnels []*TunnelSummary `protobuf:"bytes,2,rep,name=tunnels,proto3" json:"tunnels,omitempty"` - Tunnel *TunnelSummary `protobuf:"bytes,3,opt,name=tunnel,proto3" json:"tunnel,omitempty"` - ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` - ErrorMessage string `protobuf:"bytes,5,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` // "ok" | "failed" | "unknown" + HttpStatus uint32 `protobuf:"varint,2,opt,name=http_status,json=httpStatus,proto3" json:"http_status,omitempty"` // local layer only + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` + CheckedAt int64 `protobuf:"varint,4,opt,name=checked_at,json=checkedAt,proto3" json:"checked_at,omitempty"` + RttMs uint32 `protobuf:"varint,5,opt,name=rtt_ms,json=rttMs,proto3" json:"rtt_ms,omitempty"` // relay layer only unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelControlResponse) Reset() { - *x = TunnelControlResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[13] +func (x *TunnelHealth) Reset() { + *x = TunnelHealth{} + mi := &file_proto_v1_gateway_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelControlResponse) String() string { +func (x *TunnelHealth) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelControlResponse) ProtoMessage() {} +func (*TunnelHealth) ProtoMessage() {} -func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[13] +func (x *TunnelHealth) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2558,78 +2649,77 @@ func (x *TunnelControlResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelControlResponse.ProtoReflect.Descriptor instead. -func (*TunnelControlResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{13} +// Deprecated: Use TunnelHealth.ProtoReflect.Descriptor instead. +func (*TunnelHealth) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} } -func (x *TunnelControlResponse) GetAction() string { +func (x *TunnelHealth) GetStatus() string { if x != nil { - return x.Action + return x.Status } return "" } -func (x *TunnelControlResponse) GetTunnels() []*TunnelSummary { +func (x *TunnelHealth) GetHttpStatus() uint32 { if x != nil { - return x.Tunnels + return x.HttpStatus } - return nil + return 0 } -func (x *TunnelControlResponse) GetTunnel() *TunnelSummary { +func (x *TunnelHealth) GetError() string { if x != nil { - return x.Tunnel + return x.Error } - return nil + return "" } -func (x *TunnelControlResponse) GetErrorCode() string { +func (x *TunnelHealth) GetCheckedAt() int64 { if x != nil { - return x.ErrorCode + return x.CheckedAt } - return "" + return 0 } -func (x *TunnelControlResponse) GetErrorMessage() string { +func (x *TunnelHealth) GetRttMs() uint32 { if x != nil { - return x.ErrorMessage + return x.RttMs } - return "" + return 0 } -type TunnelSummary struct { +type TunnelStatus struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` Slug string `protobuf:"bytes,2,opt,name=slug,proto3" json:"slug,omitempty"` Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` TargetUrl string `protobuf:"bytes,4,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` - PublicUrl string `protobuf:"bytes,5,opt,name=public_url,json=publicUrl,proto3" json:"public_url,omitempty"` + PublicPath string `protobuf:"bytes,5,opt,name=public_path,json=publicPath,proto3" json:"public_path,omitempty"` // "/t/{slug}/"; clients compose the full URL CreatedAt int64 `protobuf:"varint,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` ActiveConnections uint32 `protobuf:"varint,8,opt,name=active_connections,json=activeConnections,proto3" json:"active_connections,omitempty"` - Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"` - ProjectPathKey string `protobuf:"bytes,10,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` - Diagnostics []*TunnelDiagnostic `protobuf:"bytes,11,rep,name=diagnostics,proto3" json:"diagnostics,omitempty"` + ProjectPathKey string `protobuf:"bytes,9,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + Local *TunnelHealth `protobuf:"bytes,10,opt,name=local,proto3" json:"local,omitempty"` // agent -> local service reachability unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelSummary) Reset() { - *x = TunnelSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[14] +func (x *TunnelStatus) Reset() { + *x = TunnelStatus{} + mi := &file_proto_v1_gateway_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelSummary) String() string { +func (x *TunnelStatus) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelSummary) ProtoMessage() {} +func (*TunnelStatus) ProtoMessage() {} -func (x *TunnelSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[14] +func (x *TunnelStatus) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2640,115 +2730,106 @@ func (x *TunnelSummary) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelSummary.ProtoReflect.Descriptor instead. -func (*TunnelSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{14} +// Deprecated: Use TunnelStatus.ProtoReflect.Descriptor instead. +func (*TunnelStatus) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} } -func (x *TunnelSummary) GetId() string { +func (x *TunnelStatus) GetId() string { if x != nil { return x.Id } return "" } -func (x *TunnelSummary) GetSlug() string { +func (x *TunnelStatus) GetSlug() string { if x != nil { return x.Slug } return "" } -func (x *TunnelSummary) GetName() string { +func (x *TunnelStatus) GetName() string { if x != nil { return x.Name } return "" } -func (x *TunnelSummary) GetTargetUrl() string { +func (x *TunnelStatus) GetTargetUrl() string { if x != nil { return x.TargetUrl } return "" } -func (x *TunnelSummary) GetPublicUrl() string { +func (x *TunnelStatus) GetPublicPath() string { if x != nil { - return x.PublicUrl + return x.PublicPath } return "" } -func (x *TunnelSummary) GetCreatedAt() int64 { +func (x *TunnelStatus) GetCreatedAt() int64 { if x != nil { return x.CreatedAt } return 0 } -func (x *TunnelSummary) GetExpiresAt() int64 { +func (x *TunnelStatus) GetExpiresAt() int64 { if x != nil { return x.ExpiresAt } return 0 } -func (x *TunnelSummary) GetActiveConnections() uint32 { +func (x *TunnelStatus) GetActiveConnections() uint32 { if x != nil { return x.ActiveConnections } return 0 } -func (x *TunnelSummary) GetStatus() string { - if x != nil { - return x.Status - } - return "" -} - -func (x *TunnelSummary) GetProjectPathKey() string { +func (x *TunnelStatus) GetProjectPathKey() string { if x != nil { return x.ProjectPathKey } return "" } -func (x *TunnelSummary) GetDiagnostics() []*TunnelDiagnostic { +func (x *TunnelStatus) GetLocal() *TunnelHealth { if x != nil { - return x.Diagnostics + return x.Local } return nil } -type TunnelDiagnostic struct { +type TunnelStateSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` - Protocol string `protobuf:"bytes,1,opt,name=protocol,proto3" json:"protocol,omitempty"` - Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` - StatusCode uint32 `protobuf:"varint,3,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` - ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` - Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` - CheckedAt int64 `protobuf:"varint,6,opt,name=checked_at,json=checkedAt,proto3" json:"checked_at,omitempty"` + Tunnels []*TunnelStatus `protobuf:"bytes,1,rep,name=tunnels,proto3" json:"tunnels,omitempty"` + Revision uint64 `protobuf:"varint,2,opt,name=revision,proto3" json:"revision,omitempty"` // gateway-side monotonic + AgentOnline bool `protobuf:"varint,3,opt,name=agent_online,json=agentOnline,proto3" json:"agent_online,omitempty"` + Relay *TunnelHealth `protobuf:"bytes,4,opt,name=relay,proto3" json:"relay,omitempty"` // gateway <-> agent frame path unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *TunnelDiagnostic) Reset() { - *x = TunnelDiagnostic{} - mi := &file_proto_v1_gateway_proto_msgTypes[15] +func (x *TunnelStateSnapshot) Reset() { + *x = TunnelStateSnapshot{} + mi := &file_proto_v1_gateway_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *TunnelDiagnostic) String() string { +func (x *TunnelStateSnapshot) String() string { return protoimpl.X.MessageStringOf(x) } -func (*TunnelDiagnostic) ProtoMessage() {} +func (*TunnelStateSnapshot) ProtoMessage() {} -func (x *TunnelDiagnostic) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[15] +func (x *TunnelStateSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2759,53 +2840,279 @@ func (x *TunnelDiagnostic) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use TunnelDiagnostic.ProtoReflect.Descriptor instead. -func (*TunnelDiagnostic) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{15} +// Deprecated: Use TunnelStateSnapshot.ProtoReflect.Descriptor instead. +func (*TunnelStateSnapshot) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} } -func (x *TunnelDiagnostic) GetProtocol() string { +func (x *TunnelStateSnapshot) GetTunnels() []*TunnelStatus { if x != nil { - return x.Protocol + return x.Tunnels } - return "" + return nil } -func (x *TunnelDiagnostic) GetStatus() string { +func (x *TunnelStateSnapshot) GetRevision() uint64 { if x != nil { - return x.Status + return x.Revision } - return "" + return 0 } -func (x *TunnelDiagnostic) GetStatusCode() uint32 { +func (x *TunnelStateSnapshot) GetAgentOnline() bool { if x != nil { - return x.StatusCode + return x.AgentOnline } - return 0 + return false } -func (x *TunnelDiagnostic) GetErrorCode() string { +func (x *TunnelStateSnapshot) GetRelay() *TunnelHealth { if x != nil { - return x.ErrorCode + return x.Relay } - return "" + return nil } -func (x *TunnelDiagnostic) GetMessage() string { - if x != nil { - return x.Message - } - return "" +type TunnelMutation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` // "create" | "update" | "close" | "check" + TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` // update/close/check + TargetUrl string `protobuf:"bytes,3,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + TtlSeconds *uint32 `protobuf:"varint,5,opt,name=ttl_seconds,json=ttlSeconds,proto3,oneof" json:"ttl_seconds,omitempty"` // absent on update = keep current expiry + ProjectPathKey string `protobuf:"bytes,6,opt,name=project_path_key,json=projectPathKey,proto3" json:"project_path_key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *TunnelDiagnostic) GetCheckedAt() int64 { - if x != nil { - return x.CheckedAt - } +func (x *TunnelMutation) Reset() { + *x = TunnelMutation{} + mi := &file_proto_v1_gateway_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelMutation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelMutation) ProtoMessage() {} + +func (x *TunnelMutation) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelMutation.ProtoReflect.Descriptor instead. +func (*TunnelMutation) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} +} + +func (x *TunnelMutation) GetAction() string { + if x != nil { + return x.Action + } + return "" +} + +func (x *TunnelMutation) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelMutation) GetTargetUrl() string { + if x != nil { + return x.TargetUrl + } + return "" +} + +func (x *TunnelMutation) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TunnelMutation) GetTtlSeconds() uint32 { + if x != nil && x.TtlSeconds != nil { + return *x.TtlSeconds + } return 0 } +func (x *TunnelMutation) GetProjectPathKey() string { + if x != nil { + return x.ProjectPathKey + } + return "" +} + +type TunnelMutationResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TunnelId string `protobuf:"bytes,1,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + ErrorCode string `protobuf:"bytes,2,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` // "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + ErrorMessage string `protobuf:"bytes,3,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelMutationResult) Reset() { + *x = TunnelMutationResult{} + mi := &file_proto_v1_gateway_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelMutationResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelMutationResult) ProtoMessage() {} + +func (x *TunnelMutationResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelMutationResult.ProtoReflect.Descriptor instead. +func (*TunnelMutationResult) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} +} + +func (x *TunnelMutationResult) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelMutationResult) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *TunnelMutationResult) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +type TunnelProbeResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + TunnelId string `protobuf:"bytes,1,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` + Local *TunnelHealth `protobuf:"bytes,2,opt,name=local,proto3" json:"local,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelProbeResult) Reset() { + *x = TunnelProbeResult{} + mi := &file_proto_v1_gateway_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelProbeResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelProbeResult) ProtoMessage() {} + +func (x *TunnelProbeResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelProbeResult.ProtoReflect.Descriptor instead. +func (*TunnelProbeResult) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} +} + +func (x *TunnelProbeResult) GetTunnelId() string { + if x != nil { + return x.TunnelId + } + return "" +} + +func (x *TunnelProbeResult) GetLocal() *TunnelHealth { + if x != nil { + return x.Local + } + return nil +} + +type TunnelProbeReport struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results []*TunnelProbeResult `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TunnelProbeReport) Reset() { + *x = TunnelProbeReport{} + mi := &file_proto_v1_gateway_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TunnelProbeReport) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TunnelProbeReport) ProtoMessage() {} + +func (x *TunnelProbeReport) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TunnelProbeReport.ProtoReflect.Descriptor instead. +func (*TunnelProbeReport) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} +} + +func (x *TunnelProbeReport) GetResults() []*TunnelProbeResult { + if x != nil { + return x.Results + } + return nil +} + type TunnelHeader struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -2816,7 +3123,7 @@ type TunnelHeader struct { func (x *TunnelHeader) Reset() { *x = TunnelHeader{} - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2828,7 +3135,7 @@ func (x *TunnelHeader) String() string { func (*TunnelHeader) ProtoMessage() {} func (x *TunnelHeader) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[16] + mi := &file_proto_v1_gateway_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2841,7 +3148,7 @@ func (x *TunnelHeader) ProtoReflect() protoreflect.Message { // Deprecated: Use TunnelHeader.ProtoReflect.Descriptor instead. func (*TunnelHeader) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{16} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} } func (x *TunnelHeader) GetName() string { @@ -2861,25 +3168,25 @@ func (x *TunnelHeader) GetValue() string { type TunnelFrame struct { state protoimpl.MessageState `protogen:"open.v1"` StreamId string `protobuf:"bytes,1,opt,name=stream_id,json=streamId,proto3" json:"stream_id,omitempty"` - TunnelId string `protobuf:"bytes,2,opt,name=tunnel_id,json=tunnelId,proto3" json:"tunnel_id,omitempty"` - Slug string `protobuf:"bytes,3,opt,name=slug,proto3" json:"slug,omitempty"` - Kind TunnelFrameKind `protobuf:"varint,4,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` - Method string `protobuf:"bytes,5,opt,name=method,proto3" json:"method,omitempty"` - Path string `protobuf:"bytes,6,opt,name=path,proto3" json:"path,omitempty"` - Headers []*TunnelHeader `protobuf:"bytes,7,rep,name=headers,proto3" json:"headers,omitempty"` - StatusCode uint32 `protobuf:"varint,8,opt,name=status_code,json=statusCode,proto3" json:"status_code,omitempty"` - Body []byte `protobuf:"bytes,9,opt,name=body,proto3" json:"body,omitempty"` - EndStream bool `protobuf:"varint,10,opt,name=end_stream,json=endStream,proto3" json:"end_stream,omitempty"` - Error string `protobuf:"bytes,11,opt,name=error,proto3" json:"error,omitempty"` - WsMessageType string `protobuf:"bytes,12,opt,name=ws_message_type,json=wsMessageType,proto3" json:"ws_message_type,omitempty"` - WsProtocol string `protobuf:"bytes,13,opt,name=ws_protocol,json=wsProtocol,proto3" json:"ws_protocol,omitempty"` + Kind TunnelFrameKind `protobuf:"varint,2,opt,name=kind,proto3,enum=liveagent.gateway.v1.TunnelFrameKind" json:"kind,omitempty"` + TargetUrl string `protobuf:"bytes,3,opt,name=target_url,json=targetUrl,proto3" json:"target_url,omitempty"` // set on HTTP_REQUEST_START and WS_DIAL only + Method string `protobuf:"bytes,4,opt,name=method,proto3" json:"method,omitempty"` + Path string `protobuf:"bytes,5,opt,name=path,proto3" json:"path,omitempty"` // path+query relative to the target base + Headers []*TunnelHeader `protobuf:"bytes,6,rep,name=headers,proto3" json:"headers,omitempty"` + Status uint32 `protobuf:"varint,7,opt,name=status,proto3" json:"status,omitempty"` + Body []byte `protobuf:"bytes,8,opt,name=body,proto3" json:"body,omitempty"` + Error string `protobuf:"bytes,9,opt,name=error,proto3" json:"error,omitempty"` + WsMessageType TunnelWsMessageType `protobuf:"varint,10,opt,name=ws_message_type,json=wsMessageType,proto3,enum=liveagent.gateway.v1.TunnelWsMessageType" json:"ws_message_type,omitempty"` + WsSubprotocol string `protobuf:"bytes,11,opt,name=ws_subprotocol,json=wsSubprotocol,proto3" json:"ws_subprotocol,omitempty"` + WsCloseCode uint32 `protobuf:"varint,12,opt,name=ws_close_code,json=wsCloseCode,proto3" json:"ws_close_code,omitempty"` + WsCloseReason string `protobuf:"bytes,13,opt,name=ws_close_reason,json=wsCloseReason,proto3" json:"ws_close_reason,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *TunnelFrame) Reset() { *x = TunnelFrame{} - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2891,7 +3198,7 @@ func (x *TunnelFrame) String() string { func (*TunnelFrame) ProtoMessage() {} func (x *TunnelFrame) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[17] + mi := &file_proto_v1_gateway_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2904,7 +3211,7 @@ func (x *TunnelFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TunnelFrame.ProtoReflect.Descriptor instead. func (*TunnelFrame) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{17} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} } func (x *TunnelFrame) GetStreamId() string { @@ -2914,27 +3221,20 @@ func (x *TunnelFrame) GetStreamId() string { return "" } -func (x *TunnelFrame) GetTunnelId() string { +func (x *TunnelFrame) GetKind() TunnelFrameKind { if x != nil { - return x.TunnelId + return x.Kind } - return "" + return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED } -func (x *TunnelFrame) GetSlug() string { +func (x *TunnelFrame) GetTargetUrl() string { if x != nil { - return x.Slug + return x.TargetUrl } return "" } -func (x *TunnelFrame) GetKind() TunnelFrameKind { - if x != nil { - return x.Kind - } - return TunnelFrameKind_TUNNEL_FRAME_KIND_UNSPECIFIED -} - func (x *TunnelFrame) GetMethod() string { if x != nil { return x.Method @@ -2956,9 +3256,9 @@ func (x *TunnelFrame) GetHeaders() []*TunnelHeader { return nil } -func (x *TunnelFrame) GetStatusCode() uint32 { +func (x *TunnelFrame) GetStatus() uint32 { if x != nil { - return x.StatusCode + return x.Status } return 0 } @@ -2970,13 +3270,6 @@ func (x *TunnelFrame) GetBody() []byte { return nil } -func (x *TunnelFrame) GetEndStream() bool { - if x != nil { - return x.EndStream - } - return false -} - func (x *TunnelFrame) GetError() string { if x != nil { return x.Error @@ -2984,16 +3277,30 @@ func (x *TunnelFrame) GetError() string { return "" } -func (x *TunnelFrame) GetWsMessageType() string { +func (x *TunnelFrame) GetWsMessageType() TunnelWsMessageType { if x != nil { return x.WsMessageType } + return TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED +} + +func (x *TunnelFrame) GetWsSubprotocol() string { + if x != nil { + return x.WsSubprotocol + } return "" } -func (x *TunnelFrame) GetWsProtocol() string { +func (x *TunnelFrame) GetWsCloseCode() uint32 { if x != nil { - return x.WsProtocol + return x.WsCloseCode + } + return 0 +} + +func (x *TunnelFrame) GetWsCloseReason() string { + if x != nil { + return x.WsCloseReason } return "" } @@ -3008,7 +3315,7 @@ type MemoryManageRequest struct { func (x *MemoryManageRequest) Reset() { *x = MemoryManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3020,7 +3327,7 @@ func (x *MemoryManageRequest) String() string { func (*MemoryManageRequest) ProtoMessage() {} func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[18] + mi := &file_proto_v1_gateway_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3033,7 +3340,7 @@ func (x *MemoryManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageRequest.ProtoReflect.Descriptor instead. func (*MemoryManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{18} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} } func (x *MemoryManageRequest) GetCommand() string { @@ -3059,7 +3366,7 @@ type MemoryManageResponse struct { func (x *MemoryManageResponse) Reset() { *x = MemoryManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3071,7 +3378,7 @@ func (x *MemoryManageResponse) String() string { func (*MemoryManageResponse) ProtoMessage() {} func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[19] + mi := &file_proto_v1_gateway_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3084,7 +3391,7 @@ func (x *MemoryManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MemoryManageResponse.ProtoReflect.Descriptor instead. func (*MemoryManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{19} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} } func (x *MemoryManageResponse) GetResultJson() string { @@ -3119,7 +3426,7 @@ type TerminalRequest struct { func (x *TerminalRequest) Reset() { *x = TerminalRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3131,7 +3438,7 @@ func (x *TerminalRequest) String() string { func (*TerminalRequest) ProtoMessage() {} func (x *TerminalRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[20] + mi := &file_proto_v1_gateway_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3144,7 +3451,7 @@ func (x *TerminalRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalRequest.ProtoReflect.Descriptor instead. func (*TerminalRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{20} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} } func (x *TerminalRequest) GetAction() string { @@ -3289,7 +3596,7 @@ type TerminalSession struct { func (x *TerminalSession) Reset() { *x = TerminalSession{} - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3301,7 +3608,7 @@ func (x *TerminalSession) String() string { func (*TerminalSession) ProtoMessage() {} func (x *TerminalSession) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[21] + mi := &file_proto_v1_gateway_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3314,7 +3621,7 @@ func (x *TerminalSession) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSession.ProtoReflect.Descriptor instead. func (*TerminalSession) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{21} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} } func (x *TerminalSession) GetId() string { @@ -3440,7 +3747,7 @@ type TerminalSshMetadata struct { func (x *TerminalSshMetadata) Reset() { *x = TerminalSshMetadata{} - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3452,7 +3759,7 @@ func (x *TerminalSshMetadata) String() string { func (*TerminalSshMetadata) ProtoMessage() {} func (x *TerminalSshMetadata) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[22] + mi := &file_proto_v1_gateway_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3465,7 +3772,7 @@ func (x *TerminalSshMetadata) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshMetadata.ProtoReflect.Descriptor instead. func (*TerminalSshMetadata) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{22} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} } func (x *TerminalSshMetadata) GetHostId() string { @@ -3558,7 +3865,7 @@ type SftpRequest struct { func (x *SftpRequest) Reset() { *x = SftpRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3570,7 +3877,7 @@ func (x *SftpRequest) String() string { func (*SftpRequest) ProtoMessage() {} func (x *SftpRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[23] + mi := &file_proto_v1_gateway_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3583,7 +3890,7 @@ func (x *SftpRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpRequest.ProtoReflect.Descriptor instead. func (*SftpRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{23} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} } func (x *SftpRequest) GetAction() string { @@ -3683,7 +3990,7 @@ type SftpEntry struct { func (x *SftpEntry) Reset() { *x = SftpEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3695,7 +4002,7 @@ func (x *SftpEntry) String() string { func (*SftpEntry) ProtoMessage() {} func (x *SftpEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[24] + mi := &file_proto_v1_gateway_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3708,7 +4015,7 @@ func (x *SftpEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpEntry.ProtoReflect.Descriptor instead. func (*SftpEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{24} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} } func (x *SftpEntry) GetPath() string { @@ -3766,7 +4073,7 @@ type SftpTransfer struct { func (x *SftpTransfer) Reset() { *x = SftpTransfer{} - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3778,7 +4085,7 @@ func (x *SftpTransfer) String() string { func (*SftpTransfer) ProtoMessage() {} func (x *SftpTransfer) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[25] + mi := &file_proto_v1_gateway_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3791,7 +4098,7 @@ func (x *SftpTransfer) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpTransfer.ProtoReflect.Descriptor instead. func (*SftpTransfer) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{25} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} } func (x *SftpTransfer) GetId() string { @@ -3892,7 +4199,7 @@ type SftpResponse struct { func (x *SftpResponse) Reset() { *x = SftpResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3904,7 +4211,7 @@ func (x *SftpResponse) String() string { func (*SftpResponse) ProtoMessage() {} func (x *SftpResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[26] + mi := &file_proto_v1_gateway_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3917,7 +4224,7 @@ func (x *SftpResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpResponse.ProtoReflect.Descriptor instead. func (*SftpResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{26} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} } func (x *SftpResponse) GetAction() string { @@ -3972,7 +4279,7 @@ type SftpEvent struct { func (x *SftpEvent) Reset() { *x = SftpEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3984,7 +4291,7 @@ func (x *SftpEvent) String() string { func (*SftpEvent) ProtoMessage() {} func (x *SftpEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[27] + mi := &file_proto_v1_gateway_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3997,7 +4304,7 @@ func (x *SftpEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SftpEvent.ProtoReflect.Descriptor instead. func (*SftpEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{27} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} } func (x *SftpEvent) GetKind() string { @@ -4032,7 +4339,7 @@ type TerminalSshPrompt struct { func (x *TerminalSshPrompt) Reset() { *x = TerminalSshPrompt{} - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4044,7 +4351,7 @@ func (x *TerminalSshPrompt) String() string { func (*TerminalSshPrompt) ProtoMessage() {} func (x *TerminalSshPrompt) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[28] + mi := &file_proto_v1_gateway_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4057,7 +4364,7 @@ func (x *TerminalSshPrompt) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshPrompt.ProtoReflect.Descriptor instead. func (*TerminalSshPrompt) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{28} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} } func (x *TerminalSshPrompt) GetId() string { @@ -4141,7 +4448,7 @@ type TerminalShellOption struct { func (x *TerminalShellOption) Reset() { *x = TerminalShellOption{} - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4153,7 +4460,7 @@ func (x *TerminalShellOption) String() string { func (*TerminalShellOption) ProtoMessage() {} func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[29] + mi := &file_proto_v1_gateway_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4166,7 +4473,7 @@ func (x *TerminalShellOption) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalShellOption.ProtoReflect.Descriptor instead. func (*TerminalShellOption) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{29} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} } func (x *TerminalShellOption) GetId() string { @@ -4204,7 +4511,7 @@ type TerminalSshTab struct { func (x *TerminalSshTab) Reset() { *x = TerminalSshTab{} - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4216,7 +4523,7 @@ func (x *TerminalSshTab) String() string { func (*TerminalSshTab) ProtoMessage() {} func (x *TerminalSshTab) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[30] + mi := &file_proto_v1_gateway_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4229,7 +4536,7 @@ func (x *TerminalSshTab) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshTab.ProtoReflect.Descriptor instead. func (*TerminalSshTab) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{30} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} } func (x *TerminalSshTab) GetId() string { @@ -4285,7 +4592,7 @@ type TerminalSshTabsSnapshot struct { func (x *TerminalSshTabsSnapshot) Reset() { *x = TerminalSshTabsSnapshot{} - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4297,7 +4604,7 @@ func (x *TerminalSshTabsSnapshot) String() string { func (*TerminalSshTabsSnapshot) ProtoMessage() {} func (x *TerminalSshTabsSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[31] + mi := &file_proto_v1_gateway_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4310,7 +4617,7 @@ func (x *TerminalSshTabsSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalSshTabsSnapshot.ProtoReflect.Descriptor instead. func (*TerminalSshTabsSnapshot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{31} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} } func (x *TerminalSshTabsSnapshot) GetProjectPathKey() string { @@ -4354,7 +4661,7 @@ type TerminalResponse struct { func (x *TerminalResponse) Reset() { *x = TerminalResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4366,7 +4673,7 @@ func (x *TerminalResponse) String() string { func (*TerminalResponse) ProtoMessage() {} func (x *TerminalResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[32] + mi := &file_proto_v1_gateway_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4379,7 +4686,7 @@ func (x *TerminalResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalResponse.ProtoReflect.Descriptor instead. func (*TerminalResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{32} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} } func (x *TerminalResponse) GetAction() string { @@ -4482,7 +4789,7 @@ type TerminalEvent struct { func (x *TerminalEvent) Reset() { *x = TerminalEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4494,7 +4801,7 @@ func (x *TerminalEvent) String() string { func (*TerminalEvent) ProtoMessage() {} func (x *TerminalEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[33] + mi := &file_proto_v1_gateway_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4507,7 +4814,7 @@ func (x *TerminalEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalEvent.ProtoReflect.Descriptor instead. func (*TerminalEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{33} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} } func (x *TerminalEvent) GetKind() string { @@ -4588,7 +4895,7 @@ type TerminalStreamFrame struct { func (x *TerminalStreamFrame) Reset() { *x = TerminalStreamFrame{} - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4600,7 +4907,7 @@ func (x *TerminalStreamFrame) String() string { func (*TerminalStreamFrame) ProtoMessage() {} func (x *TerminalStreamFrame) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[34] + mi := &file_proto_v1_gateway_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4613,7 +4920,7 @@ func (x *TerminalStreamFrame) ProtoReflect() protoreflect.Message { // Deprecated: Use TerminalStreamFrame.ProtoReflect.Descriptor instead. func (*TerminalStreamFrame) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{34} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} } func (x *TerminalStreamFrame) GetKind() string { @@ -4725,7 +5032,7 @@ type GitRequest struct { func (x *GitRequest) Reset() { *x = GitRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4737,7 +5044,7 @@ func (x *GitRequest) String() string { func (*GitRequest) ProtoMessage() {} func (x *GitRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[35] + mi := &file_proto_v1_gateway_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4750,7 +5057,7 @@ func (x *GitRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GitRequest.ProtoReflect.Descriptor instead. func (*GitRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{35} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} } func (x *GitRequest) GetAction() string { @@ -4784,7 +5091,7 @@ type GitResponse struct { func (x *GitResponse) Reset() { *x = GitResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4796,7 +5103,7 @@ func (x *GitResponse) String() string { func (*GitResponse) ProtoMessage() {} func (x *GitResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[36] + mi := &file_proto_v1_gateway_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4809,7 +5116,7 @@ func (x *GitResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GitResponse.ProtoReflect.Descriptor instead. func (*GitResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{36} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} } func (x *GitResponse) GetAction() string { @@ -4844,7 +5151,7 @@ type ChatRequest struct { func (x *ChatRequest) Reset() { *x = ChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4856,7 +5163,7 @@ func (x *ChatRequest) String() string { func (*ChatRequest) ProtoMessage() {} func (x *ChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[37] + mi := &file_proto_v1_gateway_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4869,7 +5176,7 @@ func (x *ChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead. func (*ChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{37} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} } func (x *ChatRequest) GetConversationId() string { @@ -4956,7 +5263,7 @@ type ChatMessageRef struct { func (x *ChatMessageRef) Reset() { *x = ChatMessageRef{} - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4968,7 +5275,7 @@ func (x *ChatMessageRef) String() string { func (*ChatMessageRef) ProtoMessage() {} func (x *ChatMessageRef) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[38] + mi := &file_proto_v1_gateway_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4981,7 +5288,7 @@ func (x *ChatMessageRef) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatMessageRef.ProtoReflect.Descriptor instead. func (*ChatMessageRef) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{38} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} } func (x *ChatMessageRef) GetSegmentIndex() int32 { @@ -5035,7 +5342,7 @@ type CancelChatRequest struct { func (x *CancelChatRequest) Reset() { *x = CancelChatRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5047,7 +5354,7 @@ func (x *CancelChatRequest) String() string { func (*CancelChatRequest) ProtoMessage() {} func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[39] + mi := &file_proto_v1_gateway_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5060,7 +5367,7 @@ func (x *CancelChatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CancelChatRequest.ProtoReflect.Descriptor instead. func (*CancelChatRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{39} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} } func (x *CancelChatRequest) GetConversationId() string { @@ -5082,7 +5389,7 @@ type ChatCommandRequest struct { func (x *ChatCommandRequest) Reset() { *x = ChatCommandRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5094,7 +5401,7 @@ func (x *ChatCommandRequest) String() string { func (*ChatCommandRequest) ProtoMessage() {} func (x *ChatCommandRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[40] + mi := &file_proto_v1_gateway_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5107,7 +5414,7 @@ func (x *ChatCommandRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatCommandRequest.ProtoReflect.Descriptor instead. func (*ChatCommandRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{40} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} } func (x *ChatCommandRequest) GetType() string { @@ -5154,7 +5461,7 @@ type ChatQueueRequest struct { func (x *ChatQueueRequest) Reset() { *x = ChatQueueRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5166,7 +5473,7 @@ func (x *ChatQueueRequest) String() string { func (*ChatQueueRequest) ProtoMessage() {} func (x *ChatQueueRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[41] + mi := &file_proto_v1_gateway_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5179,7 +5486,7 @@ func (x *ChatQueueRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueRequest.ProtoReflect.Descriptor instead. func (*ChatQueueRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{41} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} } func (x *ChatQueueRequest) GetAction() string { @@ -5252,7 +5559,7 @@ type ChatQueueResponse struct { func (x *ChatQueueResponse) Reset() { *x = ChatQueueResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5264,7 +5571,7 @@ func (x *ChatQueueResponse) String() string { func (*ChatQueueResponse) ProtoMessage() {} func (x *ChatQueueResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[42] + mi := &file_proto_v1_gateway_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5277,7 +5584,7 @@ func (x *ChatQueueResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueResponse.ProtoReflect.Descriptor instead. func (*ChatQueueResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{42} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} } func (x *ChatQueueResponse) GetAccepted() bool { @@ -5333,7 +5640,7 @@ type ChatQueueEvent struct { func (x *ChatQueueEvent) Reset() { *x = ChatQueueEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5345,7 +5652,7 @@ func (x *ChatQueueEvent) String() string { func (*ChatQueueEvent) ProtoMessage() {} func (x *ChatQueueEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[43] + mi := &file_proto_v1_gateway_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5358,7 +5665,7 @@ func (x *ChatQueueEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatQueueEvent.ProtoReflect.Descriptor instead. func (*ChatQueueEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{43} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} } func (x *ChatQueueEvent) GetConversationId() string { @@ -5393,7 +5700,7 @@ type ChatEvent struct { func (x *ChatEvent) Reset() { *x = ChatEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5405,7 +5712,7 @@ func (x *ChatEvent) String() string { func (*ChatEvent) ProtoMessage() {} func (x *ChatEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[44] + mi := &file_proto_v1_gateway_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5418,7 +5725,7 @@ func (x *ChatEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEvent.ProtoReflect.Descriptor instead. func (*ChatEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{44} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} } func (x *ChatEvent) GetType() ChatEvent_ChatEventType { @@ -5459,7 +5766,7 @@ type ChatControlEvent struct { func (x *ChatControlEvent) Reset() { *x = ChatControlEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5471,7 +5778,7 @@ func (x *ChatControlEvent) String() string { func (*ChatControlEvent) ProtoMessage() {} func (x *ChatControlEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[45] + mi := &file_proto_v1_gateway_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5484,7 +5791,7 @@ func (x *ChatControlEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatControlEvent.ProtoReflect.Descriptor instead. func (*ChatControlEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{45} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} } func (x *ChatControlEvent) GetRequestId() string { @@ -5569,7 +5876,7 @@ type ChatRuntimeSnapshot struct { func (x *ChatRuntimeSnapshot) Reset() { *x = ChatRuntimeSnapshot{} - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5581,7 +5888,7 @@ func (x *ChatRuntimeSnapshot) String() string { func (*ChatRuntimeSnapshot) ProtoMessage() {} func (x *ChatRuntimeSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[46] + mi := &file_proto_v1_gateway_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5594,7 +5901,7 @@ func (x *ChatRuntimeSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatRuntimeSnapshot.ProtoReflect.Descriptor instead. func (*ChatRuntimeSnapshot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{46} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} } func (x *ChatRuntimeSnapshot) GetConversationId() string { @@ -5687,7 +5994,7 @@ type RuntimeStatusEvent struct { func (x *RuntimeStatusEvent) Reset() { *x = RuntimeStatusEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5699,7 +6006,7 @@ func (x *RuntimeStatusEvent) String() string { func (*RuntimeStatusEvent) ProtoMessage() {} func (x *RuntimeStatusEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[47] + mi := &file_proto_v1_gateway_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5712,7 +6019,7 @@ func (x *RuntimeStatusEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use RuntimeStatusEvent.ProtoReflect.Descriptor instead. func (*RuntimeStatusEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{47} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} } func (x *RuntimeStatusEvent) GetWorkerId() string { @@ -5761,7 +6068,7 @@ type ChatEventReplayRequest struct { func (x *ChatEventReplayRequest) Reset() { *x = ChatEventReplayRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5773,7 +6080,7 @@ func (x *ChatEventReplayRequest) String() string { func (*ChatEventReplayRequest) ProtoMessage() {} func (x *ChatEventReplayRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[48] + mi := &file_proto_v1_gateway_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5786,7 +6093,7 @@ func (x *ChatEventReplayRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEventReplayRequest.ProtoReflect.Descriptor instead. func (*ChatEventReplayRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{48} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} } func (x *ChatEventReplayRequest) GetRunId() string { @@ -5822,7 +6129,7 @@ type ChatEventReplayResponse struct { func (x *ChatEventReplayResponse) Reset() { *x = ChatEventReplayResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5834,7 +6141,7 @@ func (x *ChatEventReplayResponse) String() string { func (*ChatEventReplayResponse) ProtoMessage() {} func (x *ChatEventReplayResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[49] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5847,7 +6154,7 @@ func (x *ChatEventReplayResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatEventReplayResponse.ProtoReflect.Descriptor instead. func (*ChatEventReplayResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{49} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *ChatEventReplayResponse) GetRunId() string { @@ -5888,7 +6195,7 @@ type ChatReplayEvent struct { func (x *ChatReplayEvent) Reset() { *x = ChatReplayEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5900,7 +6207,7 @@ func (x *ChatReplayEvent) String() string { func (*ChatReplayEvent) ProtoMessage() {} func (x *ChatReplayEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[50] + mi := &file_proto_v1_gateway_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5913,7 +6220,7 @@ func (x *ChatReplayEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ChatReplayEvent.ProtoReflect.Descriptor instead. func (*ChatReplayEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{50} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} } func (x *ChatReplayEvent) GetSeq() int64 { @@ -5941,7 +6248,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5953,7 +6260,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[51] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5966,7 +6273,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{51} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *CronManageRequest) GetAction() string { @@ -6000,7 +6307,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6012,7 +6319,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[52] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6025,7 +6332,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{52} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } func (x *CronManageResponse) GetAction() string { @@ -6054,7 +6361,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6066,7 +6373,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6079,7 +6386,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *HistoryListRequest) GetPage() int32 { @@ -6120,7 +6427,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6132,7 +6439,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6145,7 +6452,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -6182,7 +6489,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6194,7 +6501,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6207,7 +6514,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *ConversationSummary) GetId() string { @@ -6304,7 +6611,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6316,7 +6623,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6329,7 +6636,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *HistoryGetRequest) GetConversationId() string { @@ -6360,7 +6667,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6372,7 +6679,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6385,7 +6692,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } func (x *HistoryGetResponse) GetConversationId() string { @@ -6441,7 +6748,7 @@ type HistoryPrefixRequest struct { func (x *HistoryPrefixRequest) Reset() { *x = HistoryPrefixRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6453,7 +6760,7 @@ func (x *HistoryPrefixRequest) String() string { func (*HistoryPrefixRequest) ProtoMessage() {} func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6466,7 +6773,7 @@ func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixRequest.ProtoReflect.Descriptor instead. func (*HistoryPrefixRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *HistoryPrefixRequest) GetConversationId() string { @@ -6504,7 +6811,7 @@ type HistoryPrefixResponse struct { func (x *HistoryPrefixResponse) Reset() { *x = HistoryPrefixResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6516,7 +6823,7 @@ func (x *HistoryPrefixResponse) String() string { func (*HistoryPrefixResponse) ProtoMessage() {} func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6529,7 +6836,7 @@ func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixResponse.ProtoReflect.Descriptor instead. func (*HistoryPrefixResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } func (x *HistoryPrefixResponse) GetConversationId() string { @@ -6584,7 +6891,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6596,7 +6903,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6609,7 +6916,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -6635,7 +6942,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6647,7 +6954,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6660,7 +6967,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -6680,7 +6987,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6692,7 +6999,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6705,7 +7012,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *HistoryPinRequest) GetConversationId() string { @@ -6731,7 +7038,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6743,7 +7050,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6756,7 +7063,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -6780,7 +7087,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6792,7 +7099,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6805,7 +7112,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *HistoryShareStatus) GetConversationId() string { @@ -6859,7 +7166,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6871,7 +7178,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6884,7 +7191,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -6903,7 +7210,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6915,7 +7222,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6928,7 +7235,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -6949,7 +7256,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6961,7 +7268,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6974,7 +7281,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -7007,7 +7314,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7019,7 +7326,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7032,7 +7339,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -7051,7 +7358,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7063,7 +7370,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7076,7 +7383,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -7099,7 +7406,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7111,7 +7418,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7124,7 +7431,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -7170,7 +7477,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7182,7 +7489,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7195,7 +7502,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } type HistoryWorkdirSummary struct { @@ -7209,7 +7516,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7221,7 +7528,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7234,7 +7541,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -7267,7 +7574,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7279,7 +7586,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7292,7 +7599,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -7311,7 +7618,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7323,7 +7630,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7336,7 +7643,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -7354,7 +7661,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7366,7 +7673,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7379,7 +7686,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } type HistorySyncEvent struct { @@ -7393,7 +7700,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7405,7 +7712,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7418,7 +7725,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *HistorySyncEvent) GetKind() string { @@ -7450,7 +7757,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7462,7 +7769,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7475,7 +7782,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } type ProviderListResponse struct { @@ -7487,7 +7794,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7499,7 +7806,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7512,7 +7819,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -7530,7 +7837,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7542,7 +7849,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7555,7 +7862,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } type SettingsGetResponse struct { @@ -7567,7 +7874,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7579,7 +7886,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7592,7 +7899,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -7611,7 +7918,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7623,7 +7930,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7636,7 +7943,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -7656,7 +7963,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7668,7 +7975,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7681,7 +7988,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -7708,7 +8015,7 @@ type SettingsResetSshKnownHostRequest struct { func (x *SettingsResetSshKnownHostRequest) Reset() { *x = SettingsResetSshKnownHostRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7720,7 +8027,7 @@ func (x *SettingsResetSshKnownHostRequest) String() string { func (*SettingsResetSshKnownHostRequest) ProtoMessage() {} func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7733,7 +8040,7 @@ func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsResetSshKnownHostRequest.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *SettingsResetSshKnownHostRequest) GetHost() string { @@ -7759,7 +8066,7 @@ type SettingsResetSshKnownHostResponse struct { func (x *SettingsResetSshKnownHostResponse) Reset() { *x = SettingsResetSshKnownHostResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7771,7 +8078,7 @@ func (x *SettingsResetSshKnownHostResponse) String() string { func (*SettingsResetSshKnownHostResponse) ProtoMessage() {} func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7784,7 +8091,7 @@ func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SettingsResetSshKnownHostResponse.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } func (x *SettingsResetSshKnownHostResponse) GetDeleted() uint32 { @@ -7803,7 +8110,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7815,7 +8122,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7828,7 +8135,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -7846,7 +8153,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7858,7 +8165,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7871,7 +8178,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } type SkillFilesListResponse struct { @@ -7885,7 +8192,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7897,7 +8204,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7910,7 +8217,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -7943,7 +8250,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7955,7 +8262,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7968,7 +8275,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -7988,7 +8295,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8000,7 +8307,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8013,7 +8320,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *SkillMetadataReadResponse) GetName() string { @@ -8041,7 +8348,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8053,7 +8360,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8066,7 +8373,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *SkillTextReadRequest) GetPath() string { @@ -8100,7 +8407,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8112,7 +8419,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8125,7 +8432,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *SkillTextReadResponse) GetContent() string { @@ -8151,7 +8458,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8163,7 +8470,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8176,7 +8483,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -8195,7 +8502,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8207,7 +8514,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8220,7 +8527,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } func (x *SkillManageResponse) GetResultJson() string { @@ -8241,7 +8548,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8253,7 +8560,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8266,7 +8573,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -8300,7 +8607,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8312,7 +8619,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8325,7 +8632,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} } func (x *FileMentionEntry) GetPath() string { @@ -8352,7 +8659,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8364,7 +8671,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8377,7 +8684,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -8406,7 +8713,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8418,7 +8725,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8431,7 +8738,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} } func (x *FsRoot) GetId() string { @@ -8470,7 +8777,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8482,7 +8789,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8495,7 +8802,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} } type FsRootsResponse struct { @@ -8507,7 +8814,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8519,7 +8826,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8532,7 +8839,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -8552,7 +8859,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8564,7 +8871,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8577,7 +8884,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} } func (x *FsListDirsRequest) GetPath() string { @@ -8604,7 +8911,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8616,7 +8923,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8629,7 +8936,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} } func (x *FsDirEntry) GetPath() string { @@ -8657,7 +8964,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8669,7 +8976,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8682,7 +8989,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} } func (x *FsListDirsResponse) GetPath() string { @@ -8716,7 +9023,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8728,7 +9035,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8741,7 +9048,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -8767,7 +9074,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8779,7 +9086,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8792,7 +9099,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -8815,7 +9122,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8827,7 +9134,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8840,7 +9147,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} } func (x *FsListRequest) GetWorkdir() string { @@ -8888,7 +9195,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8900,7 +9207,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8913,7 +9220,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} } func (x *FsListEntry) GetPath() string { @@ -8946,7 +9253,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8958,7 +9265,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8971,7 +9278,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} } func (x *FsListResponse) GetPath() string { @@ -9040,7 +9347,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9052,7 +9359,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9065,7 +9372,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -9096,7 +9403,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9108,7 +9415,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9121,7 +9428,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -9176,7 +9483,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9188,7 +9495,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9201,7 +9508,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -9232,7 +9539,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9244,7 +9551,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9257,7 +9564,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -9318,7 +9625,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9330,7 +9637,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9343,7 +9650,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -9417,7 +9724,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9429,7 +9736,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9442,7 +9749,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} } func (x *FsWriteTextResponse) GetPath() string { @@ -9504,7 +9811,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9516,7 +9823,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9529,7 +9836,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -9556,7 +9863,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9568,7 +9875,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9581,7 +9888,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} } func (x *FsCreateDirResponse) GetPath() string { @@ -9609,7 +9916,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9621,7 +9928,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9634,7 +9941,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} } func (x *FsRenameRequest) GetWorkdir() string { @@ -9669,7 +9976,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9681,7 +9988,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9694,7 +10001,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} } func (x *FsRenameResponse) GetFromPath() string { @@ -9728,7 +10035,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9740,7 +10047,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9753,7 +10060,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{123} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -9780,7 +10087,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9792,7 +10099,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9805,7 +10112,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{124} } func (x *FsDeleteResponse) GetPath() string { @@ -9831,7 +10138,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[120] + mi := &file_proto_v1_gateway_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9843,7 +10150,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[120] + mi := &file_proto_v1_gateway_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9856,7 +10163,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{125} } func (x *PingRequest) GetTimestamp() int64 { @@ -9875,7 +10182,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[121] + mi := &file_proto_v1_gateway_proto_msgTypes[126] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9887,7 +10194,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[121] + mi := &file_proto_v1_gateway_proto_msgTypes[126] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9900,7 +10207,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{126} } func (x *PongResponse) GetTimestamp() int64 { @@ -9920,7 +10227,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[122] + mi := &file_proto_v1_gateway_proto_msgTypes[127] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9932,7 +10239,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[122] + mi := &file_proto_v1_gateway_proto_msgTypes[127] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9945,7 +10252,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{127} } func (x *ErrorResponse) GetCode() int32 { @@ -9975,7 +10282,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\xa3\x1d\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\xa2\x1d\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -10022,15 +10329,15 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "gitRequest\x12d\n" + "\x15fs_read_editable_text\x18> \x01(\v2/.liveagent.gateway.v1.FsReadEditableTextRequestH\x00R\x12fsReadEditableText\x12j\n" + "\x17fs_read_workspace_image\x18? \x01(\v21.liveagent.gateway.v1.FsReadWorkspaceImageRequestH\x00R\x14fsReadWorkspaceImage\x12F\n" + - "\fsftp_request\x18@ \x01(\v2!.liveagent.gateway.v1.SftpRequestH\x00R\vsftpRequest\x12S\n" + - "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + - "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + - "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12z\n" + + "\fsftp_request\x18@ \x01(\v2!.liveagent.gateway.v1.SftpRequestH\x00R\vsftpRequest\x12z\n" + "\x1dsettings_reset_ssh_known_host\x18H \x01(\v26.liveagent.gateway.v1.SettingsResetSshKnownHostRequestH\x00R\x19settingsResetSshKnownHost\x12G\n" + "\n" + "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueue\x12Z\n" + - "\x11chat_event_replay\x18J \x01(\v2,.liveagent.gateway.v1.ChatEventReplayRequestH\x00R\x0fchatEventReplayB\t\n" + - "\apayload\"\xba%\n" + + "\x11chat_event_replay\x18J \x01(\v2,.liveagent.gateway.v1.ChatEventReplayRequestH\x00R\x0fchatEventReplay\x12N\n" + + "\ftunnel_state\x18P \x01(\v2).liveagent.gateway.v1.TunnelStateSnapshotH\x00R\vtunnelState\x12O\n" + + "\x0ftunnel_mutation\x18Q \x01(\v2$.liveagent.gateway.v1.TunnelMutationH\x00R\x0etunnelMutation\x12F\n" + + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrameB\t\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10F\"\xaa&\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -10081,17 +10388,18 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\n" + "sftp_event\x18J \x01(\v2\x1f.liveagent.gateway.v1.SftpEventH\x00R\tsftpEvent\x12Q\n" + "\x0fchat_queue_resp\x18K \x01(\v2'.liveagent.gateway.v1.ChatQueueResponseH\x00R\rchatQueueResp\x12P\n" + - "\x10chat_queue_event\x18L \x01(\v2$.liveagent.gateway.v1.ChatQueueEventH\x00R\x0echatQueueEvent\x12S\n" + - "\x0etunnel_control\x18C \x01(\v2*.liveagent.gateway.v1.TunnelControlRequestH\x00R\rtunnelControl\x12]\n" + - "\x13tunnel_control_resp\x18D \x01(\v2+.liveagent.gateway.v1.TunnelControlResponseH\x00R\x11tunnelControlResp\x12F\n" + - "\ftunnel_frame\x18E \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12K\n" + + "\x10chat_queue_event\x18L \x01(\v2$.liveagent.gateway.v1.ChatQueueEventH\x00R\x0echatQueueEvent\x12K\n" + "\fchat_control\x18F \x01(\v2&.liveagent.gateway.v1.ChatControlEventH\x00R\vchatControl\x12Q\n" + "\x0eruntime_status\x18G \x01(\v2(.liveagent.gateway.v1.RuntimeStatusEventH\x00R\rruntimeStatus\x12\x84\x01\n" + "\"settings_reset_ssh_known_host_resp\x18H \x01(\v27.liveagent.gateway.v1.SettingsResetSshKnownHostResponseH\x00R\x1dsettingsResetSshKnownHostResp\x12_\n" + "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12d\n" + - "\x16chat_event_replay_resp\x18N \x01(\v2-.liveagent.gateway.v1.ChatEventReplayResponseH\x00R\x13chatEventReplayResp\x12;\n" + + "\x16chat_event_replay_resp\x18N \x01(\v2-.liveagent.gateway.v1.ChatEventReplayResponseH\x00R\x13chatEventReplayResp\x12Q\n" + + "\x0etunnel_desired\x18P \x01(\v2(.liveagent.gateway.v1.TunnelDesiredStateH\x00R\rtunnelDesired\x12b\n" + + "\x16tunnel_mutation_result\x18Q \x01(\v2*.liveagent.gateway.v1.TunnelMutationResultH\x00R\x14tunnelMutationResult\x12F\n" + + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12Y\n" + + "\x13tunnel_probe_report\x18S \x01(\v2'.liveagent.gateway.v1.TunnelProbeReportH\x00R\x11tunnelProbeReport\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + - "\apayload\"|\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10F\"|\n" + "\x11ChatSelectedModel\x12,\n" + "\x12custom_provider_id\x18\x01 \x01(\tR\x10customProviderId\x12\x14\n" + "\x05model\x18\x02 \x01(\tR\x05model\x12#\n" + @@ -10122,78 +10430,88 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\rabsolute_path\x18\x02 \x01(\tR\fabsolutePath\"O\n" + "\x1cUploadedImagePreviewResponse\x12\x1b\n" + "\tmime_type\x18\x01 \x01(\tR\bmimeType\x12\x12\n" + - "\x04data\x18\x02 \x01(\tR\x04data\"\xc3\x02\n" + - "\x14TunnelControlRequest\x12\x16\n" + - "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + - "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + - "\x04slug\x18\x03 \x01(\tR\x04slug\x12\x1d\n" + + "\x04data\x18\x02 \x01(\tR\x04data\"\xb5\x01\n" + "\n" + - "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x12\n" + - "\x04name\x18\x05 \x01(\tR\x04name\x12\x1f\n" + - "\vttl_seconds\x18\x06 \x01(\rR\n" + - "ttlSeconds\x12\x1d\n" + + "TunnelSpec\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1b\n" + + "\tslug_hint\x18\x02 \x01(\tR\bslugHint\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "expires_at\x18\a \x01(\x03R\texpiresAt\x12\x1d\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + "\n" + - "public_url\x18\b \x01(\tR\tpublicUrl\x12&\n" + - "\x0fpublic_base_url\x18\t \x01(\tR\rpublicBaseUrl\x12(\n" + - "\x10project_path_key\x18\n" + - " \x01(\tR\x0eprojectPathKey\"\xef\x01\n" + - "\x15TunnelControlResponse\x12\x16\n" + - "\x06action\x18\x01 \x01(\tR\x06action\x12=\n" + - "\atunnels\x18\x02 \x03(\v2#.liveagent.gateway.v1.TunnelSummaryR\atunnels\x12;\n" + - "\x06tunnel\x18\x03 \x01(\v2#.liveagent.gateway.v1.TunnelSummaryR\x06tunnel\x12\x1d\n" + + "expires_at\x18\x05 \x01(\x03R\texpiresAt\x12(\n" + + "\x10project_path_key\x18\x06 \x01(\tR\x0eprojectPathKey\"l\n" + + "\x12TunnelDesiredState\x12:\n" + + "\atunnels\x18\x01 \x03(\v2 .liveagent.gateway.v1.TunnelSpecR\atunnels\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x04R\brevision\"\x93\x01\n" + + "\fTunnelHealth\x12\x16\n" + + "\x06status\x18\x01 \x01(\tR\x06status\x12\x1f\n" + + "\vhttp_status\x18\x02 \x01(\rR\n" + + "httpStatus\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x1d\n" + "\n" + - "error_code\x18\x04 \x01(\tR\terrorCode\x12#\n" + - "\rerror_message\x18\x05 \x01(\tR\ferrorMessage\"\xfe\x02\n" + - "\rTunnelSummary\x12\x0e\n" + + "checked_at\x18\x04 \x01(\x03R\tcheckedAt\x12\x15\n" + + "\x06rtt_ms\x18\x05 \x01(\rR\x05rttMs\"\xd7\x02\n" + + "\fTunnelStatus\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + "\x04slug\x18\x02 \x01(\tR\x04slug\x12\x12\n" + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1d\n" + "\n" + - "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1d\n" + - "\n" + - "public_url\x18\x05 \x01(\tR\tpublicUrl\x12\x1d\n" + + "target_url\x18\x04 \x01(\tR\ttargetUrl\x12\x1f\n" + + "\vpublic_path\x18\x05 \x01(\tR\n" + + "publicPath\x12\x1d\n" + "\n" + "created_at\x18\x06 \x01(\x03R\tcreatedAt\x12\x1d\n" + "\n" + "expires_at\x18\a \x01(\x03R\texpiresAt\x12-\n" + - "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12\x16\n" + - "\x06status\x18\t \x01(\tR\x06status\x12(\n" + - "\x10project_path_key\x18\n" + - " \x01(\tR\x0eprojectPathKey\x12H\n" + - "\vdiagnostics\x18\v \x03(\v2&.liveagent.gateway.v1.TunnelDiagnosticR\vdiagnostics\"\xbf\x01\n" + - "\x10TunnelDiagnostic\x12\x1a\n" + - "\bprotocol\x18\x01 \x01(\tR\bprotocol\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\x06status\x12\x1f\n" + - "\vstatus_code\x18\x03 \x01(\rR\n" + - "statusCode\x12\x1d\n" + + "\x12active_connections\x18\b \x01(\rR\x11activeConnections\x12(\n" + + "\x10project_path_key\x18\t \x01(\tR\x0eprojectPathKey\x128\n" + + "\x05local\x18\n" + + " \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05local\"\xcc\x01\n" + + "\x13TunnelStateSnapshot\x12<\n" + + "\atunnels\x18\x01 \x03(\v2\".liveagent.gateway.v1.TunnelStatusR\atunnels\x12\x1a\n" + + "\brevision\x18\x02 \x01(\x04R\brevision\x12!\n" + + "\fagent_online\x18\x03 \x01(\bR\vagentOnline\x128\n" + + "\x05relay\x18\x04 \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05relay\"\xd8\x01\n" + + "\x0eTunnelMutation\x12\x16\n" + + "\x06action\x18\x01 \x01(\tR\x06action\x12\x1b\n" + + "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x1d\n" + "\n" + - "error_code\x18\x04 \x01(\tR\terrorCode\x12\x18\n" + - "\amessage\x18\x05 \x01(\tR\amessage\x12\x1d\n" + + "target_url\x18\x03 \x01(\tR\ttargetUrl\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12$\n" + + "\vttl_seconds\x18\x05 \x01(\rH\x00R\n" + + "ttlSeconds\x88\x01\x01\x12(\n" + + "\x10project_path_key\x18\x06 \x01(\tR\x0eprojectPathKeyB\x0e\n" + + "\f_ttl_seconds\"w\n" + + "\x14TunnelMutationResult\x12\x1b\n" + + "\ttunnel_id\x18\x01 \x01(\tR\btunnelId\x12\x1d\n" + "\n" + - "checked_at\x18\x06 \x01(\x03R\tcheckedAt\"8\n" + + "error_code\x18\x02 \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\x03 \x01(\tR\ferrorMessage\"j\n" + + "\x11TunnelProbeResult\x12\x1b\n" + + "\ttunnel_id\x18\x01 \x01(\tR\btunnelId\x128\n" + + "\x05local\x18\x02 \x01(\v2\".liveagent.gateway.v1.TunnelHealthR\x05local\"V\n" + + "\x11TunnelProbeReport\x12A\n" + + "\aresults\x18\x01 \x03(\v2'.liveagent.gateway.v1.TunnelProbeResultR\aresults\"8\n" + "\fTunnelHeader\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + - "\x05value\x18\x02 \x01(\tR\x05value\"\xb3\x03\n" + + "\x05value\x18\x02 \x01(\tR\x05value\"\xf6\x03\n" + "\vTunnelFrame\x12\x1b\n" + - "\tstream_id\x18\x01 \x01(\tR\bstreamId\x12\x1b\n" + - "\ttunnel_id\x18\x02 \x01(\tR\btunnelId\x12\x12\n" + - "\x04slug\x18\x03 \x01(\tR\x04slug\x129\n" + - "\x04kind\x18\x04 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x16\n" + - "\x06method\x18\x05 \x01(\tR\x06method\x12\x12\n" + - "\x04path\x18\x06 \x01(\tR\x04path\x12<\n" + - "\aheaders\x18\a \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x1f\n" + - "\vstatus_code\x18\b \x01(\rR\n" + - "statusCode\x12\x12\n" + - "\x04body\x18\t \x01(\fR\x04body\x12\x1d\n" + + "\tstream_id\x18\x01 \x01(\tR\bstreamId\x129\n" + + "\x04kind\x18\x02 \x01(\x0e2%.liveagent.gateway.v1.TunnelFrameKindR\x04kind\x12\x1d\n" + "\n" + - "end_stream\x18\n" + - " \x01(\bR\tendStream\x12\x14\n" + - "\x05error\x18\v \x01(\tR\x05error\x12&\n" + - "\x0fws_message_type\x18\f \x01(\tR\rwsMessageType\x12\x1f\n" + - "\vws_protocol\x18\r \x01(\tR\n" + - "wsProtocol\"L\n" + + "target_url\x18\x03 \x01(\tR\ttargetUrl\x12\x16\n" + + "\x06method\x18\x04 \x01(\tR\x06method\x12\x12\n" + + "\x04path\x18\x05 \x01(\tR\x04path\x12<\n" + + "\aheaders\x18\x06 \x03(\v2\".liveagent.gateway.v1.TunnelHeaderR\aheaders\x12\x16\n" + + "\x06status\x18\a \x01(\rR\x06status\x12\x12\n" + + "\x04body\x18\b \x01(\fR\x04body\x12\x14\n" + + "\x05error\x18\t \x01(\tR\x05error\x12Q\n" + + "\x0fws_message_type\x18\n" + + " \x01(\x0e2).liveagent.gateway.v1.TunnelWsMessageTypeR\rwsMessageType\x12%\n" + + "\x0ews_subprotocol\x18\v \x01(\tR\rwsSubprotocol\x12\"\n" + + "\rws_close_code\x18\f \x01(\rR\vwsCloseCode\x12&\n" + + "\x0fws_close_reason\x18\r \x01(\tR\rwsCloseReason\"L\n" + "\x13MemoryManageRequest\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x1b\n" + "\targs_json\x18\x02 \x01(\tR\bargsJson\"7\n" + @@ -10774,7 +11092,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\ttimestamp\x18\x01 \x01(\x03R\ttimestamp\"=\n" + "\rErrorResponse\x12\x12\n" + "\x04code\x18\x01 \x01(\x05R\x04code\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage*\x8e\x04\n" + + "\amessage\x18\x02 \x01(\tR\amessage*\xc6\x04\n" + "\x0fTunnelFrameKind\x12!\n" + "\x1dTUNNEL_FRAME_KIND_UNSPECIFIED\x10\x00\x12(\n" + "$TUNNEL_FRAME_KIND_HTTP_REQUEST_START\x10\x01\x12'\n" + @@ -10790,7 +11108,13 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x12\x1e\n" + "\x1aTUNNEL_FRAME_KIND_WS_CLOSE\x10\v\x12\x1b\n" + "\x17TUNNEL_FRAME_KIND_ERROR\x10\f\x12\x1c\n" + - "\x18TUNNEL_FRAME_KIND_CANCEL\x10\r2\xb7\x02\n" + + "\x18TUNNEL_FRAME_KIND_CANCEL\x10\r\x12\x1a\n" + + "\x16TUNNEL_FRAME_KIND_PING\x10\x0e\x12\x1a\n" + + "\x16TUNNEL_FRAME_KIND_PONG\x10\x0f*\x81\x01\n" + + "\x13TunnelWsMessageType\x12&\n" + + "\"TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n" + + "\x1bTUNNEL_WS_MESSAGE_TYPE_TEXT\x10\x01\x12!\n" + + "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x022\xb7\x02\n" + "\fAgentGateway\x12^\n" + "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12p\n" + "\x14AgentTerminalConnect\x12).liveagent.gateway.v1.TerminalStreamFrame\x1a).liveagent.gateway.v1.TerminalStreamFrame(\x010\x01\x12U\n" + @@ -10808,286 +11132,297 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { return file_proto_v1_gateway_proto_rawDescData } -var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 123) +var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 3) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 128) var file_proto_v1_gateway_proto_goTypes = []any{ (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind - (ChatEvent_ChatEventType)(0), // 1: liveagent.gateway.v1.ChatEvent.ChatEventType - (*AuthRequest)(nil), // 2: liveagent.gateway.v1.AuthRequest - (*AuthResponse)(nil), // 3: liveagent.gateway.v1.AuthResponse - (*GatewayEnvelope)(nil), // 4: liveagent.gateway.v1.GatewayEnvelope - (*AgentEnvelope)(nil), // 5: liveagent.gateway.v1.AgentEnvelope - (*ChatSelectedModel)(nil), // 6: liveagent.gateway.v1.ChatSelectedModel - (*ChatRuntimeControls)(nil), // 7: liveagent.gateway.v1.ChatRuntimeControls - (*ChatUploadedFile)(nil), // 8: liveagent.gateway.v1.ChatUploadedFile - (*UploadReadableFile)(nil), // 9: liveagent.gateway.v1.UploadReadableFile - (*UploadReadableFilesRequest)(nil), // 10: liveagent.gateway.v1.UploadReadableFilesRequest - (*UploadReadableFilesResponse)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesResponse - (*UploadedImagePreviewRequest)(nil), // 12: liveagent.gateway.v1.UploadedImagePreviewRequest - (*UploadedImagePreviewResponse)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewResponse - (*TunnelControlRequest)(nil), // 14: liveagent.gateway.v1.TunnelControlRequest - (*TunnelControlResponse)(nil), // 15: liveagent.gateway.v1.TunnelControlResponse - (*TunnelSummary)(nil), // 16: liveagent.gateway.v1.TunnelSummary - (*TunnelDiagnostic)(nil), // 17: liveagent.gateway.v1.TunnelDiagnostic - (*TunnelHeader)(nil), // 18: liveagent.gateway.v1.TunnelHeader - (*TunnelFrame)(nil), // 19: liveagent.gateway.v1.TunnelFrame - (*MemoryManageRequest)(nil), // 20: liveagent.gateway.v1.MemoryManageRequest - (*MemoryManageResponse)(nil), // 21: liveagent.gateway.v1.MemoryManageResponse - (*TerminalRequest)(nil), // 22: liveagent.gateway.v1.TerminalRequest - (*TerminalSession)(nil), // 23: liveagent.gateway.v1.TerminalSession - (*TerminalSshMetadata)(nil), // 24: liveagent.gateway.v1.TerminalSshMetadata - (*SftpRequest)(nil), // 25: liveagent.gateway.v1.SftpRequest - (*SftpEntry)(nil), // 26: liveagent.gateway.v1.SftpEntry - (*SftpTransfer)(nil), // 27: liveagent.gateway.v1.SftpTransfer - (*SftpResponse)(nil), // 28: liveagent.gateway.v1.SftpResponse - (*SftpEvent)(nil), // 29: liveagent.gateway.v1.SftpEvent - (*TerminalSshPrompt)(nil), // 30: liveagent.gateway.v1.TerminalSshPrompt - (*TerminalShellOption)(nil), // 31: liveagent.gateway.v1.TerminalShellOption - (*TerminalSshTab)(nil), // 32: liveagent.gateway.v1.TerminalSshTab - (*TerminalSshTabsSnapshot)(nil), // 33: liveagent.gateway.v1.TerminalSshTabsSnapshot - (*TerminalResponse)(nil), // 34: liveagent.gateway.v1.TerminalResponse - (*TerminalEvent)(nil), // 35: liveagent.gateway.v1.TerminalEvent - (*TerminalStreamFrame)(nil), // 36: liveagent.gateway.v1.TerminalStreamFrame - (*GitRequest)(nil), // 37: liveagent.gateway.v1.GitRequest - (*GitResponse)(nil), // 38: liveagent.gateway.v1.GitResponse - (*ChatRequest)(nil), // 39: liveagent.gateway.v1.ChatRequest - (*ChatMessageRef)(nil), // 40: liveagent.gateway.v1.ChatMessageRef - (*CancelChatRequest)(nil), // 41: liveagent.gateway.v1.CancelChatRequest - (*ChatCommandRequest)(nil), // 42: liveagent.gateway.v1.ChatCommandRequest - (*ChatQueueRequest)(nil), // 43: liveagent.gateway.v1.ChatQueueRequest - (*ChatQueueResponse)(nil), // 44: liveagent.gateway.v1.ChatQueueResponse - (*ChatQueueEvent)(nil), // 45: liveagent.gateway.v1.ChatQueueEvent - (*ChatEvent)(nil), // 46: liveagent.gateway.v1.ChatEvent - (*ChatControlEvent)(nil), // 47: liveagent.gateway.v1.ChatControlEvent - (*ChatRuntimeSnapshot)(nil), // 48: liveagent.gateway.v1.ChatRuntimeSnapshot - (*RuntimeStatusEvent)(nil), // 49: liveagent.gateway.v1.RuntimeStatusEvent - (*ChatEventReplayRequest)(nil), // 50: liveagent.gateway.v1.ChatEventReplayRequest - (*ChatEventReplayResponse)(nil), // 51: liveagent.gateway.v1.ChatEventReplayResponse - (*ChatReplayEvent)(nil), // 52: liveagent.gateway.v1.ChatReplayEvent - (*CronManageRequest)(nil), // 53: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 54: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 55: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 56: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 57: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 58: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 59: liveagent.gateway.v1.HistoryGetResponse - (*HistoryPrefixRequest)(nil), // 60: liveagent.gateway.v1.HistoryPrefixRequest - (*HistoryPrefixResponse)(nil), // 61: liveagent.gateway.v1.HistoryPrefixResponse - (*HistoryRenameRequest)(nil), // 62: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 63: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 64: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 65: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 66: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 67: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 68: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 69: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 70: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 71: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 72: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 73: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 74: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 75: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 76: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 77: liveagent.gateway.v1.HistoryDeleteResponse - (*HistorySyncEvent)(nil), // 78: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 79: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 80: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 81: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 82: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 83: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 84: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsResetSshKnownHostRequest)(nil), // 85: liveagent.gateway.v1.SettingsResetSshKnownHostRequest - (*SettingsResetSshKnownHostResponse)(nil), // 86: liveagent.gateway.v1.SettingsResetSshKnownHostResponse - (*SettingsSyncEvent)(nil), // 87: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 88: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 89: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 90: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 91: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 92: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 93: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 94: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 95: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 96: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 97: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 98: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 99: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 100: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 101: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 102: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 103: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 104: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 105: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 106: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 107: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 108: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 109: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 110: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 111: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 112: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 113: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 114: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 115: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 116: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 117: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 118: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 119: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 120: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 121: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 122: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 123: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 124: liveagent.gateway.v1.ErrorResponse + (TunnelWsMessageType)(0), // 1: liveagent.gateway.v1.TunnelWsMessageType + (ChatEvent_ChatEventType)(0), // 2: liveagent.gateway.v1.ChatEvent.ChatEventType + (*AuthRequest)(nil), // 3: liveagent.gateway.v1.AuthRequest + (*AuthResponse)(nil), // 4: liveagent.gateway.v1.AuthResponse + (*GatewayEnvelope)(nil), // 5: liveagent.gateway.v1.GatewayEnvelope + (*AgentEnvelope)(nil), // 6: liveagent.gateway.v1.AgentEnvelope + (*ChatSelectedModel)(nil), // 7: liveagent.gateway.v1.ChatSelectedModel + (*ChatRuntimeControls)(nil), // 8: liveagent.gateway.v1.ChatRuntimeControls + (*ChatUploadedFile)(nil), // 9: liveagent.gateway.v1.ChatUploadedFile + (*UploadReadableFile)(nil), // 10: liveagent.gateway.v1.UploadReadableFile + (*UploadReadableFilesRequest)(nil), // 11: liveagent.gateway.v1.UploadReadableFilesRequest + (*UploadReadableFilesResponse)(nil), // 12: liveagent.gateway.v1.UploadReadableFilesResponse + (*UploadedImagePreviewRequest)(nil), // 13: liveagent.gateway.v1.UploadedImagePreviewRequest + (*UploadedImagePreviewResponse)(nil), // 14: liveagent.gateway.v1.UploadedImagePreviewResponse + (*TunnelSpec)(nil), // 15: liveagent.gateway.v1.TunnelSpec + (*TunnelDesiredState)(nil), // 16: liveagent.gateway.v1.TunnelDesiredState + (*TunnelHealth)(nil), // 17: liveagent.gateway.v1.TunnelHealth + (*TunnelStatus)(nil), // 18: liveagent.gateway.v1.TunnelStatus + (*TunnelStateSnapshot)(nil), // 19: liveagent.gateway.v1.TunnelStateSnapshot + (*TunnelMutation)(nil), // 20: liveagent.gateway.v1.TunnelMutation + (*TunnelMutationResult)(nil), // 21: liveagent.gateway.v1.TunnelMutationResult + (*TunnelProbeResult)(nil), // 22: liveagent.gateway.v1.TunnelProbeResult + (*TunnelProbeReport)(nil), // 23: liveagent.gateway.v1.TunnelProbeReport + (*TunnelHeader)(nil), // 24: liveagent.gateway.v1.TunnelHeader + (*TunnelFrame)(nil), // 25: liveagent.gateway.v1.TunnelFrame + (*MemoryManageRequest)(nil), // 26: liveagent.gateway.v1.MemoryManageRequest + (*MemoryManageResponse)(nil), // 27: liveagent.gateway.v1.MemoryManageResponse + (*TerminalRequest)(nil), // 28: liveagent.gateway.v1.TerminalRequest + (*TerminalSession)(nil), // 29: liveagent.gateway.v1.TerminalSession + (*TerminalSshMetadata)(nil), // 30: liveagent.gateway.v1.TerminalSshMetadata + (*SftpRequest)(nil), // 31: liveagent.gateway.v1.SftpRequest + (*SftpEntry)(nil), // 32: liveagent.gateway.v1.SftpEntry + (*SftpTransfer)(nil), // 33: liveagent.gateway.v1.SftpTransfer + (*SftpResponse)(nil), // 34: liveagent.gateway.v1.SftpResponse + (*SftpEvent)(nil), // 35: liveagent.gateway.v1.SftpEvent + (*TerminalSshPrompt)(nil), // 36: liveagent.gateway.v1.TerminalSshPrompt + (*TerminalShellOption)(nil), // 37: liveagent.gateway.v1.TerminalShellOption + (*TerminalSshTab)(nil), // 38: liveagent.gateway.v1.TerminalSshTab + (*TerminalSshTabsSnapshot)(nil), // 39: liveagent.gateway.v1.TerminalSshTabsSnapshot + (*TerminalResponse)(nil), // 40: liveagent.gateway.v1.TerminalResponse + (*TerminalEvent)(nil), // 41: liveagent.gateway.v1.TerminalEvent + (*TerminalStreamFrame)(nil), // 42: liveagent.gateway.v1.TerminalStreamFrame + (*GitRequest)(nil), // 43: liveagent.gateway.v1.GitRequest + (*GitResponse)(nil), // 44: liveagent.gateway.v1.GitResponse + (*ChatRequest)(nil), // 45: liveagent.gateway.v1.ChatRequest + (*ChatMessageRef)(nil), // 46: liveagent.gateway.v1.ChatMessageRef + (*CancelChatRequest)(nil), // 47: liveagent.gateway.v1.CancelChatRequest + (*ChatCommandRequest)(nil), // 48: liveagent.gateway.v1.ChatCommandRequest + (*ChatQueueRequest)(nil), // 49: liveagent.gateway.v1.ChatQueueRequest + (*ChatQueueResponse)(nil), // 50: liveagent.gateway.v1.ChatQueueResponse + (*ChatQueueEvent)(nil), // 51: liveagent.gateway.v1.ChatQueueEvent + (*ChatEvent)(nil), // 52: liveagent.gateway.v1.ChatEvent + (*ChatControlEvent)(nil), // 53: liveagent.gateway.v1.ChatControlEvent + (*ChatRuntimeSnapshot)(nil), // 54: liveagent.gateway.v1.ChatRuntimeSnapshot + (*RuntimeStatusEvent)(nil), // 55: liveagent.gateway.v1.RuntimeStatusEvent + (*ChatEventReplayRequest)(nil), // 56: liveagent.gateway.v1.ChatEventReplayRequest + (*ChatEventReplayResponse)(nil), // 57: liveagent.gateway.v1.ChatEventReplayResponse + (*ChatReplayEvent)(nil), // 58: liveagent.gateway.v1.ChatReplayEvent + (*CronManageRequest)(nil), // 59: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 60: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 61: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 62: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 63: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 64: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 65: liveagent.gateway.v1.HistoryGetResponse + (*HistoryPrefixRequest)(nil), // 66: liveagent.gateway.v1.HistoryPrefixRequest + (*HistoryPrefixResponse)(nil), // 67: liveagent.gateway.v1.HistoryPrefixResponse + (*HistoryRenameRequest)(nil), // 68: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 69: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 70: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 71: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 72: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 73: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 74: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 75: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 76: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 77: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 78: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 79: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 80: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 81: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 82: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 83: liveagent.gateway.v1.HistoryDeleteResponse + (*HistorySyncEvent)(nil), // 84: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 85: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 86: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 87: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 88: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 89: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 90: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsResetSshKnownHostRequest)(nil), // 91: liveagent.gateway.v1.SettingsResetSshKnownHostRequest + (*SettingsResetSshKnownHostResponse)(nil), // 92: liveagent.gateway.v1.SettingsResetSshKnownHostResponse + (*SettingsSyncEvent)(nil), // 93: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 94: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 95: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 96: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 97: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 98: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 99: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 100: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 101: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 102: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 103: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 104: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 105: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 106: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 107: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 108: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 109: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 110: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 111: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 112: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 113: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 114: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 115: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 116: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 117: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 118: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 119: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 120: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 121: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 122: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 123: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 124: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 125: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 126: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 127: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 128: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 129: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 130: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ - 42, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest - 53, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 55, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 58, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 62, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 76, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 60, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest - 64, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 67, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 69, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 71, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 73, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 79, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 81, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 83, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 88, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 90, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 92, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 96, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest - 10, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 100, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 102, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 122, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest - 12, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest - 20, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 94, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 105, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest - 22, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 107, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 114, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 116, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 118, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 120, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest - 37, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 110, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 112, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest - 25, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest - 14, // 37: liveagent.gateway.v1.GatewayEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest - 15, // 38: liveagent.gateway.v1.GatewayEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse - 19, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 85, // 40: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest - 43, // 41: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest - 50, // 42: liveagent.gateway.v1.GatewayEnvelope.chat_event_replay:type_name -> liveagent.gateway.v1.ChatEventReplayRequest - 46, // 43: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 54, // 44: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 56, // 45: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 59, // 46: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 63, // 47: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 77, // 48: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 78, // 49: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 61, // 50: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse - 65, // 51: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 68, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 70, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 72, // 54: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 75, // 55: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 80, // 56: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 82, // 57: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 84, // 58: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 87, // 59: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 89, // 60: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 91, // 61: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 93, // 62: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 98, // 63: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 11, // 64: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 101, // 65: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 123, // 66: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 104, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 13, // 68: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 21, // 69: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 95, // 70: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 106, // 71: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 34, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 35, // 73: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 109, // 74: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 115, // 75: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 117, // 76: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 119, // 77: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 121, // 78: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 38, // 79: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 111, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 113, // 81: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 28, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse - 29, // 83: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent - 44, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse - 45, // 85: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent - 14, // 86: liveagent.gateway.v1.AgentEnvelope.tunnel_control:type_name -> liveagent.gateway.v1.TunnelControlRequest - 15, // 87: liveagent.gateway.v1.AgentEnvelope.tunnel_control_resp:type_name -> liveagent.gateway.v1.TunnelControlResponse - 19, // 88: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 47, // 89: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent - 49, // 90: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent - 86, // 91: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse - 48, // 92: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot - 51, // 93: liveagent.gateway.v1.AgentEnvelope.chat_event_replay_resp:type_name -> liveagent.gateway.v1.ChatEventReplayResponse - 124, // 94: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 9, // 95: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 8, // 96: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 16, // 97: liveagent.gateway.v1.TunnelControlResponse.tunnels:type_name -> liveagent.gateway.v1.TunnelSummary - 16, // 98: liveagent.gateway.v1.TunnelControlResponse.tunnel:type_name -> liveagent.gateway.v1.TunnelSummary - 17, // 99: liveagent.gateway.v1.TunnelSummary.diagnostics:type_name -> liveagent.gateway.v1.TunnelDiagnostic - 0, // 100: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind - 18, // 101: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader - 24, // 102: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata - 26, // 103: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry - 26, // 104: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry - 27, // 105: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 27, // 106: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 32, // 107: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab - 23, // 108: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 23, // 109: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 31, // 110: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 30, // 111: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt - 33, // 112: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 23, // 113: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 33, // 114: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 23, // 115: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession - 6, // 116: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 8, // 117: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 7, // 118: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 39, // 119: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest - 40, // 120: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 41, // 121: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest - 1, // 122: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 52, // 123: liveagent.gateway.v1.ChatEventReplayResponse.events:type_name -> liveagent.gateway.v1.ChatReplayEvent - 57, // 124: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 57, // 125: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 40, // 126: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 57, // 127: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 57, // 128: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 57, // 129: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 66, // 130: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 66, // 131: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 57, // 132: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 74, // 133: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 57, // 134: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 97, // 135: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 99, // 136: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 103, // 137: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 108, // 138: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 5, // 139: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 36, // 140: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame - 2, // 141: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 4, // 142: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 36, // 143: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame - 3, // 144: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 142, // [142:145] is the sub-list for method output_type - 139, // [139:142] is the sub-list for method input_type - 139, // [139:139] is the sub-list for extension type_name - 139, // [139:139] is the sub-list for extension extendee - 0, // [0:139] is the sub-list for field type_name + 48, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest + 59, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 61, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 64, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 68, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 82, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 66, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest + 70, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 73, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 75, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 77, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 79, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 85, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 87, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 89, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 94, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 96, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 98, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 102, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 11, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest + 106, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 108, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 128, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 13, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest + 26, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest + 100, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 111, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 28, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest + 113, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 120, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 122, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 124, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 126, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 43, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest + 116, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 118, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 31, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest + 91, // 37: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest + 49, // 38: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest + 56, // 39: liveagent.gateway.v1.GatewayEnvelope.chat_event_replay:type_name -> liveagent.gateway.v1.ChatEventReplayRequest + 19, // 40: liveagent.gateway.v1.GatewayEnvelope.tunnel_state:type_name -> liveagent.gateway.v1.TunnelStateSnapshot + 20, // 41: liveagent.gateway.v1.GatewayEnvelope.tunnel_mutation:type_name -> liveagent.gateway.v1.TunnelMutation + 25, // 42: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 52, // 43: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 60, // 44: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 62, // 45: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 65, // 46: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 69, // 47: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 83, // 48: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 84, // 49: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 67, // 50: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse + 71, // 51: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 74, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 76, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 78, // 54: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 81, // 55: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 86, // 56: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 88, // 57: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 90, // 58: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 93, // 59: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 95, // 60: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 97, // 61: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 99, // 62: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 104, // 63: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 12, // 64: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 107, // 65: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 129, // 66: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 110, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 14, // 68: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 27, // 69: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 101, // 70: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 112, // 71: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 40, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 41, // 73: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 115, // 74: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 121, // 75: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 123, // 76: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 125, // 77: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 127, // 78: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 44, // 79: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 117, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 119, // 81: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 34, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse + 35, // 83: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent + 50, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse + 51, // 85: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent + 53, // 86: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent + 55, // 87: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent + 92, // 88: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse + 54, // 89: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot + 57, // 90: liveagent.gateway.v1.AgentEnvelope.chat_event_replay_resp:type_name -> liveagent.gateway.v1.ChatEventReplayResponse + 16, // 91: liveagent.gateway.v1.AgentEnvelope.tunnel_desired:type_name -> liveagent.gateway.v1.TunnelDesiredState + 21, // 92: liveagent.gateway.v1.AgentEnvelope.tunnel_mutation_result:type_name -> liveagent.gateway.v1.TunnelMutationResult + 25, // 93: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 23, // 94: liveagent.gateway.v1.AgentEnvelope.tunnel_probe_report:type_name -> liveagent.gateway.v1.TunnelProbeReport + 130, // 95: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 10, // 96: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 9, // 97: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 15, // 98: liveagent.gateway.v1.TunnelDesiredState.tunnels:type_name -> liveagent.gateway.v1.TunnelSpec + 17, // 99: liveagent.gateway.v1.TunnelStatus.local:type_name -> liveagent.gateway.v1.TunnelHealth + 18, // 100: liveagent.gateway.v1.TunnelStateSnapshot.tunnels:type_name -> liveagent.gateway.v1.TunnelStatus + 17, // 101: liveagent.gateway.v1.TunnelStateSnapshot.relay:type_name -> liveagent.gateway.v1.TunnelHealth + 17, // 102: liveagent.gateway.v1.TunnelProbeResult.local:type_name -> liveagent.gateway.v1.TunnelHealth + 22, // 103: liveagent.gateway.v1.TunnelProbeReport.results:type_name -> liveagent.gateway.v1.TunnelProbeResult + 0, // 104: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 24, // 105: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 1, // 106: liveagent.gateway.v1.TunnelFrame.ws_message_type:type_name -> liveagent.gateway.v1.TunnelWsMessageType + 30, // 107: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata + 32, // 108: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry + 32, // 109: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry + 33, // 110: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 33, // 111: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 38, // 112: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab + 29, // 113: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 29, // 114: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 37, // 115: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 36, // 116: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt + 39, // 117: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 29, // 118: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 39, // 119: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 29, // 120: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession + 7, // 121: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 9, // 122: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 8, // 123: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 45, // 124: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest + 46, // 125: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 47, // 126: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest + 2, // 127: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 58, // 128: liveagent.gateway.v1.ChatEventReplayResponse.events:type_name -> liveagent.gateway.v1.ChatReplayEvent + 63, // 129: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 130: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 46, // 131: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 63, // 132: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 133: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 63, // 134: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 72, // 135: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 72, // 136: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 63, // 137: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 80, // 138: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 63, // 139: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 103, // 140: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 105, // 141: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 109, // 142: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 114, // 143: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 6, // 144: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 42, // 145: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame + 3, // 146: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 5, // 147: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 42, // 148: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame + 4, // 149: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 147, // [147:150] is the sub-list for method output_type + 144, // [144:147] is the sub-list for method input_type + 144, // [144:144] is the sub-list for extension type_name + 144, // [144:144] is the sub-list for extension extendee + 0, // [0:144] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -11133,12 +11468,12 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_FsReadEditableText)(nil), (*GatewayEnvelope_FsReadWorkspaceImage)(nil), (*GatewayEnvelope_SftpRequest)(nil), - (*GatewayEnvelope_TunnelControl)(nil), - (*GatewayEnvelope_TunnelControlResp)(nil), - (*GatewayEnvelope_TunnelFrame)(nil), (*GatewayEnvelope_SettingsResetSshKnownHost)(nil), (*GatewayEnvelope_ChatQueue)(nil), (*GatewayEnvelope_ChatEventReplay)(nil), + (*GatewayEnvelope_TunnelState)(nil), + (*GatewayEnvelope_TunnelMutation)(nil), + (*GatewayEnvelope_TunnelFrame)(nil), } file_proto_v1_gateway_proto_msgTypes[3].OneofWrappers = []any{ (*AgentEnvelope_ChatEvent)(nil), @@ -11184,24 +11519,26 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_SftpEvent)(nil), (*AgentEnvelope_ChatQueueResp)(nil), (*AgentEnvelope_ChatQueueEvent)(nil), - (*AgentEnvelope_TunnelControl)(nil), - (*AgentEnvelope_TunnelControlResp)(nil), - (*AgentEnvelope_TunnelFrame)(nil), (*AgentEnvelope_ChatControl)(nil), (*AgentEnvelope_RuntimeStatus)(nil), (*AgentEnvelope_SettingsResetSshKnownHostResp)(nil), (*AgentEnvelope_ChatRuntimeSnapshot)(nil), (*AgentEnvelope_ChatEventReplayResp)(nil), + (*AgentEnvelope_TunnelDesired)(nil), + (*AgentEnvelope_TunnelMutationResult)(nil), + (*AgentEnvelope_TunnelFrame)(nil), + (*AgentEnvelope_TunnelProbeReport)(nil), (*AgentEnvelope_Error)(nil), } - file_proto_v1_gateway_proto_msgTypes[67].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[17].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[72].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), - NumEnums: 2, - NumMessages: 123, + NumEnums: 3, + NumMessages: 128, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/tunnel.go b/crates/agent-gateway/internal/server/tunnel_proxy.go similarity index 59% rename from crates/agent-gateway/internal/server/tunnel.go rename to crates/agent-gateway/internal/server/tunnel_proxy.go index dd85b155..380ae69c 100644 --- a/crates/agent-gateway/internal/server/tunnel.go +++ b/crates/agent-gateway/internal/server/tunnel_proxy.go @@ -16,7 +16,14 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -const tunnelBodyChunkSize = 64 * 1024 +const ( + tunnelBodyChunkSize = 64 * 1024 + tunnelRequestBodyMaxBytes = 32 * 1024 * 1024 + tunnelWebSocketDialTimeout = 30 * time.Second + tunnelDataPlaneWSReadLimit = 16 * 1024 * 1024 +) + +var errTunnelRequestBodyTooLarge = errors.New("tunnel request body too large") func publicTunnelProxy(sm *session.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -53,12 +60,13 @@ func serveTunnelHTTP( slug string, restPath string, ) { - streamID := "http-" + uuid.NewString() + streamID := "h-" + uuid.NewString() lease, err := sm.AcquireTunnel(slug, streamID) if err != nil { writeTunnelAcquireError(w, err) return } + rewrite := tunnelRewrite{slug: lease.Slug(), targetURL: lease.TargetURL()} ctx, cancel := context.WithCancel(r.Context()) completed := false @@ -67,8 +75,6 @@ func serveTunnelHTTP( if !completed { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, }) } @@ -76,28 +82,28 @@ func serveTunnelHTTP( }() start := &gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, - Method: r.Method, - Path: restPath, - Headers: tunnelRequestHeaders(r, lease.Tunnel()), + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START, + TargetUrl: lease.TargetURL(), + Method: r.Method, + Path: restPath, + Headers: tunnelRequestHeaders(r, lease.Slug()), } if err := sm.SendTunnelFrameToAgent(start); err != nil { writeTunnelAcquireError(w, err) return } - bodyDone := make(chan struct{}) - go streamTunnelHTTPRequestBody(ctx, sm, lease.TunnelID(), slug, streamID, r.Body, bodyDone) + bodyResult := make(chan error, 1) + go streamTunnelHTTPRequestBody(ctx, sm, streamID, r.Body, bodyResult) responseStarted := false responseHeadersWritten := false responseStatus := http.StatusOK responseHeaders := http.Header{} - responseRewriteKind := tunnelResponseRewriteNone - var responseBody []byte + rewriteKind := tunnelResponseRewriteNone + var rewriteBuffer []byte + writeResponseHeaders := func() { if responseHeadersWritten { return @@ -106,17 +112,23 @@ func serveTunnelHTTP( w.WriteHeader(responseStatus) responseHeadersWritten = true } - writeBufferedResponse := func() { - if responseRewriteKind == tunnelResponseRewriteNone { - return - } + // flushRewriteBuffer abandons rewriting and streams what was buffered. + // Content-Length/ETag were already dropped at RESPONSE_START, so a + // mid-stream abort terminates the chunked stream instead of lying about + // the body length. + flushRewriteBuffer := func() bool { + rewriteKind = tunnelResponseRewriteNone writeResponseHeaders() - if len(responseBody) > 0 { - _, _ = w.Write(responseBody) - responseBody = nil + if len(rewriteBuffer) > 0 { + if _, err := w.Write(rewriteBuffer); err != nil { + return false + } + rewriteBuffer = nil } flushTunnelResponse(w) + return true } + for { select { case <-r.Context().Done(): @@ -124,12 +136,16 @@ func serveTunnelHTTP( case <-lease.Done(): if !responseStarted { writeTunnelError(w, http.StatusBadGateway, "tunnel stream closed") - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return - case <-bodyDone: - bodyDone = nil + case err := <-bodyResult: + bodyResult = nil + if errors.Is(err, errTunnelRequestBodyTooLarge) && !responseStarted { + writeTunnelError(w, http.StatusRequestEntityTooLarge, "request body too large") + return + } case frame := <-lease.Frames(): if frame == nil { continue @@ -140,54 +156,50 @@ func serveTunnelHTTP( continue } responseStarted = true - status := int(frame.GetStatusCode()) - if status <= 0 { - status = http.StatusOK + if status := int(frame.GetStatus()); status > 0 { + responseStatus = status } - responseStatus = status - responseHeaders = tunnelResponseHeaders(frame, lease.Tunnel()) - responseRewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) - if responseRewriteKind == tunnelResponseRewriteNone { + responseHeaders = tunnelResponseHeaders(frame, rewrite) + rewriteKind = tunnelResponseRewriteKindFor(r.Method, responseStatus, responseHeaders) + if rewriteKind != tunnelResponseRewriteNone { + responseHeaders.Del("Content-Length") + responseHeaders.Del("Etag") + } else { writeResponseHeaders() flushTunnelResponse(w) } case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY: if !responseStarted { responseStarted = true - responseStatus = http.StatusOK responseHeaders = http.Header{} - responseRewriteKind = tunnelResponseRewriteNone writeResponseHeaders() } - if body := frame.GetBody(); len(body) > 0 { - if responseRewriteKind != tunnelResponseRewriteNone { - if len(responseBody)+len(body) <= tunnelRewriteBodyMaxBytes { - responseBody = append(responseBody, body...) - continue - } - responseRewriteKind = tunnelResponseRewriteNone - writeResponseHeaders() - if len(responseBody) > 0 { - if _, err := w.Write(responseBody); err != nil { - return - } - responseBody = nil - } + body := frame.GetBody() + if len(body) == 0 { + continue + } + if rewriteKind != tunnelResponseRewriteNone { + if len(rewriteBuffer)+len(body) <= tunnelRewriteBodyMaxBytes { + rewriteBuffer = append(rewriteBuffer, body...) + continue } - writeResponseHeaders() - if _, err := w.Write(body); err != nil { + if !flushRewriteBuffer() { return } - flushTunnelResponse(w) } + writeResponseHeaders() + if _, err := w.Write(body); err != nil { + return + } + flushTunnelResponse(w) case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END: - if responseRewriteKind != tunnelResponseRewriteNone { - body := responseBody - if rewritten, changed := rewriteTunnelResponseBody(body, lease.Tunnel(), responseRewriteKind); changed { + if rewriteKind != tunnelResponseRewriteNone { + body := rewriteBuffer + if rewritten, changed := rewriteTunnelResponseBody(body, rewrite, rewriteKind); changed { body = rewritten - responseHeaders.Del("Content-Length") - responseHeaders.Del("Etag") - responseHeaders.Del("ETag") + if rewriteKind == tunnelResponseRewriteHTML { + amendTunnelCSP(responseHeaders, tunnelShimScriptBody(rewrite)) + } } writeResponseHeaders() if len(body) > 0 { @@ -195,7 +207,7 @@ func serveTunnelHTTP( return } } - responseBody = nil + rewriteBuffer = nil } else if responseStarted && !responseHeadersWritten { writeResponseHeaders() } @@ -205,15 +217,15 @@ func serveTunnelHTTP( case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: if !responseStarted { writeTunnelError(w, http.StatusBadGateway, frame.GetError()) - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL: if !responseStarted { writeTunnelError(w, http.StatusServiceUnavailable, "tunnel canceled") - } else if !responseHeadersWritten { - writeBufferedResponse() + } else if rewriteKind != tunnelResponseRewriteNone { + flushRewriteBuffer() } return } @@ -228,7 +240,7 @@ func serveTunnelWebSocket( slug string, restPath string, ) { - streamID := "ws-" + uuid.NewString() + streamID := "w-" + uuid.NewString() lease, err := sm.AcquireTunnel(slug, streamID) if err != nil { writeTunnelAcquireError(w, err) @@ -236,25 +248,22 @@ func serveTunnelWebSocket( } if err := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL, - Method: r.Method, - Path: restPath, - Headers: tunnelWebSocketRequestHeaders(r, lease.Tunnel()), + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL, + TargetUrl: lease.TargetURL(), + Method: r.Method, + Path: restPath, + Headers: tunnelWebSocketRequestHeaders(r, lease.Slug()), }); err != nil { lease.Release() writeTunnelAcquireError(w, err) return } - wsProtocol, dialErr := awaitTunnelWebSocketDial(lease) + wsSubprotocol, dialErr := awaitTunnelWebSocketDial(lease) if dialErr != nil { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, Error: dialErr.Error(), }) @@ -268,68 +277,75 @@ func serveTunnelWebSocket( return true }, } - if wsProtocol != "" { - upgrader.Subprotocols = []string{wsProtocol} + if wsSubprotocol != "" { + upgrader.Subprotocols = []string{wsSubprotocol} } ws, err := upgrader.Upgrade(w, r, nil) if err != nil { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, }) lease.Release() return } - ws.SetReadLimit(16 * 1024 * 1024) + ws.SetReadLimit(tunnelDataPlaneWSReadLimit) defer lease.Release() - handleTunnelWebSocket(ws, sm, lease, slug, streamID) + pumpTunnelWebSocket(ws, sm, lease, streamID) } -func handleTunnelWebSocket( +func pumpTunnelWebSocket( ws *websocket.Conn, sm *session.Manager, lease *session.TunnelStreamLease, - slug string, streamID string, ) { - closed := false + closeSent := false defer func() { - if !closed { + if !closeSent { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, }) } _ = ws.Close() }() - browserFrames := make(chan *gatewayv1.TunnelFrame, 64) + visitorFrames := make(chan *gatewayv1.TunnelFrame, 64) + visitorClose := make(chan *gatewayv1.TunnelFrame, 1) readerDone := make(chan struct{}) go func() { defer close(readerDone) for { messageType, body, err := ws.ReadMessage() if err != nil { + closeFrame := &gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, + } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + closeFrame.WsCloseCode = uint32(closeErr.Code) + closeFrame.WsCloseReason = closeErr.Text + } + visitorClose <- closeFrame return } - wireMessageType := "binary" + wireType := gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_BINARY if messageType == websocket.TextMessage { - wireMessageType = "text" + wireType = gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT } frame := &gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, Body: body, - WsMessageType: wireMessageType, + WsMessageType: wireType, } select { - case browserFrames <- frame: + case visitorFrames <- frame: case <-lease.Done(): return } @@ -339,18 +355,21 @@ func handleTunnelWebSocket( for { select { case <-lease.Done(): - closed = true + closeSent = true + return + case closeFrame := <-visitorClose: + closeSent = true + _ = sm.SendTunnelFrameToAgent(closeFrame) return case <-readerDone: - closed = true + closeSent = true _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ - StreamId: streamID, - TunnelId: lease.TunnelID(), - Slug: slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: websocket.CloseGoingAway, }) return - case frame := <-browserFrames: + case frame := <-visitorFrames: if frame != nil { _ = sm.SendTunnelFrameToAgent(frame) } @@ -360,19 +379,35 @@ func handleTunnelWebSocket( } switch frame.GetKind() { case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: - if strings.EqualFold(frame.GetWsMessageType(), "text") { - if err := ws.WriteMessage(websocket.TextMessage, frame.GetBody()); err != nil { - return - } - } else { - if err := ws.WriteMessage(websocket.BinaryMessage, frame.GetBody()); err != nil { - return - } + messageType := websocket.BinaryMessage + if frame.GetWsMessageType() == gatewayv1.TunnelWsMessageType_TUNNEL_WS_MESSAGE_TYPE_TEXT { + messageType = websocket.TextMessage } - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, - gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + if err := ws.WriteMessage(messageType, frame.GetBody()); err != nil { + return + } + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: + closeSent = true + code := int(frame.GetWsCloseCode()) + if code == 0 { + code = websocket.CloseNormalClosure + } + deadline := time.Now().Add(time.Second) + _ = ws.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(code, frame.GetWsCloseReason()), + deadline, + ) + return + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR: - closed = true + closeSent = true + deadline := time.Now().Add(time.Second) + _ = ws.WriteControl( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseInternalServerErr, ""), + deadline, + ) return } } @@ -380,7 +415,7 @@ func handleTunnelWebSocket( } func awaitTunnelWebSocketDial(lease *session.TunnelStreamLease) (string, error) { - timer := time.NewTimer(30 * time.Second) + timer := time.NewTimer(tunnelWebSocketDialTimeout) defer timer.Stop() for { select { @@ -394,14 +429,9 @@ func awaitTunnelWebSocketDial(lease *session.TunnelStreamLease) (string, error) } switch frame.GetKind() { case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_OK: - return strings.TrimSpace(frame.GetWsProtocol()), nil - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_ERROR: - message := strings.TrimSpace(frame.GetError()) - if message == "" { - message = "local tunnel websocket dial failed" - } - return "", errors.New(message) - case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, + return strings.TrimSpace(frame.GetWsSubprotocol()), nil + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_ERROR, + gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_ERROR, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE: message := strings.TrimSpace(frame.GetError()) @@ -417,45 +447,53 @@ func awaitTunnelWebSocketDial(lease *session.TunnelStreamLease) (string, error) func streamTunnelHTTPRequestBody( ctx context.Context, sm *session.Manager, - tunnelID string, - slug string, streamID string, body io.ReadCloser, - done chan<- struct{}, + result chan<- error, ) { - defer close(done) + var resultErr error + defer func() { + result <- resultErr + }() defer body.Close() buffer := make([]byte, tunnelBodyChunkSize) + sent := 0 for { n, err := body.Read(buffer) if n > 0 { + sent += n + if sent > tunnelRequestBodyMaxBytes { + resultErr = errTunnelRequestBodyTooLarge + _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: errTunnelRequestBodyTooLarge.Error(), + }) + return + } chunk := make([]byte, n) copy(chunk, buffer[:n]) if sendErr := sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_BODY, Body: chunk, }); sendErr != nil { + resultErr = sendErr return } } if errors.Is(err, io.EOF) { _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_END, }) return } if err != nil { + resultErr = err _ = sm.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ StreamId: streamID, - TunnelId: tunnelID, - Slug: slug, Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, Error: err.Error(), }) @@ -463,6 +501,7 @@ func streamTunnelHTTPRequestBody( } select { case <-ctx.Done(): + resultErr = ctx.Err() return default: } @@ -524,45 +563,21 @@ func isWebSocketUpgrade(r *http.Request) bool { strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") } -func tunnelRequestHeaders(r *http.Request, tunnel *gatewayv1.TunnelSummary) []*gatewayv1.TunnelHeader { - headers := filteredTunnelHeaders(r.Header, true) - return appendTunnelForwardedHeaders(headers, r, tunnel) -} - -func tunnelWebSocketRequestHeaders(r *http.Request, tunnel *gatewayv1.TunnelSummary) []*gatewayv1.TunnelHeader { - headers := make([]*gatewayv1.TunnelHeader, 0, len(r.Header)) - for name, values := range r.Header { - canonical := http.CanonicalHeaderKey(strings.TrimSpace(name)) - if canonical == "" || shouldDropTunnelWebSocketRequestHeader(canonical) { - continue - } - for _, value := range values { - headers = append(headers, &gatewayv1.TunnelHeader{ - Name: canonical, - Value: value, - }) - } - } - return appendTunnelForwardedHeaders(headers, r, tunnel) +func tunnelRequestHeaders(r *http.Request, slug string) []*gatewayv1.TunnelHeader { + headers := filteredTunnelRequestHeaders(r.Header, false) + return appendTunnelForwardedHeaders(headers, r, slug) } -func filteredTunnelResponseHeaders(headers []*gatewayv1.TunnelHeader) http.Header { - out := http.Header{} - for _, header := range headers { - name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) - if name == "" || shouldDropTunnelHeader(name, false) { - continue - } - out.Add(name, header.GetValue()) - } - return out +func tunnelWebSocketRequestHeaders(r *http.Request, slug string) []*gatewayv1.TunnelHeader { + headers := filteredTunnelRequestHeaders(r.Header, true) + return appendTunnelForwardedHeaders(headers, r, slug) } -func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.TunnelHeader { +func filteredTunnelRequestHeaders(headers http.Header, websocketUpgrade bool) []*gatewayv1.TunnelHeader { out := make([]*gatewayv1.TunnelHeader, 0, len(headers)) for name, values := range headers { canonical := http.CanonicalHeaderKey(strings.TrimSpace(name)) - if canonical == "" || shouldDropTunnelHeader(canonical, request) { + if canonical == "" || shouldDropTunnelRequestHeader(canonical, websocketUpgrade) { continue } for _, value := range values { @@ -575,8 +590,14 @@ func filteredTunnelHeaders(headers http.Header, request bool) []*gatewayv1.Tunne return out } -func shouldDropTunnelHeader(name string, request bool) bool { - switch strings.ToLower(name) { +func shouldDropTunnelRequestHeader(name string, websocketUpgrade bool) bool { + lower := strings.ToLower(name) + // Visitor-supplied forwarding headers are stripped so the local service + // only ever sees the gateway's own X-Forwarded-* values. + if strings.HasPrefix(lower, "x-forwarded-") || lower == "forwarded" { + return true + } + switch lower { case "connection", "keep-alive", "proxy-authenticate", @@ -585,21 +606,30 @@ func shouldDropTunnelHeader(name string, request bool) bool { "te", "trailer", "transfer-encoding", - "upgrade": + "upgrade", + "host": return true - case "host": - return request - default: - return false } + if websocketUpgrade { + switch lower { + case "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-accept": + return true + } + } + return false } -func shouldDropTunnelWebSocketRequestHeader(name string) bool { - if shouldDropTunnelHeader(name, true) { - return true - } +func shouldDropTunnelResponseHeader(name string) bool { switch strings.ToLower(name) { - case "sec-websocket-key", "sec-websocket-version", "sec-websocket-extensions", "sec-websocket-accept": + case "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade": return true default: return false @@ -609,7 +639,7 @@ func shouldDropTunnelWebSocketRequestHeader(name string) bool { func appendTunnelForwardedHeaders( headers []*gatewayv1.TunnelHeader, r *http.Request, - tunnel *gatewayv1.TunnelSummary, + slug string, ) []*gatewayv1.TunnelHeader { if r == nil { return headers @@ -618,10 +648,6 @@ func appendTunnelForwardedHeaders( if r.TLS != nil { proto = "https" } - prefix := "" - if tunnel != nil && strings.TrimSpace(tunnel.GetSlug()) != "" { - prefix = "/t/" + strings.TrimSpace(tunnel.GetSlug()) - } headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Host", Value: r.Host}, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Proto", Value: proto}, @@ -629,29 +655,27 @@ func appendTunnelForwardedHeaders( if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Origin", Value: origin}) } - if prefix != "" { - headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Prefix", Value: prefix}) + if slug = strings.TrimSpace(slug); slug != "" { + headers = append(headers, &gatewayv1.TunnelHeader{Name: "X-Forwarded-Prefix", Value: "/t/" + slug}) } return headers } -func tunnelResponseHeaders( - frame *gatewayv1.TunnelFrame, - tunnel *gatewayv1.TunnelSummary, -) http.Header { - headers := filteredTunnelResponseHeaders(frame.GetHeaders()) - for name, values := range headers { - rewritten := make([]string, 0, len(values)) - for _, value := range values { - if strings.EqualFold(name, "Location") { - value = rewriteTunnelLocation(value, tunnel) - } - if strings.EqualFold(name, "Set-Cookie") { - value = rewriteTunnelSetCookiePath(value, tunnel) - } - rewritten = append(rewritten, value) +func tunnelResponseHeaders(frame *gatewayv1.TunnelFrame, rw tunnelRewrite) http.Header { + headers := http.Header{} + for _, header := range frame.GetHeaders() { + name := http.CanonicalHeaderKey(strings.TrimSpace(header.GetName())) + if name == "" || shouldDropTunnelResponseHeader(name) { + continue + } + value := header.GetValue() + if strings.EqualFold(name, "Location") { + value = rewriteTunnelLocation(value, rw) + } + if strings.EqualFold(name, "Set-Cookie") { + value = rewriteTunnelSetCookiePath(value, rw) } - headers[name] = rewritten + headers.Add(name, value) } return headers } @@ -670,15 +694,15 @@ func flushTunnelResponse(w http.ResponseWriter) { } } -func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil { +func rewriteTunnelLocation(value string, rw tunnelRewrite) string { + publicPrefix := rw.publicPrefix() + if publicPrefix == "" { return value } - target, err := url.Parse(tunnel.GetTargetUrl()) + target, err := rw.parseTarget() if err != nil || target.Host == "" { return value } - publicPrefix := "/t/" + tunnel.GetSlug() parsed, err := url.Parse(value) if err != nil { return value @@ -688,40 +712,23 @@ func rewriteTunnelLocation(value string, tunnel *gatewayv1.TunnelSummary) string return value } path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) - if path == "" { - path = "/" - } - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - if parsed.Fragment != "" { - path += "#" + parsed.EscapedFragment() - } - return publicPrefix + path + return publicPrefix + appendTunnelURLQueryAndFragment(pathOrRoot(path), parsed) } if strings.HasPrefix(value, "/") { path := stripTunnelTargetBasePath(parsed.EscapedPath(), target.EscapedPath()) - if path == "" { - path = "/" - } - if parsed.RawQuery != "" { - path += "?" + parsed.RawQuery - } - if parsed.Fragment != "" { - path += "#" + parsed.EscapedFragment() - } - return publicPrefix + path + return publicPrefix + appendTunnelURLQueryAndFragment(pathOrRoot(path), parsed) } return value } -func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil || tunnel.GetSlug() == "" { +func rewriteTunnelSetCookiePath(value string, rw tunnelRewrite) string { + slug := strings.TrimSpace(rw.slug) + if slug == "" { return value } parts := strings.Split(value, ";") targetBasePath := "/" - if target, err := url.Parse(tunnel.GetTargetUrl()); err == nil { + if target, err := rw.parseTarget(); err == nil { targetBasePath = target.EscapedPath() } for index, part := range parts { @@ -741,7 +748,7 @@ func rewriteTunnelSetCookiePath(value string, tunnel *gatewayv1.TunnelSummary) s if leading := len(part) - len(strings.TrimLeft(part, " \t")); leading > 0 { prefix = part[:leading] } - parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, tunnel.GetSlug(), rest) + parts[index] = fmt.Sprintf("%sPath=/t/%s%s", prefix, slug, rest) } return strings.Join(parts, ";") } @@ -771,30 +778,3 @@ func normalizeTunnelPath(value string) string { } return value } - -func publicBaseURLFromHTTPRequest(r *http.Request) string { - if r == nil { - return "" - } - scheme := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Proto")) - if scheme == "" { - if r.TLS != nil { - scheme = "https" - } else { - scheme = "http" - } - } - host := forwardedHeaderFirst(r.Header.Get("X-Forwarded-Host")) - if host == "" { - host = r.Host - } - if host == "" { - return "" - } - return scheme + "://" + host -} - -func forwardedHeaderFirst(value string) string { - first := strings.TrimSpace(strings.Split(value, ",")[0]) - return strings.TrimSpace(first) -} diff --git a/crates/agent-gateway/internal/server/tunnel_proxy_test.go b/crates/agent-gateway/internal/server/tunnel_proxy_test.go new file mode 100644 index 00000000..8a2666b7 --- /dev/null +++ b/crates/agent-gateway/internal/server/tunnel_proxy_test.go @@ -0,0 +1,207 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/session" +) + +func TestParseTunnelPublicPath(t *testing.T) { + t.Parallel() + + tests := []struct { + raw string + slug string + rest string + ok bool + }{ + {"/t/abc/", "abc", "/", true}, + {"/t/abc/app/index.html", "abc", "/app/index.html", true}, + {"/t/abc", "abc", "/", true}, + {"/t/", "", "", false}, + {"/other", "", "", false}, + } + for _, tt := range tests { + slug, rest, ok := parseTunnelPublicPath(tt.raw) + if slug != tt.slug || rest != tt.rest || ok != tt.ok { + t.Fatalf("parseTunnelPublicPath(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.raw, slug, rest, ok, tt.slug, tt.rest, tt.ok) + } + } + + if slug, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/abc"); !ok || slug != "abc" { + t.Fatalf("no-trailing-slash parse = (%q, %v)", slug, ok) + } + if _, ok := parseTunnelPublicPathWithoutTrailingSlash("/t/abc/x"); ok { + t.Fatal("nested path must not match the redirect case") + } +} + +func TestTunnelRequestHeadersStripForwardedAndHopByHop(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "http://public.example/t/abc/", nil) + req.Header.Set("X-Forwarded-Host", "evil.example") + req.Header.Set("X-Forwarded-For", "1.2.3.4") + req.Header.Set("Forwarded", "for=1.2.3.4") + req.Header.Set("Connection", "keep-alive") + req.Header.Set("Transfer-Encoding", "chunked") + req.Header.Set("Accept", "text/html") + req.Header.Set("Origin", "https://public.example") + + headers := tunnelRequestHeaders(req, "abc") + byName := map[string][]string{} + for _, header := range headers { + byName[header.GetName()] = append(byName[header.GetName()], header.GetValue()) + } + + if got := byName["X-Forwarded-Host"]; len(got) != 1 || got[0] != "public.example" { + t.Fatalf("X-Forwarded-Host = %v, want the gateway-derived host only", got) + } + for _, banned := range []string{"X-Forwarded-For", "Forwarded", "Connection", "Transfer-Encoding", "Host"} { + if _, present := byName[banned]; present { + t.Fatalf("header %q must be stripped", banned) + } + } + if got := byName["X-Forwarded-Prefix"]; len(got) != 1 || got[0] != "/t/abc" { + t.Fatalf("X-Forwarded-Prefix = %v", got) + } + if got := byName["X-Forwarded-Origin"]; len(got) != 1 || got[0] != "https://public.example" { + t.Fatalf("X-Forwarded-Origin = %v", got) + } + if got := byName["Accept"]; len(got) != 1 || got[0] != "text/html" { + t.Fatalf("Accept = %v, want passthrough", got) + } +} + +func TestTunnelWebSocketRequestHeadersStripSecWebSocket(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, "http://public.example/t/abc/ws", nil) + req.Header.Set("Sec-Websocket-Key", "k") + req.Header.Set("Sec-Websocket-Version", "13") + req.Header.Set("Sec-Websocket-Protocol", "graphql-ws") + + headers := tunnelWebSocketRequestHeaders(req, "abc") + byName := map[string]string{} + for _, header := range headers { + byName[header.GetName()] = header.GetValue() + } + if _, present := byName["Sec-Websocket-Key"]; present { + t.Fatal("Sec-WebSocket-Key must be stripped") + } + if _, present := byName["Sec-Websocket-Version"]; present { + t.Fatal("Sec-WebSocket-Version must be stripped") + } + if got := byName["Sec-Websocket-Protocol"]; got != "graphql-ws" { + t.Fatalf("Sec-WebSocket-Protocol = %q, want passthrough", got) + } +} + +func TestWriteTunnelAcquireErrorStatusMapping(t *testing.T) { + t.Parallel() + + tests := []struct { + err error + status int + }{ + {session.ErrTunnelNotFound, http.StatusNotFound}, + {session.ErrTunnelExpired, http.StatusNotFound}, + {session.ErrAgentOffline, http.StatusServiceUnavailable}, + {session.ErrTunnelOverLimit, http.StatusTooManyRequests}, + } + for _, tt := range tests { + recorder := httptest.NewRecorder() + writeTunnelAcquireError(recorder, tt.err) + if recorder.Code != tt.status { + t.Fatalf("status for %v = %d, want %d", tt.err, recorder.Code, tt.status) + } + } +} + +func TestTunnelResponseHeadersRewriteLocationAndCookies(t *testing.T) { + t.Parallel() + + rw := tunnelRewrite{slug: "abc", targetURL: "http://localhost:3000"} + frame := &gatewayv1.TunnelFrame{ + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Location", Value: "http://localhost:3000/login?next=%2F"}, + {Name: "Set-Cookie", Value: "sid=1; Path=/; HttpOnly"}, + {Name: "Transfer-Encoding", Value: "chunked"}, + {Name: "Content-Type", Value: "text/html"}, + }, + } + headers := tunnelResponseHeaders(frame, rw) + if got := headers.Get("Location"); got != "/t/abc/login?next=%2F" { + t.Fatalf("Location = %q", got) + } + if got := headers.Get("Set-Cookie"); !strings.Contains(got, "Path=/t/abc/") { + t.Fatalf("Set-Cookie = %q, want rewritten path", got) + } + if headers.Get("Transfer-Encoding") != "" { + t.Fatal("hop-by-hop response header must be dropped") + } + if headers.Get("Content-Type") != "text/html" { + t.Fatal("Content-Type must pass through") + } +} + +func TestAmendTunnelCSPHashAmendable(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "default-src 'self'; script-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + policy := headers.Get("Content-Security-Policy") + if !strings.Contains(policy, "script-src 'self' 'sha256-") { + t.Fatalf("policy = %q, want sha256 appended to script-src", policy) + } + if strings.Contains(strings.TrimPrefix(policy, "default-src 'self' 'sha256-"), "default-src 'self' 'sha256-") { + t.Fatalf("policy = %q, default-src must stay untouched when script-src exists", policy) + } +} + +func TestAmendTunnelCSPDefaultSrcFallback(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "default-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + if policy := headers.Get("Content-Security-Policy"); !strings.Contains(policy, "default-src 'self' 'sha256-") { + t.Fatalf("policy = %q, want sha256 appended to default-src", policy) + } +} + +func TestAmendTunnelCSPNonceStripped(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "script-src 'nonce-abc123'") + headers.Set("Content-Security-Policy-Report-Only", "script-src 'self'") + amendTunnelCSP(headers, "console.log(1)") + if headers.Get("Content-Security-Policy") != "" { + t.Fatal("nonce policy must be stripped") + } + if headers.Get("Content-Security-Policy-Report-Only") != "" { + t.Fatal("report-only policy must be stripped alongside") + } + if headers.Get("X-Liveagent-Tunnel-Csp") != "stripped" { + t.Fatal("stripped marker header missing") + } +} + +func TestAmendTunnelCSPUnsafeInlineLeftAlone(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Content-Security-Policy", "script-src 'self' 'unsafe-inline'") + amendTunnelCSP(headers, "console.log(1)") + policy := headers.Get("Content-Security-Policy") + if strings.Contains(policy, "sha256-") { + t.Fatalf("policy = %q; adding a hash would re-disable 'unsafe-inline'", policy) + } +} diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite.go b/crates/agent-gateway/internal/server/tunnel_rewrite.go index e5a1d9e3..8a7dd8e2 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite.go @@ -1,6 +1,8 @@ package server import ( + "crypto/sha256" + "encoding/base64" "encoding/json" "io" "mime" @@ -12,8 +14,6 @@ import ( "github.com/tdewolff/parse/v2" "github.com/tdewolff/parse/v2/css" "golang.org/x/net/html" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) const tunnelRewriteBodyMaxBytes = 4 * 1024 * 1024 @@ -65,10 +65,10 @@ func tunnelResponseRewriteKindFor( func rewriteTunnelResponseBody( body []byte, - tunnel *gatewayv1.TunnelSummary, + rw tunnelRewrite, kind tunnelResponseRewriteKind, ) ([]byte, bool) { - if len(body) == 0 || kind == tunnelResponseRewriteNone || tunnelPublicPathPrefix(tunnel) == "" { + if len(body) == 0 || kind == tunnelResponseRewriteNone || rw.publicPrefix() == "" { return body, false } if !utf8.Valid(body) { @@ -79,9 +79,9 @@ func rewriteTunnelResponseBody( rewritten := original switch kind { case tunnelResponseRewriteHTML: - rewritten = rewriteTunnelHTMLBody(rewritten, tunnel) + rewritten = rewriteTunnelHTMLBody(rewritten, rw) case tunnelResponseRewriteCSS: - rewritten = rewriteTunnelCSSBody(rewritten, tunnel) + rewritten = rewriteTunnelCSSBody(rewritten, rw) } if rewritten == original { return body, false @@ -89,12 +89,12 @@ func rewriteTunnelResponseBody( return []byte(rewritten), true } -func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string { +func rewriteTunnelHTMLBody(input string, rw tunnelRewrite) string { tokenizer := html.NewTokenizer(strings.NewReader(input)) var builder strings.Builder changed := false injected := false - shim := tunnelRuntimeBootstrapScript(tunnel) + shim := tunnelRuntimeBootstrapScript(rw) for { tokenType := tokenizer.Next() @@ -124,13 +124,13 @@ func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string key := strings.ToLower(strings.TrimSpace(attr.Key)) switch { case isTunnelHTMLURLAttribute(key): - rewritten := rewriteTunnelBodyURL(attr.Val, tunnel) + rewritten := rewriteTunnelBodyURL(attr.Val, rw) if rewritten != attr.Val { attr.Val = rewritten tokenChanged = true } case key == "style": - rewritten := rewriteTunnelCSSBody(attr.Val, tunnel) + rewritten := rewriteTunnelCSSBody(attr.Val, rw) if rewritten != attr.Val { attr.Val = rewritten tokenChanged = true @@ -159,7 +159,7 @@ func rewriteTunnelHTMLBody(input string, tunnel *gatewayv1.TunnelSummary) string return builder.String() } -func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string { +func rewriteTunnelCSSBody(input string, rw tunnelRewrite) string { lexer := css.NewLexer(parse.NewInputString(input)) var builder strings.Builder changed := false @@ -175,7 +175,7 @@ func rewriteTunnelCSSBody(input string, tunnel *gatewayv1.TunnelSummary) string token := string(data) if tokenType == css.URLToken { - if rewritten, ok := rewriteTunnelCSSURLToken(token, tunnel); ok { + if rewritten, ok := rewriteTunnelCSSURLToken(token, rw); ok { builder.WriteString(rewritten) changed = true continue @@ -199,8 +199,18 @@ func isTunnelHTMLURLAttribute(key string) bool { } } -func tunnelRuntimeBootstrapScript(tunnel *gatewayv1.TunnelSummary) string { - prefix := tunnelPublicPathPrefix(tunnel) +func tunnelRuntimeBootstrapScript(rw tunnelRewrite) string { + body := tunnelShimScriptBody(rw) + if body == "" { + return "" + } + return `` +} + +// tunnelShimScriptBody is the raw JS between the shim's script tags; CSP +// hash amendment must digest exactly this string. +func tunnelShimScriptBody(rw tunnelRewrite) string { + prefix := rw.publicPrefix() if prefix == "" { return "" } @@ -210,7 +220,7 @@ func tunnelRuntimeBootstrapScript(tunnel *gatewayv1.TunnelSummary) string { if err != nil { return "" } - return `` + `})(` + string(config) + `);` } -func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (string, bool) { +func rewriteTunnelCSSURLToken(token string, rw tunnelRewrite) (string, bool) { openIndex := strings.Index(token, "(") closeIndex := strings.LastIndex(token, ")") if openIndex < 0 || closeIndex < openIndex { @@ -254,7 +264,7 @@ func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (st value = value[1 : len(value)-1] } - rewritten := rewriteTunnelBodyURL(value, tunnel) + rewritten := rewriteTunnelBodyURL(value, rw) if rewritten == value { return token, false } @@ -267,8 +277,8 @@ func rewriteTunnelCSSURLToken(token string, tunnel *gatewayv1.TunnelSummary) (st return before + leading + rewritten + trailing + after, true } -func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string { - prefix := tunnelPublicPathPrefix(tunnel) +func rewriteTunnelBodyURL(value string, rw tunnelRewrite) string { + prefix := rw.publicPrefix() if prefix == "" { return value } @@ -283,7 +293,7 @@ func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string if err != nil { return value } - target, targetErr := url.Parse(tunnel.GetTargetUrl()) + target, targetErr := rw.parseTarget() if parsed.IsAbs() { if targetErr != nil || target.Host == "" { return value @@ -309,17 +319,90 @@ func rewriteTunnelBodyURL(value string, tunnel *gatewayv1.TunnelSummary) string return appendTunnelURLQueryAndFragment(prefix+pathOrRoot(path), parsed) } -func tunnelPublicPathPrefix(tunnel *gatewayv1.TunnelSummary) string { - if tunnel == nil { - return "" - } - slug := strings.TrimSpace(tunnel.GetSlug()) +// tunnelRewrite carries the two facts body/header rewriting needs: which +// public prefix the tunnel is mounted under and which local target it fronts. +type tunnelRewrite struct { + slug string + targetURL string +} + +func (rw tunnelRewrite) publicPrefix() string { + slug := strings.TrimSpace(rw.slug) if slug == "" { return "" } return "/t/" + slug } +func (rw tunnelRewrite) parseTarget() (*url.URL, error) { + return url.Parse(strings.TrimSpace(rw.targetURL)) +} + +// amendTunnelCSP makes the injected shim executable under the response's +// Content-Security-Policy. Hash-amendable policies get the shim's sha256; +// nonce/strict-dynamic policies cannot be amended without weakening them, so +// they are stripped with an explicit marker header instead. +func amendTunnelCSP(headers http.Header, shimScriptBody string) { + policies := headers.Values("Content-Security-Policy") + if len(policies) == 0 || strings.TrimSpace(shimScriptBody) == "" { + return + } + digest := sha256.Sum256([]byte(shimScriptBody)) + hash := "'sha256-" + base64.StdEncoding.EncodeToString(digest[:]) + "'" + + amended := make([]string, 0, len(policies)) + for _, policy := range policies { + lower := strings.ToLower(policy) + if strings.Contains(lower, "'nonce-") || strings.Contains(lower, "'strict-dynamic'") { + headers.Del("Content-Security-Policy") + headers.Del("Content-Security-Policy-Report-Only") + headers.Set("X-Liveagent-Tunnel-Csp", "stripped") + return + } + amended = append(amended, amendTunnelCSPPolicy(policy, hash)) + } + headers.Del("Content-Security-Policy") + for _, policy := range amended { + headers.Add("Content-Security-Policy", policy) + } +} + +func amendTunnelCSPPolicy(policy string, hash string) string { + directives := strings.Split(policy, ";") + scriptIndexes := make([]int, 0, 2) + defaultIndex := -1 + for index, directive := range directives { + fields := strings.Fields(directive) + if len(fields) == 0 { + continue + } + name := strings.ToLower(fields[0]) + switch name { + case "script-src", "script-src-elem": + scriptIndexes = append(scriptIndexes, index) + case "default-src": + defaultIndex = index + } + } + targets := scriptIndexes + if len(targets) == 0 { + if defaultIndex < 0 { + return policy // no script restriction to satisfy + } + targets = []int{defaultIndex} + } + for _, index := range targets { + lower := strings.ToLower(directives[index]) + // A hash would re-disable 'unsafe-inline' on policies that rely on it. + if strings.Contains(lower, "'unsafe-inline'") && + !strings.Contains(lower, "'sha") && !strings.Contains(lower, "'nonce-") { + continue + } + directives[index] = strings.TrimRight(directives[index], " ") + " " + hash + } + return strings.Join(directives, ";") +} + func pathOrRoot(path string) string { if strings.TrimSpace(path) == "" { return "/" diff --git a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go index bba874a1..4594171c 100644 --- a/crates/agent-gateway/internal/server/tunnel_rewrite_test.go +++ b/crates/agent-gateway/internal/server/tunnel_rewrite_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) func TestTunnelResponseRewriteKindFor(t *testing.T) { @@ -124,9 +123,9 @@ func TestRewriteTunnelHTMLBodyPrefixesRootRelativeAttributes(t *testing.T) { func TestRewriteTunnelBodyStripsTargetBasePath(t *testing.T) { t.Parallel() - tunnel := &gatewayv1.TunnelSummary{ - Slug: "base-slug", - TargetUrl: "http://127.0.0.1:3100/app", + tunnel := tunnelRewrite{ + slug: "base-slug", + targetURL: "http://127.0.0.1:3100/app", } input := strings.Join([]string{ ``, @@ -304,10 +303,10 @@ func TestRewriteTunnelLocationPreservesQueryAndFragment(t *testing.T) { } } -func tunnelRewriteTestSummary() *gatewayv1.TunnelSummary { - return &gatewayv1.TunnelSummary{ - Slug: "test-slug", - TargetUrl: "http://127.0.0.1:3100", +func tunnelRewriteTestSummary() tunnelRewrite { + return tunnelRewrite{ + slug: "test-slug", + targetURL: "http://127.0.0.1:3100", } } diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 1e922b31..733bcdee 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -124,6 +124,8 @@ type websocketConnection struct { chatQueueEventsCleanup func() chatActivityEvents <-chan session.ConversationActivityEvent chatActivityEventsCleanup func() + tunnelStateEvents <-chan *gatewayv1.TunnelStateSnapshot + tunnelStateEventsCleanup func() heartbeatOnce sync.Once terminalInterest *websocketTerminalInterestTracker @@ -254,6 +256,10 @@ func (c *websocketConnection) close() { c.chatActivityEventsCleanup() c.chatActivityEventsCleanup = nil } + if c.tunnelStateEventsCleanup != nil { + c.tunnelStateEventsCleanup() + c.tunnelStateEventsCleanup = nil + } c.cleanupChatStreamSubscriptions() _ = c.conn.Close() }) @@ -282,12 +288,14 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { c.startSftpEventForwarder() c.startChatQueueEventForwarder() c.startChatActivityForwarder() + c.startTunnelStateForwarder() c.startWebSocketHeartbeat() if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { c.close() return } c.replayTerminalSessionSnapshot() + c.replayTunnelStateSnapshot() } func (c *websocketConnection) startHistorySyncForwarder() { diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 402cc5e2..7e9a070a 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -57,11 +57,10 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "sftp.delete": (*websocketConnection).handleSftpRequest, "sftp.transfer": (*websocketConnection).handleSftpRequest, "sftp.cancel": (*websocketConnection).handleSftpRequest, - "tunnel.list": (*websocketConnection).handleTunnelList, "tunnel.create": (*websocketConnection).handleTunnelCreate, "tunnel.update": (*websocketConnection).handleTunnelUpdate, "tunnel.close": (*websocketConnection).handleTunnelClose, - "tunnel.probe": (*websocketConnection).handleTunnelProbe, + "tunnel.check": (*websocketConnection).handleTunnelCheck, "git.status": (*websocketConnection).handleGitRequest, "git.branches": (*websocketConnection).handleGitRequest, "git.init": (*websocketConnection).handleGitRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index 28ee9abd..bd3a78d5 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -1,7 +1,6 @@ package server import ( - "encoding/json" "testing" ) @@ -61,11 +60,10 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "sftp.delete", "sftp.transfer", "sftp.cancel", - "tunnel.list", "tunnel.create", "tunnel.update", "tunnel.close", - "tunnel.probe", + "tunnel.check", "git.status", "git.branches", "git.init", @@ -145,50 +143,3 @@ func TestDecodeWebSocketPayloadRejectsUnknownFields(t *testing.T) { t.Fatal("expected unknown payload field to be rejected") } } - -func TestTunnelTTLFromPayloadDefaultsOnlyWhenOmitted(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - raw json.RawMessage - camelValue uint32 - snakeValue uint32 - want uint32 - }{ - { - name: "omitted", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000"}`), - camelValue: 0, - snakeValue: 0, - want: websocketDefaultTunnelTTLSeconds, - }, - { - name: "camel explicit infinite", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":0}`), - camelValue: 0, - snakeValue: 0, - want: 0, - }, - { - name: "snake explicit infinite", - raw: json.RawMessage(`{"target_url":"http://localhost:3000","ttl_seconds":0}`), - camelValue: 0, - snakeValue: 0, - want: 0, - }, - { - name: "camel finite", - raw: json.RawMessage(`{"targetUrl":"http://localhost:3000","ttlSeconds":900}`), - camelValue: 900, - snakeValue: 0, - want: 900, - }, - } - - for _, tt := range tests { - if got := tunnelTTLFromPayload(tt.raw, tt.camelValue, tt.snakeValue); got != tt.want { - t.Fatalf("%s: tunnelTTLFromPayload = %d, want %d", tt.name, got, tt.want) - } - } -} diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go index 560fd394..fdfd4dae 100644 --- a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go @@ -1,199 +1,64 @@ package server import ( - "context" - "encoding/json" - "strings" "time" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" ) -const websocketDefaultTunnelTTLSeconds = 3600 - -type websocketTunnelCreatePayload struct { - TargetURL string `json:"targetUrl"` - TargetUrl string `json:"target_url"` - Name string `json:"name"` - TTLSeconds uint32 `json:"ttlSeconds"` - TtlSeconds uint32 `json:"ttl_seconds"` - ProjectPathKey string `json:"projectPathKey"` - ProjectPathKeySnake string `json:"project_path_key"` -} - -func tunnelTTLFromPayload(raw json.RawMessage, camelValue uint32, snakeValue uint32) uint32 { - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return websocketDefaultTunnelTTLSeconds - } - if _, ok := fields["ttlSeconds"]; ok { - return camelValue - } - if _, ok := fields["ttl_seconds"]; ok { - return snakeValue - } - return websocketDefaultTunnelTTLSeconds +type websocketTunnelMutationPayload struct { + TunnelID string `json:"tunnel_id"` + TargetURL string `json:"target_url"` + Name string `json:"name"` + TTLSeconds *uint32 `json:"ttl_seconds"` + ProjectPathKey string `json:"project_path_key"` } -type websocketTunnelUpdatePayload struct { - ID string `json:"id"` - TunnelID string `json:"tunnelId"` - TunnelId string `json:"tunnel_id"` - Slug string `json:"slug"` - TargetURL string `json:"targetUrl"` - TargetUrl string `json:"target_url"` - Name string `json:"name"` - TTLSeconds uint32 `json:"ttlSeconds"` - TtlSeconds uint32 `json:"ttl_seconds"` - ProjectPathKey string `json:"projectPathKey"` - ProjectPathKeySnake string `json:"project_path_key"` +func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { + c.handleTunnelMutation(req, "create") } -type websocketTunnelClosePayload struct { - ID string `json:"id"` - TunnelID string `json:"tunnelId"` - TunnelId string `json:"tunnel_id"` - Slug string `json:"slug"` +func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { + c.handleTunnelMutation(req, "update") } -type websocketTunnelProbePayload struct { - ID string `json:"id"` - TunnelID string `json:"tunnelId"` - TunnelId string `json:"tunnel_id"` - Slug string `json:"slug"` +func (c *websocketConnection) handleTunnelClose(req websocketRequest) { + c.handleTunnelMutation(req, "close") } -func (c *websocketConnection) handleTunnelList(req websocketRequest) { - _ = c.writeResponse(req.ID, map[string]any{ - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) +func (c *websocketConnection) handleTunnelCheck(req websocketRequest) { + c.handleTunnelMutation(req, "check") } -func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { +// handleTunnelMutation forwards a webui tunnel mutation to the agent (the +// desired-state owner) and relays its verdict. State itself arrives on every +// client through the tunnel.state broadcast that the mutation triggers. +func (c *websocketConnection) handleTunnelMutation(req websocketRequest, action string) { if !c.sm.WebTunnelsEnabled() { _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") return } - var body websocketTunnelCreatePayload + var body websocketTunnelMutationPayload if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.create payload") - return - } - targetURL := strings.TrimSpace(body.TargetURL) - if targetURL == "" { - targetURL = strings.TrimSpace(body.TargetUrl) - } - ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - if projectPathKey == "" { - projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) - } - prepared, err := c.sm.PrepareTunnelCreate(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: targetURL, - Name: strings.TrimSpace(body.Name), - TtlSeconds: ttlSeconds, - ProjectPathKey: projectPathKey, - }, c.publicBaseURL()) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) + _ = c.writeError(req.ID, "invalid tunnel."+action+" payload") return } - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: prepared, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - controlResp := response.GetTunnelControlResp() - if controlResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - if controlResp.GetErrorMessage() != "" { - _ = c.writeError(req.ID, controlResp.GetErrorMessage()) - return - } - targetOverride := "" - if tunnel := controlResp.GetTunnel(); tunnel != nil { - targetOverride = tunnel.GetTargetUrl() - } - tunnel, err := c.sm.StorePreparedTunnel(prepared, targetOverride) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - tunnel = c.probeTunnel(tunnel) - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) -} - -func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { - if !c.sm.WebTunnelsEnabled() { - _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") - return - } - - var body websocketTunnelUpdatePayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.update payload") - return - } - identifier := strings.TrimSpace(body.ID) - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelID) - } - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelId) - } - if identifier == "" { - identifier = strings.TrimSpace(body.Slug) - } - if identifier == "" { - _ = c.writeError(req.ID, "tunnel id is required") - return - } - targetURL := strings.TrimSpace(body.TargetURL) - if targetURL == "" { - targetURL = strings.TrimSpace(body.TargetUrl) - } - ttlSeconds := tunnelTTLFromPayload(req.Payload, body.TTLSeconds, body.TtlSeconds) - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - if projectPathKey == "" { - projectPathKey = strings.TrimSpace(body.ProjectPathKeySnake) - } - prepared, err := c.sm.PrepareTunnelUpdate(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: identifier, - TargetUrl: targetURL, - Name: strings.TrimSpace(body.Name), - TtlSeconds: ttlSeconds, - ProjectPathKey: projectPathKey, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return + mutation := &gatewayv1.TunnelMutation{ + Action: action, + TunnelId: body.TunnelID, + TargetUrl: body.TargetURL, + Name: body.Name, + TtlSeconds: body.TTLSeconds, + ProjectPathKey: body.ProjectPathKey, } response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ RequestId: req.ID, Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: prepared, + Payload: &gatewayv1.GatewayEnvelope_TunnelMutation{ + TunnelMutation: mutation, }, }) if err != nil { @@ -204,202 +69,93 @@ func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { _ = c.writeError(req.ID, errResp.GetMessage()) return } - controlResp := response.GetTunnelControlResp() - if controlResp == nil { + result := response.GetTunnelMutationResult() + if result == nil { _ = c.writeError(req.ID, "unexpected agent response") return } - if controlResp.GetErrorMessage() != "" { - _ = c.writeError(req.ID, controlResp.GetErrorMessage()) - return - } - tunnel, err := c.sm.ApplyTunnelUpdate(controlResp.GetTunnel()) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) + if result.GetErrorMessage() != "" { + _ = c.writeError(req.ID, result.GetErrorMessage()) return } - tunnel = c.probeTunnel(tunnel) _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), + "tunnel_id": result.GetTunnelId(), }) } -func (c *websocketConnection) handleTunnelProbe(req websocketRequest) { - var body websocketTunnelProbePayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.probe payload") - return - } - identifier := strings.TrimSpace(body.ID) - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelID) - } - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelId) - } - if identifier == "" { - identifier = strings.TrimSpace(body.Slug) - } - if identifier == "" { - _ = c.writeError(req.ID, "tunnel id is required") - return - } - var tunnel *gatewayv1.TunnelSummary - for _, item := range c.sm.ListTunnels() { - if item.GetId() == identifier || item.GetSlug() == identifier { - tunnel = item - break - } - } - if tunnel == nil { - _ = c.writeError(req.ID, "tunnel not found") +func (c *websocketConnection) startTunnelStateForwarder() { + if c.tunnelStateEvents != nil || c.tunnelStateEventsCleanup != nil { return } - tunnel = c.probeTunnel(tunnel) - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) -} -func (c *websocketConnection) handleTunnelClose(req websocketRequest) { - if !c.sm.WebTunnelsEnabled() { - _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") - return - } + tunnelStateEvents, cleanup := c.sm.SubscribeTunnelState() + c.tunnelStateEvents = tunnelStateEvents + c.tunnelStateEventsCleanup = cleanup - var body websocketTunnelClosePayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel.close payload") - return - } - identifier := strings.TrimSpace(body.ID) - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelID) - } - if identifier == "" { - identifier = strings.TrimSpace(body.TunnelId) - } - if identifier == "" { - identifier = strings.TrimSpace(body.Slug) - } - if identifier == "" { - _ = c.writeError(req.ID, "tunnel id is required") - return - } - - tunnel, err := c.sm.CloseTunnel(identifier) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - _ = c.sendToAgent(&gatewayv1.GatewayEnvelope{ - RequestId: "tunnel-close-" + tunnel.GetId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControl{ - TunnelControl: &gatewayv1.TunnelControlRequest{ - Action: "close", - TunnelId: tunnel.GetId(), - Slug: tunnel.GetSlug(), - }, - }, - }) - - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel": websocketTunnelSummaryPayload(tunnel, c.publicBaseURL()), - "tunnels": websocketTunnelSummariesPayload(c.sm.ListTunnels(), c.publicBaseURL()), - }) + go func() { + for { + select { + case <-c.done: + return + case snapshot, ok := <-tunnelStateEvents: + if !ok { + return + } + if err := c.writeEvent("tunnel.state", websocketTunnelStatePayload(snapshot)); err != nil { + return + } + } + } + }() } -func (c *websocketConnection) publicBaseURL() string { - return publicBaseURLFromHTTPRequest(c.req) +func (c *websocketConnection) replayTunnelStateSnapshot() { + _ = c.writeEvent("tunnel.state", websocketTunnelStatePayload(c.sm.TunnelStateSnapshot())) } -func (c *websocketConnection) probeTunnel(tunnel *gatewayv1.TunnelSummary) *gatewayv1.TunnelSummary { - ctx, cancel := context.WithTimeout(c.req.Context(), 3*session.TunnelProbeTimeout) - defer cancel() - updated, err := c.sm.ProbeTunnel(ctx, tunnel.GetId(), c.publicBaseURL()) - if err != nil { - return tunnel - } - return updated -} - -func websocketTunnelSummariesPayload( - summaries []*gatewayv1.TunnelSummary, - publicBaseURL string, -) []map[string]any { - payload := make([]map[string]any, 0, len(summaries)) - for _, summary := range summaries { - if item := websocketTunnelSummaryPayload(summary, publicBaseURL); item != nil { - payload = append(payload, item) +func websocketTunnelStatePayload(snapshot *gatewayv1.TunnelStateSnapshot) map[string]any { + if snapshot == nil { + return map[string]any{ + "revision": 0, + "agent_online": false, + "tunnels": []map[string]any{}, } } - return payload -} - -func websocketTunnelSummaryPayload( - summary *gatewayv1.TunnelSummary, - publicBaseURL string, -) map[string]any { - if summary == nil { - return nil - } - publicURL := strings.TrimSpace(summary.GetPublicUrl()) - if publicURL == "" { - publicURL = buildPublicTunnelURL(publicBaseURL, summary.GetSlug()) - } - return map[string]any{ - "id": strings.TrimSpace(summary.GetId()), - "slug": strings.TrimSpace(summary.GetSlug()), - "name": strings.TrimSpace(summary.GetName()), - "targetUrl": strings.TrimSpace(summary.GetTargetUrl()), - "target_url": strings.TrimSpace(summary.GetTargetUrl()), - "publicUrl": publicURL, - "public_url": publicURL, - "createdAt": summary.GetCreatedAt(), - "created_at": summary.GetCreatedAt(), - "expiresAt": summary.GetExpiresAt(), - "expires_at": summary.GetExpiresAt(), - "activeConnections": summary.GetActiveConnections(), - "active_connections": summary.GetActiveConnections(), - "status": strings.TrimSpace(summary.GetStatus()), - "projectPathKey": strings.TrimSpace(summary.GetProjectPathKey()), - "project_path_key": strings.TrimSpace(summary.GetProjectPathKey()), - "diagnostics": websocketTunnelDiagnosticsPayload(summary.GetDiagnostics()), - } -} - -func websocketTunnelDiagnosticsPayload( - diagnostics []*gatewayv1.TunnelDiagnostic, -) []map[string]any { - payload := make([]map[string]any, 0, len(diagnostics)) - for _, diagnostic := range diagnostics { - if diagnostic == nil { + tunnels := make([]map[string]any, 0, len(snapshot.GetTunnels())) + for _, tunnel := range snapshot.GetTunnels() { + if tunnel == nil { continue } - payload = append(payload, map[string]any{ - "protocol": strings.TrimSpace(diagnostic.GetProtocol()), - "status": strings.TrimSpace(diagnostic.GetStatus()), - "statusCode": diagnostic.GetStatusCode(), - "status_code": diagnostic.GetStatusCode(), - "errorCode": strings.TrimSpace(diagnostic.GetErrorCode()), - "error_code": strings.TrimSpace(diagnostic.GetErrorCode()), - "message": strings.TrimSpace(diagnostic.GetMessage()), - "checkedAt": diagnostic.GetCheckedAt(), - "checked_at": diagnostic.GetCheckedAt(), + tunnels = append(tunnels, map[string]any{ + "id": tunnel.GetId(), + "slug": tunnel.GetSlug(), + "name": tunnel.GetName(), + "target_url": tunnel.GetTargetUrl(), + "public_path": tunnel.GetPublicPath(), + "created_at": tunnel.GetCreatedAt(), + "expires_at": tunnel.GetExpiresAt(), + "active_connections": tunnel.GetActiveConnections(), + "project_path_key": tunnel.GetProjectPathKey(), + "local": websocketTunnelHealthPayload(tunnel.GetLocal()), }) } - return payload + return map[string]any{ + "revision": snapshot.GetRevision(), + "agent_online": snapshot.GetAgentOnline(), + "relay": websocketTunnelHealthPayload(snapshot.GetRelay()), + "tunnels": tunnels, + } } -func buildPublicTunnelURL(publicBaseURL string, slug string) string { - publicBaseURL = strings.TrimRight(strings.TrimSpace(publicBaseURL), "/") - slug = strings.TrimSpace(slug) - if publicBaseURL == "" || slug == "" { - return "" +func websocketTunnelHealthPayload(health *gatewayv1.TunnelHealth) map[string]any { + if health == nil { + return nil + } + return map[string]any{ + "status": health.GetStatus(), + "http_status": health.GetHttpStatus(), + "error": health.GetError(), + "checked_at": health.GetCheckedAt(), + "rtt_ms": health.GetRttMs(), } - return publicBaseURL + "/t/" + slug + "/" } diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index ae9c02dc..ddfadb40 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -12,7 +12,6 @@ var ErrAgentOffline = errors.New("agent offline") var ErrTunnelNotFound = errors.New("tunnel not found") var ErrTunnelExpired = errors.New("tunnel expired") var ErrTunnelOverLimit = errors.New("tunnel connection limit exceeded") -var ErrTunnelLimitExceeded = errors.New("tunnel limit exceeded") var ErrCommandQueueFull = errors.New("command queue full") var ErrCommandQueueTimeout = errors.New("command queue timeout: agent did not reconnect") @@ -32,7 +31,7 @@ type Manager struct { registry *sessionRegistry syncHub *syncHub convStreams *conversationStreamStore - tunnels *tunnelStore + tunnels *tunnelRuntime cmdQueue *commandQueue } @@ -79,10 +78,11 @@ func NewManager() *Manager { m := &Manager{ registry: newSessionRegistry(), syncHub: newSyncHub(), - tunnels: newTunnelStore(), + tunnels: newTunnelRuntime(), cmdQueue: newCommandQueue(defaultCommandQueueTimeout), } m.convStreams = newConversationStreamStore(m.IsOnline) + go m.tunnelExpirySweepLoop() return m } diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go index 1ff8c979..9754b90a 100644 --- a/crates/agent-gateway/internal/session/manager_dispatch.go +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -79,10 +79,19 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent return } - if tunnelControl := env.GetTunnelControl(); tunnelControl != nil { - m.handleAgentTunnelControl(session, env.GetRequestId(), tunnelControl) + // Desired-state and probe payloads fan out broadcasts and relay probes; + // run them off the gRPC read loop so tunnel frames keep flowing. + if tunnelDesired := env.GetTunnelDesired(); tunnelDesired != nil { + go m.ApplyDesiredState(tunnelDesired) return } + if tunnelProbeReport := env.GetTunnelProbeReport(); tunnelProbeReport != nil { + go m.ApplyProbeReport(tunnelProbeReport) + return + } + + // TunnelMutationResult intentionally falls through to session.dispatch: + // it answers a gateway-issued request and correlates by request id. session.dispatch(env) } diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 099b2a32..3d648148 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -75,6 +75,7 @@ func (m *Manager) ClearSession(session *AgentSession) { session.Close() m.clearTerminalSessionSnapshot() + go m.onAgentSessionCleared() } func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout time.Duration) bool { @@ -98,6 +99,7 @@ func (m *Manager) ClearSessionIfHeartbeatStale(session *AgentSession, timeout ti session.Close() m.clearTerminalSessionSnapshot() + go m.onAgentSessionCleared() return true } @@ -114,6 +116,7 @@ func (m *Manager) clearSessionForEpoch(sessionEpoch uint64) bool { session.Close() m.clearTerminalSessionSnapshot() + go m.onAgentSessionCleared() return true } diff --git a/crates/agent-gateway/internal/session/manager_tunnel.go b/crates/agent-gateway/internal/session/manager_tunnel.go deleted file mode 100644 index 00c8957b..00000000 --- a/crates/agent-gateway/internal/session/manager_tunnel.go +++ /dev/null @@ -1,904 +0,0 @@ -package session - -import ( - "context" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "net/url" - "strings" - "sync" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -const ( - maxTunnelsPerAgent = 5 - maxTunnelConnections = 20 - defaultTunnelTTLSeconds = 3600 - tunnelSlugEntropyBytes = 24 - tunnelStreamChannelDepth = 256 - tunnelAgentSendTimeout = 10 * time.Second -) - -type tunnelStore struct { - mu sync.Mutex - tunnelsByID map[string]*tunnelRecord - tunnelIDBySlug map[string]string - streams map[string]*tunnelStream -} - -type tunnelRecord struct { - id string - slug string - name string - targetURL string - publicURL string - projectPathKey string - diagnostics []*gatewayv1.TunnelDiagnostic - createdAt time.Time - expiresAt time.Time - activeConnections int - closed bool -} - -type tunnelStream struct { - streamID string - tunnelID string - ch chan *gatewayv1.TunnelFrame - done chan struct{} - once sync.Once -} - -type TunnelStreamLease struct { - manager *Manager - stream *tunnelStream - tunnel *gatewayv1.TunnelSummary - once sync.Once -} - -func newTunnelStore() *tunnelStore { - return &tunnelStore{ - tunnelsByID: make(map[string]*tunnelRecord), - tunnelIDBySlug: make(map[string]string), - streams: make(map[string]*tunnelStream), - } -} - -func (l *TunnelStreamLease) Tunnel() *gatewayv1.TunnelSummary { - if l == nil || l.tunnel == nil { - return nil - } - return cloneTunnelSummary(l.tunnel) -} - -func (l *TunnelStreamLease) TunnelID() string { - if l == nil || l.stream == nil { - return "" - } - return l.stream.tunnelID -} - -func (l *TunnelStreamLease) StreamID() string { - if l == nil || l.stream == nil { - return "" - } - return l.stream.streamID -} - -func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { - if l == nil || l.stream == nil { - return nil - } - return l.stream.ch -} - -func (l *TunnelStreamLease) Done() <-chan struct{} { - if l == nil || l.stream == nil { - return nil - } - return l.stream.done -} - -func (l *TunnelStreamLease) Release() { - if l == nil { - return - } - l.once.Do(func() { - l.manager.releaseTunnelStream(l.stream) - }) -} - -func (s *tunnelStream) close() { - if s == nil { - return - } - s.once.Do(func() { - close(s.done) - }) -} - -func (s *tunnelStream) send(frame *gatewayv1.TunnelFrame) bool { - select { - case <-s.done: - return false - case s.ch <- frame: - return true - } -} - -func (m *Manager) WebTunnelsEnabled() bool { - m.syncHub.settingsSnapshotMu.RLock() - defer m.syncHub.settingsSnapshotMu.RUnlock() - - remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) - if !ok { - return false - } - enabled, ok := remote["enableWebTunnels"].(bool) - return ok && enabled -} - -func (m *Manager) ListTunnels() []*gatewayv1.TunnelSummary { - now := time.Now() - online := m.IsOnline() - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - summaries := make([]*gatewayv1.TunnelSummary, 0, len(m.tunnels.tunnelsByID)) - for _, record := range m.tunnels.tunnelsByID { - if record == nil || record.closed { - continue - } - summaries = append(summaries, tunnelSummaryLocked(record, now, online)) - } - sortTunnelSummaries(summaries) - return summaries -} - -func (m *Manager) setTunnelDiagnostics(identifier string, diagnostics []*gatewayv1.TunnelDiagnostic) (*gatewayv1.TunnelSummary, error) { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return nil, ErrTunnelNotFound - } - now := time.Now() - online := m.IsOnline() - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - record := m.tunnels.tunnelsByID[identifier] - if record == nil { - if id := m.tunnels.tunnelIDBySlug[identifier]; id != "" { - record = m.tunnels.tunnelsByID[id] - } - } - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - record.diagnostics = cloneTunnelDiagnostics(diagnostics) - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) PrepareTunnelCreate( - input *gatewayv1.TunnelControlRequest, - publicBaseURL string, -) (*gatewayv1.TunnelControlRequest, error) { - if input == nil { - return nil, errors.New("tunnel create input is required") - } - ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) - if err != nil { - return nil, err - } - now := time.Now() - var expiresAt time.Time - if ttlSeconds > 0 { - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - activeCount := 0 - for _, record := range m.tunnels.tunnelsByID { - if record == nil || record.closed || isTunnelExpired(record, now) { - continue - } - activeCount += 1 - } - if activeCount >= maxTunnelsPerAgent { - return nil, ErrTunnelLimitExceeded - } - - id := strings.TrimSpace(input.GetTunnelId()) - if id == "" { - id = generateTunnelID() - } - if _, exists := m.tunnels.tunnelsByID[id]; exists { - return nil, fmt.Errorf("tunnel id already exists") - } - slug := strings.TrimSpace(input.GetSlug()) - if slug == "" { - for { - generated, err := generateTunnelSlug() - if err != nil { - return nil, err - } - if _, exists := m.tunnels.tunnelIDBySlug[generated]; !exists { - slug = generated - break - } - } - } else if _, exists := m.tunnels.tunnelIDBySlug[slug]; exists { - return nil, fmt.Errorf("tunnel slug already exists") - } - - publicURL := normalizeTunnelPublicURL(input.GetPublicUrl()) - if publicURL == "" { - publicURL = buildTunnelPublicURL(publicBaseURL, slug) - } - - return &gatewayv1.TunnelControlRequest{ - Action: strings.TrimSpace(input.GetAction()), - TunnelId: id, - Slug: slug, - TargetUrl: strings.TrimSpace(input.GetTargetUrl()), - Name: strings.TrimSpace(input.GetName()), - TtlSeconds: ttlSeconds, - ExpiresAt: tunnelUnix(expiresAt), - PublicUrl: publicURL, - PublicBaseUrl: strings.TrimSpace(publicBaseURL), - ProjectPathKey: strings.TrimSpace(input.GetProjectPathKey()), - }, nil -} - -func (m *Manager) PrepareTunnelUpdate( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelControlRequest, error) { - if input == nil { - return nil, errors.New("tunnel update input is required") - } - ttlSeconds, err := normalizeTunnelTTL(input.GetTtlSeconds()) - if err != nil { - return nil, err - } - now := time.Now() - var expiresAt time.Time - if input.GetExpiresAt() > 0 { - expiresAt = time.Unix(input.GetExpiresAt(), 0) - } else if ttlSeconds > 0 { - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - targetURL := strings.TrimSpace(input.GetTargetUrl()) - if targetURL == "" { - return nil, errors.New("target_url is required") - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - identifier := strings.TrimSpace(input.GetTunnelId()) - if identifier == "" { - identifier = strings.TrimSpace(input.GetSlug()) - } - if identifier == "" { - return nil, ErrTunnelNotFound - } - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - projectPathKey := strings.TrimSpace(input.GetProjectPathKey()) - if projectPathKey == "" { - projectPathKey = record.projectPathKey - } - - return &gatewayv1.TunnelControlRequest{ - Action: strings.TrimSpace(input.GetAction()), - TunnelId: record.id, - Slug: record.slug, - TargetUrl: targetURL, - Name: strings.TrimSpace(input.GetName()), - TtlSeconds: ttlSeconds, - ExpiresAt: tunnelUnix(expiresAt), - PublicUrl: record.publicURL, - ProjectPathKey: projectPathKey, - }, nil -} - -func (m *Manager) StorePreparedTunnel( - prepared *gatewayv1.TunnelControlRequest, - targetURLOverride string, -) (*gatewayv1.TunnelSummary, error) { - if prepared == nil { - return nil, errors.New("prepared tunnel is required") - } - now := time.Now() - targetURL := strings.TrimSpace(targetURLOverride) - if targetURL == "" { - targetURL = strings.TrimSpace(prepared.GetTargetUrl()) - } - if targetURL == "" { - return nil, errors.New("target_url is required") - } - var expiresAt time.Time - if prepared.GetExpiresAt() > 0 { - expiresAt = time.Unix(prepared.GetExpiresAt(), 0) - } else if prepared.GetTtlSeconds() > 0 { - ttlSeconds, err := normalizeTunnelTTL(prepared.GetTtlSeconds()) - if err != nil { - return nil, err - } - expiresAt = now.Add(time.Duration(ttlSeconds) * time.Second) - } - record := &tunnelRecord{ - id: strings.TrimSpace(prepared.GetTunnelId()), - slug: strings.TrimSpace(prepared.GetSlug()), - name: strings.TrimSpace(prepared.GetName()), - targetURL: targetURL, - publicURL: normalizeTunnelPublicURL(prepared.GetPublicUrl()), - projectPathKey: strings.TrimSpace(prepared.GetProjectPathKey()), - createdAt: now, - expiresAt: expiresAt, - } - if record.id == "" || record.slug == "" { - return nil, errors.New("prepared tunnel is missing id or slug") - } - if record.publicURL == "" { - record.publicURL = buildTunnelPublicURL(prepared.GetPublicBaseUrl(), record.slug) - } - - online := m.IsOnline() - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - if _, exists := m.tunnels.tunnelsByID[record.id]; exists { - return nil, fmt.Errorf("tunnel id already exists") - } - if _, exists := m.tunnels.tunnelIDBySlug[record.slug]; exists { - return nil, fmt.Errorf("tunnel slug already exists") - } - m.tunnels.tunnelsByID[record.id] = record - m.tunnels.tunnelIDBySlug[record.slug] = record.id - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) createTunnelFromAgent( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelSummary, error) { - prepared, err := m.PrepareTunnelCreate(input, input.GetPublicBaseUrl()) - if err != nil { - return nil, err - } - return m.StorePreparedTunnel(prepared, input.GetTargetUrl()) -} - -func (m *Manager) updateTunnelFromAgent( - input *gatewayv1.TunnelControlRequest, -) (*gatewayv1.TunnelSummary, error) { - prepared, err := m.PrepareTunnelUpdate(input) - if err != nil { - return nil, err - } - return m.ApplyTunnelUpdate(&gatewayv1.TunnelSummary{ - Id: prepared.GetTunnelId(), - Slug: prepared.GetSlug(), - Name: prepared.GetName(), - TargetUrl: prepared.GetTargetUrl(), - PublicUrl: prepared.GetPublicUrl(), - ExpiresAt: prepared.GetExpiresAt(), - ProjectPathKey: prepared.GetProjectPathKey(), - }) -} - -func (m *Manager) ApplyTunnelUpdate(summary *gatewayv1.TunnelSummary) (*gatewayv1.TunnelSummary, error) { - if summary == nil { - return nil, errors.New("tunnel update summary is required") - } - identifier := strings.TrimSpace(summary.GetId()) - if identifier == "" { - identifier = strings.TrimSpace(summary.GetSlug()) - } - if identifier == "" { - return nil, ErrTunnelNotFound - } - targetURL := strings.TrimSpace(summary.GetTargetUrl()) - if targetURL == "" { - return nil, errors.New("target_url is required") - } - now := time.Now() - online := m.IsOnline() - var expiresAt time.Time - if summary.GetExpiresAt() > 0 { - expiresAt = time.Unix(summary.GetExpiresAt(), 0) - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - record.name = strings.TrimSpace(summary.GetName()) - record.targetURL = targetURL - if publicURL := normalizeTunnelPublicURL(summary.GetPublicUrl()); publicURL != "" { - record.publicURL = publicURL - } - record.projectPathKey = strings.TrimSpace(summary.GetProjectPathKey()) - record.expiresAt = expiresAt - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { - slug = strings.TrimSpace(slug) - streamID = strings.TrimSpace(streamID) - if slug == "" || streamID == "" { - return nil, ErrTunnelNotFound - } - if !m.IsOnline() { - return nil, ErrAgentOffline - } - now := time.Now() - online := true - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - - tunnelID := m.tunnels.tunnelIDBySlug[slug] - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - if record.activeConnections >= maxTunnelConnections { - return nil, ErrTunnelOverLimit - } - stream := &tunnelStream{ - streamID: streamID, - tunnelID: record.id, - ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), - done: make(chan struct{}), - } - if existing := m.tunnels.streams[streamID]; existing != nil { - existing.close() - } - m.tunnels.streams[streamID] = stream - record.activeConnections += 1 - - return &TunnelStreamLease{ - manager: m, - stream: stream, - tunnel: tunnelSummaryLocked(record, now, online), - }, nil -} - -func (m *Manager) CloseTunnel(identifier string) (*gatewayv1.TunnelSummary, error) { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return nil, ErrTunnelNotFound - } - now := time.Now() - online := m.IsOnline() - - var summary *gatewayv1.TunnelSummary - var cancelFrames []*gatewayv1.TunnelFrame - m.tunnels.mu.Lock() - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - m.tunnels.mu.Unlock() - return nil, ErrTunnelNotFound - } - record.closed = true - summary = tunnelSummaryLocked(record, now, online) - delete(m.tunnels.tunnelsByID, record.id) - delete(m.tunnels.tunnelIDBySlug, record.slug) - for streamID, stream := range m.tunnels.streams { - if stream == nil || stream.tunnelID != record.id { - continue - } - delete(m.tunnels.streams, streamID) - stream.close() - cancelFrames = append(cancelFrames, &gatewayv1.TunnelFrame{ - StreamId: stream.streamID, - TunnelId: record.id, - Slug: record.slug, - Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, - }) - } - m.tunnels.mu.Unlock() - - for _, frame := range cancelFrames { - _ = m.SendTunnelFrameToAgent(frame) - } - return summary, nil -} - -func (m *Manager) resumeTunnel(input *gatewayv1.TunnelControlRequest) (*gatewayv1.TunnelSummary, error) { - if input == nil { - return nil, errors.New("resume tunnel input is required") - } - now := time.Now() - online := m.IsOnline() - id := strings.TrimSpace(input.GetTunnelId()) - slug := strings.TrimSpace(input.GetSlug()) - if id == "" && slug == "" { - return nil, ErrTunnelNotFound - } - - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - if id == "" { - id = m.tunnels.tunnelIDBySlug[slug] - } - record := m.tunnels.tunnelsByID[id] - if record == nil || record.closed { - return nil, ErrTunnelNotFound - } - if slug != "" && record.slug != slug { - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - return nil, ErrTunnelExpired - } - if targetURL := strings.TrimSpace(input.GetTargetUrl()); targetURL != "" { - record.targetURL = targetURL - } - if name := strings.TrimSpace(input.GetName()); name != "" { - record.name = name - } - if projectPathKey := strings.TrimSpace(input.GetProjectPathKey()); projectPathKey != "" { - record.projectPathKey = projectPathKey - } - return tunnelSummaryLocked(record, now, online), nil -} - -func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { - if frame == nil { - return errors.New("tunnel frame is required") - } - ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) - defer cancel() - return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: fmt.Sprintf("tunnel-frame-%s", strings.TrimSpace(frame.GetStreamId())), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ - TunnelFrame: frame, - }, - }) -} - -func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { - if frame == nil { - return - } - streamID := strings.TrimSpace(frame.GetStreamId()) - if streamID == "" { - return - } - m.tunnels.mu.Lock() - stream := m.tunnels.streams[streamID] - m.tunnels.mu.Unlock() - if stream == nil { - return - } - stream.send(frame) -} - -func (m *Manager) handleAgentTunnelControl( - session *AgentSession, - requestID string, - request *gatewayv1.TunnelControlRequest, -) { - if session == nil || request == nil { - return - } - response := m.handleAgentTunnelControlInner(request) - ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) - defer cancel() - _ = session.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelControlResp{ - TunnelControlResp: response, - }, - }) -} - -func (m *Manager) handleAgentTunnelControlInner( - request *gatewayv1.TunnelControlRequest, -) *gatewayv1.TunnelControlResponse { - action := strings.ToLower(strings.TrimSpace(request.GetAction())) - if action == "" { - return tunnelControlError("invalid_action", "tunnel action is required") - } - switch action { - case "list": - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnels: m.ListTunnels(), - } - case "create": - tunnel, err := m.createTunnelFromAgent(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "update": - tunnel, err := m.updateTunnelFromAgent(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "probe": - identifier := request.GetTunnelId() - if strings.TrimSpace(identifier) == "" { - identifier = request.GetSlug() - } - tunnel, err := m.ProbeTunnel(context.Background(), identifier, request.GetPublicBaseUrl()) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "close": - identifier := request.GetTunnelId() - if strings.TrimSpace(identifier) == "" { - identifier = request.GetSlug() - } - tunnel, err := m.CloseTunnel(identifier) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - case "resume": - tunnel, err := m.resumeTunnel(request) - if err != nil { - return tunnelControlErrorFor(action, err) - } - return &gatewayv1.TunnelControlResponse{ - Action: action, - Tunnel: tunnel, - Tunnels: m.ListTunnels(), - } - default: - return tunnelControlError("invalid_action", "unsupported tunnel action") - } -} - -func (m *Manager) releaseTunnelStream(stream *tunnelStream) { - if stream == nil { - return - } - m.tunnels.mu.Lock() - if existing := m.tunnels.streams[stream.streamID]; existing == stream { - delete(m.tunnels.streams, stream.streamID) - } - if record := m.tunnels.tunnelsByID[stream.tunnelID]; record != nil && record.activeConnections > 0 { - record.activeConnections -= 1 - } - stream.close() - m.tunnels.mu.Unlock() -} - -func normalizeTunnelTTL(input uint32) (uint32, error) { - switch input { - case 0: - return 0, nil - case 900, 3600, 14400: - return input, nil - default: - return 0, errors.New("ttl_seconds must be one of 0, 900, 3600, or 14400") - } -} - -func tunnelUnix(value time.Time) int64 { - if value.IsZero() { - return 0 - } - return value.Unix() -} - -func generateTunnelID() string { - return "tun_" + strings.ReplaceAll(time.Now().UTC().Format("20060102150405.000000000"), ".", "") + "_" + randomURLToken(8) -} - -func generateTunnelSlug() (string, error) { - token := randomURLToken(tunnelSlugEntropyBytes) - if token == "" { - return "", errors.New("generate tunnel slug failed") - } - return token, nil -} - -func randomURLToken(byteCount int) string { - if byteCount <= 0 { - return "" - } - buf := make([]byte, byteCount) - if _, err := rand.Read(buf); err != nil { - return "" - } - return base64.RawURLEncoding.EncodeToString(buf) -} - -func normalizeTunnelPublicURL(input string) string { - trimmed := strings.TrimSpace(input) - if trimmed == "" { - return "" - } - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - parsed.RawQuery = "" - parsed.Fragment = "" - if !strings.HasSuffix(parsed.Path, "/") { - parsed.Path += "/" - } - return parsed.String() -} - -func buildTunnelPublicURL(publicBaseURL string, slug string) string { - base := strings.TrimSpace(publicBaseURL) - if base == "" || strings.TrimSpace(slug) == "" { - return "" - } - parsed, err := url.Parse(base) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "" - } - parsed.RawQuery = "" - parsed.Fragment = "" - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/t/" + strings.TrimSpace(slug) + "/" - return parsed.String() -} - -func isTunnelExpired(record *tunnelRecord, now time.Time) bool { - return record == nil || (!record.expiresAt.IsZero() && !record.expiresAt.After(now)) -} - -func tunnelSummaryLocked(record *tunnelRecord, now time.Time, online bool) *gatewayv1.TunnelSummary { - if record == nil { - return &gatewayv1.TunnelSummary{Status: "expired"} - } - status := "active" - if record.closed || isTunnelExpired(record, now) { - status = "expired" - } else if !online { - status = "offline" - } - activeConnections := uint32(0) - if record.activeConnections > 0 { - activeConnections = uint32(record.activeConnections) - } - return &gatewayv1.TunnelSummary{ - Id: record.id, - Slug: record.slug, - Name: record.name, - TargetUrl: record.targetURL, - PublicUrl: record.publicURL, - CreatedAt: record.createdAt.Unix(), - ExpiresAt: tunnelUnix(record.expiresAt), - ActiveConnections: activeConnections, - Status: status, - ProjectPathKey: record.projectPathKey, - Diagnostics: cloneTunnelDiagnostics(record.diagnostics), - } -} - -func cloneTunnelSummary(summary *gatewayv1.TunnelSummary) *gatewayv1.TunnelSummary { - if summary == nil { - return nil - } - return &gatewayv1.TunnelSummary{ - Id: summary.GetId(), - Slug: summary.GetSlug(), - Name: summary.GetName(), - TargetUrl: summary.GetTargetUrl(), - PublicUrl: summary.GetPublicUrl(), - CreatedAt: summary.GetCreatedAt(), - ExpiresAt: summary.GetExpiresAt(), - ActiveConnections: summary.GetActiveConnections(), - Status: summary.GetStatus(), - ProjectPathKey: strings.TrimSpace(summary.GetProjectPathKey()), - Diagnostics: cloneTunnelDiagnostics(summary.GetDiagnostics()), - } -} - -func cloneTunnelDiagnostics(input []*gatewayv1.TunnelDiagnostic) []*gatewayv1.TunnelDiagnostic { - if len(input) == 0 { - return nil - } - out := make([]*gatewayv1.TunnelDiagnostic, 0, len(input)) - for _, item := range input { - if item == nil { - continue - } - out = append(out, &gatewayv1.TunnelDiagnostic{ - Protocol: strings.TrimSpace(item.GetProtocol()), - Status: strings.TrimSpace(item.GetStatus()), - StatusCode: item.GetStatusCode(), - ErrorCode: strings.TrimSpace(item.GetErrorCode()), - Message: strings.TrimSpace(item.GetMessage()), - CheckedAt: item.GetCheckedAt(), - }) - } - return out -} - -func sortTunnelSummaries(summaries []*gatewayv1.TunnelSummary) { - for i := 1; i < len(summaries); i++ { - current := summaries[i] - j := i - 1 - for j >= 0 && summaries[j].GetCreatedAt() > current.GetCreatedAt() { - summaries[j+1] = summaries[j] - j-- - } - summaries[j+1] = current - } -} - -func tunnelControlError(code string, message string) *gatewayv1.TunnelControlResponse { - return &gatewayv1.TunnelControlResponse{ - ErrorCode: strings.TrimSpace(code), - ErrorMessage: strings.TrimSpace(message), - } -} - -func tunnelControlErrorFor(action string, err error) *gatewayv1.TunnelControlResponse { - code := "failed" - switch { - case errors.Is(err, ErrTunnelNotFound): - code = "not_found" - case errors.Is(err, ErrTunnelExpired): - code = "expired" - case errors.Is(err, ErrTunnelLimitExceeded): - code = "limit_exceeded" - case errors.Is(err, ErrTunnelOverLimit): - code = "over_limit" - case errors.Is(err, ErrAgentOffline): - code = "agent_offline" - } - return &gatewayv1.TunnelControlResponse{ - Action: strings.TrimSpace(action), - ErrorCode: code, - ErrorMessage: err.Error(), - } -} diff --git a/crates/agent-gateway/internal/session/manager_tunnel_test.go b/crates/agent-gateway/internal/session/manager_tunnel_test.go deleted file mode 100644 index b61c4893..00000000 --- a/crates/agent-gateway/internal/session/manager_tunnel_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package session - -import ( - "errors" - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func onlineTunnelTestManager() *Manager { - manager := NewManager() - manager.SetSession(NewAgentSession(AuthSnapshot{ - AgentID: "agent-a", - AgentVersion: "test", - SessionID: "session-a", - })) - return manager -} - -func createTestTunnel(t *testing.T, manager *Manager, name string) *gatewayv1.TunnelSummary { - t.Helper() - tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: name, - TtlSeconds: 3600, - PublicBaseUrl: "https://gateway.example", - }) - if err != nil { - t.Fatalf("createTunnelFromAgent: %v", err) - } - if tunnel.GetSlug() == "" || tunnel.GetPublicUrl() == "" { - t.Fatalf("created tunnel missing slug/public URL: %+v", tunnel) - } - return tunnel -} - -func TestTunnelRegistryCreateLimitListAndClose(t *testing.T) { - manager := onlineTunnelTestManager() - - var first *gatewayv1.TunnelSummary - for i := 0; i < maxTunnelsPerAgent; i++ { - tunnel := createTestTunnel(t, manager, "app") - if i == 0 { - first = tunnel - } - } - - if _, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3001", - TtlSeconds: 3600, - PublicBaseUrl: "https://gateway.example", - }); !errors.Is(err, ErrTunnelLimitExceeded) { - t.Fatalf("expected ErrTunnelLimitExceeded, got %v", err) - } - - if got := len(manager.ListTunnels()); got != maxTunnelsPerAgent { - t.Fatalf("ListTunnels returned %d tunnels, want %d", got, maxTunnelsPerAgent) - } - - closed, err := manager.CloseTunnel(first.GetId()) - if err != nil { - t.Fatalf("CloseTunnel: %v", err) - } - if closed.GetStatus() != "expired" { - t.Fatalf("closed tunnel summary status = %q, want expired", closed.GetStatus()) - } - if got := len(manager.ListTunnels()); got != maxTunnelsPerAgent-1 { - t.Fatalf("ListTunnels after close returned %d tunnels, want %d", got, maxTunnelsPerAgent-1) - } -} - -func TestTunnelAcquireConnectionLimitAndRelease(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - leases := make([]*TunnelStreamLease, 0, maxTunnelConnections) - for i := 0; i < maxTunnelConnections; i++ { - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-"+string(rune('a'+i))) - if err != nil { - t.Fatalf("AcquireTunnel %d: %v", i, err) - } - leases = append(leases, lease) - } - if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-over-limit"); !errors.Is(err, ErrTunnelOverLimit) { - t.Fatalf("expected ErrTunnelOverLimit, got %v", err) - } - - leases[0].Release() - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-after-release") - if err != nil { - t.Fatalf("AcquireTunnel after release: %v", err) - } - lease.Release() - for _, item := range leases[1:] { - item.Release() - } - - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if got := summaries[0].GetActiveConnections(); got != 0 { - t.Fatalf("active connections after release = %d, want 0", got) - } -} - -func TestTunnelExpiredCannotBeAcquired(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - manager.tunnels.mu.Lock() - manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Now().Add(-time.Second) - manager.tunnels.mu.Unlock() - - if _, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-expired"); !errors.Is(err, ErrTunnelExpired) { - t.Fatalf("expected ErrTunnelExpired, got %v", err) - } - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if summaries[0].GetStatus() != "expired" { - t.Fatalf("expired tunnel status = %q, want expired", summaries[0].GetStatus()) - } -} - -func TestTunnelInfiniteTTLCreatesNonExpiringTunnel(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: "app", - TtlSeconds: 0, - PublicBaseUrl: "https://gateway.example", - }) - if err != nil { - t.Fatalf("createTunnelFromAgent with infinite TTL: %v", err) - } - if tunnel.GetExpiresAt() != 0 { - t.Fatalf("infinite tunnel expiresAt = %d, want 0", tunnel.GetExpiresAt()) - } - if tunnel.GetStatus() != "active" { - t.Fatalf("infinite tunnel status = %q, want active", tunnel.GetStatus()) - } - - manager.tunnels.mu.Lock() - manager.tunnels.tunnelsByID[tunnel.GetId()].expiresAt = time.Time{} - manager.tunnels.mu.Unlock() - - lease, err := manager.AcquireTunnel(tunnel.GetSlug(), "stream-infinite") - if err != nil { - t.Fatalf("AcquireTunnel for infinite tunnel: %v", err) - } - lease.Release() -} - -func TestTunnelUpdateChangesTargetNameScopeAndTTL(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - updated, err := manager.updateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: tunnel.GetId(), - TargetUrl: "http://127.0.0.1:4000/dashboard", - Name: "dashboard", - TtlSeconds: 0, - ProjectPathKey: "project:/tmp/liveagent", - }) - if err != nil { - t.Fatalf("updateTunnelFromAgent: %v", err) - } - if updated.GetName() != "dashboard" { - t.Fatalf("updated name = %q, want dashboard", updated.GetName()) - } - if updated.GetTargetUrl() != "http://127.0.0.1:4000/dashboard" { - t.Fatalf("updated target = %q", updated.GetTargetUrl()) - } - if updated.GetExpiresAt() != 0 { - t.Fatalf("updated expiresAt = %d, want 0", updated.GetExpiresAt()) - } - if updated.GetProjectPathKey() != "project:/tmp/liveagent" { - t.Fatalf("updated projectPathKey = %q", updated.GetProjectPathKey()) - } - - listed := manager.ListTunnels() - if len(listed) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) - } - if listed[0].GetId() != tunnel.GetId() || listed[0].GetTargetUrl() != updated.GetTargetUrl() { - t.Fatalf("ListTunnels did not include updated tunnel: %+v", listed[0]) - } -} - -func TestTunnelInfiniteTTLStaysActiveAndVisible(t *testing.T) { - manager := onlineTunnelTestManager() - - tunnel, err := manager.createTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "create", - TargetUrl: "http://localhost:3000/app", - Name: "app", - TtlSeconds: 0, - PublicBaseUrl: "https://gateway.example", - ProjectPathKey: "/workspace/app", - }) - if err != nil { - t.Fatalf("createTunnelFromAgent: %v", err) - } - if tunnel.GetExpiresAt() != 0 { - t.Fatalf("infinite tunnel expires_at = %d, want 0", tunnel.GetExpiresAt()) - } - if tunnel.GetProjectPathKey() != "/workspace/app" { - t.Fatalf("project_path_key = %q, want /workspace/app", tunnel.GetProjectPathKey()) - } - - summaries := manager.ListTunnels() - if len(summaries) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(summaries)) - } - if summaries[0].GetStatus() != "active" { - t.Fatalf("infinite tunnel status = %q, want active", summaries[0].GetStatus()) - } - if summaries[0].GetExpiresAt() != 0 { - t.Fatalf("listed infinite tunnel expires_at = %d, want 0", summaries[0].GetExpiresAt()) - } -} - -func TestTunnelUpdateChangesTargetNameTTLAndKeepsProjectScope(t *testing.T) { - manager := onlineTunnelTestManager() - tunnel := createTestTunnel(t, manager, "app") - - updated, err := manager.updateTunnelFromAgent(&gatewayv1.TunnelControlRequest{ - Action: "update", - TunnelId: tunnel.GetId(), - TargetUrl: "http://localhost:3000/next", - Name: "next", - TtlSeconds: 0, - ProjectPathKey: "/workspace/app", - }) - if err != nil { - t.Fatalf("updateTunnelFromAgent: %v", err) - } - if updated.GetTargetUrl() != "http://localhost:3000/next" { - t.Fatalf("target_url = %q, want http://localhost:3000/next", updated.GetTargetUrl()) - } - if updated.GetName() != "next" { - t.Fatalf("name = %q, want next", updated.GetName()) - } - if updated.GetExpiresAt() != 0 { - t.Fatalf("updated expires_at = %d, want 0", updated.GetExpiresAt()) - } - if updated.GetProjectPathKey() != "/workspace/app" { - t.Fatalf("project_path_key = %q, want /workspace/app", updated.GetProjectPathKey()) - } - - listed := manager.ListTunnels() - if len(listed) != 1 { - t.Fatalf("ListTunnels returned %d tunnels, want 1", len(listed)) - } - if listed[0].GetTargetUrl() != "http://localhost:3000/next" { - t.Fatalf("listed target_url = %q, want http://localhost:3000/next", listed[0].GetTargetUrl()) - } -} diff --git a/crates/agent-gateway/internal/session/tunnel_probe.go b/crates/agent-gateway/internal/session/tunnel_probe.go deleted file mode 100644 index 2d913fdc..00000000 --- a/crates/agent-gateway/internal/session/tunnel_probe.go +++ /dev/null @@ -1,221 +0,0 @@ -package session - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/gorilla/websocket" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -const TunnelProbeTimeout = 5 * time.Second - -func (m *Manager) ProbeTunnel( - ctx context.Context, - identifier string, - publicBaseURL string, -) (*gatewayv1.TunnelSummary, error) { - identifier = strings.TrimSpace(identifier) - if identifier == "" { - return nil, ErrTunnelNotFound - } - now := time.Now() - online := m.IsOnline() - m.tunnels.mu.Lock() - tunnelID := identifier - if bySlug := m.tunnels.tunnelIDBySlug[identifier]; bySlug != "" { - tunnelID = bySlug - } - record := m.tunnels.tunnelsByID[tunnelID] - if record == nil || record.closed { - m.tunnels.mu.Unlock() - return nil, ErrTunnelNotFound - } - if isTunnelExpired(record, now) { - m.tunnels.mu.Unlock() - return nil, ErrTunnelExpired - } - publicURL := strings.TrimSpace(record.publicURL) - if publicURL == "" { - publicURL = buildTunnelPublicURL(publicBaseURL, record.slug) - } - slug := record.slug - recordID := record.id - m.tunnels.mu.Unlock() - - diagnostics := ProbePublicTunnel(ctx, publicURL) - if updated, err := m.setTunnelDiagnostics(recordID, diagnostics); err == nil { - return updated, nil - } - m.tunnels.mu.Lock() - defer m.tunnels.mu.Unlock() - if record := m.tunnels.tunnelsByID[recordID]; record != nil && !record.closed { - record.diagnostics = cloneTunnelDiagnostics(diagnostics) - return tunnelSummaryLocked(record, time.Now(), online), nil - } - if slug != "" { - return &gatewayv1.TunnelSummary{ - Id: recordID, - Slug: slug, - PublicUrl: publicURL, - Status: "expired", - Diagnostics: diagnostics, - }, nil - } - return nil, ErrTunnelNotFound -} - -func ProbePublicTunnel(ctx context.Context, publicURL string) []*gatewayv1.TunnelDiagnostic { - checkedAt := time.Now().Unix() - return []*gatewayv1.TunnelDiagnostic{ - probePublicTunnelHTTP(ctx, publicURL, "http", checkedAt), - probePublicTunnelHTTP(ctx, publicURL, "sse", checkedAt), - probePublicTunnelWebSocket(ctx, publicURL, checkedAt), - } -} - -func probePublicTunnelHTTP( - ctx context.Context, - publicURL string, - protocol string, - checkedAt int64, -) *gatewayv1.TunnelDiagnostic { - probeURL, err := buildTunnelProbeURL(publicURL, protocol, false) - if err != nil { - return tunnelProbeDiagnostic(protocol, "failed", 0, "invalid_url", err.Error(), checkedAt) - } - reqCtx, cancel := context.WithTimeout(ctx, TunnelProbeTimeout) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, probeURL, nil) - if err != nil { - return tunnelProbeDiagnostic(protocol, "failed", 0, "invalid_url", err.Error(), checkedAt) - } - req.Header.Set("User-Agent", "LiveAgent-Tunnel-Probe/1") - client := http.Client{Timeout: TunnelProbeTimeout} - resp, err := client.Do(req) - if err != nil { - return tunnelProbeDiagnostic(protocol, "failed", 0, "network", err.Error(), checkedAt) - } - defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - if protocol == "sse" { - contentType := strings.ToLower(resp.Header.Get("Content-Type")) - if resp.StatusCode == http.StatusOK && - strings.Contains(contentType, "text/event-stream") && - strings.Contains(string(body), "liveagent-probe") { - return tunnelProbeDiagnostic(protocol, "ok", uint32(resp.StatusCode), "", "SSE probe succeeded", checkedAt) - } - return tunnelProbeDiagnostic( - protocol, - "failed", - uint32(resp.StatusCode), - "sse_unexpected_response", - fmt.Sprintf("SSE probe returned status %d and content-type %q", resp.StatusCode, resp.Header.Get("Content-Type")), - checkedAt, - ) - } - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return tunnelProbeDiagnostic(protocol, "ok", uint32(resp.StatusCode), "", "HTTP probe succeeded", checkedAt) - } - return tunnelProbeDiagnostic( - protocol, - "failed", - uint32(resp.StatusCode), - "http_status", - fmt.Sprintf("HTTP probe returned status %d", resp.StatusCode), - checkedAt, - ) -} - -func probePublicTunnelWebSocket( - ctx context.Context, - publicURL string, - checkedAt int64, -) *gatewayv1.TunnelDiagnostic { - probeURL, err := buildTunnelProbeURL(publicURL, "ws", true) - if err != nil { - return tunnelProbeDiagnostic("websocket", "failed", 0, "invalid_url", err.Error(), checkedAt) - } - dialCtx, cancel := context.WithTimeout(ctx, TunnelProbeTimeout) - defer cancel() - dialer := websocket.Dialer{HandshakeTimeout: TunnelProbeTimeout} - ws, resp, err := dialer.DialContext(dialCtx, probeURL, http.Header{ - "User-Agent": []string{"LiveAgent-Tunnel-Probe/1"}, - }) - if err != nil { - statusCode := uint32(0) - errorCode := "websocket_dial" - message := err.Error() - if resp != nil { - statusCode = uint32(resp.StatusCode) - contentType := strings.ToLower(resp.Header.Get("Content-Type")) - if resp.StatusCode == http.StatusOK && strings.Contains(contentType, "text/html") { - errorCode = "path_or_upgrade_missed" - message = "WebSocket probe returned a 200 HTML response; the request likely missed the tunnel websocket upgrade path or a proxy stripped Upgrade headers" - } else { - errorCode = "websocket_status" - message = fmt.Sprintf("WebSocket probe returned status %d", resp.StatusCode) - } - } - return tunnelProbeDiagnostic("websocket", "failed", statusCode, errorCode, message, checkedAt) - } - defer ws.Close() - deadline := time.Now().Add(TunnelProbeTimeout) - _ = ws.SetReadDeadline(deadline) - _ = ws.SetWriteDeadline(deadline) - if err := ws.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil { - return tunnelProbeDiagnostic("websocket", "failed", 0, "websocket_write", err.Error(), checkedAt) - } - messageType, body, err := ws.ReadMessage() - if err != nil { - return tunnelProbeDiagnostic("websocket", "failed", 0, "websocket_read", err.Error(), checkedAt) - } - if messageType == websocket.TextMessage && string(body) == "pong" { - return tunnelProbeDiagnostic("websocket", "ok", http.StatusSwitchingProtocols, "", "WebSocket probe succeeded", checkedAt) - } - return tunnelProbeDiagnostic("websocket", "failed", http.StatusSwitchingProtocols, "websocket_echo", "WebSocket probe did not receive the expected echo response", checkedAt) -} - -func buildTunnelProbeURL(publicURL string, protocol string, websocketURL bool) (string, error) { - parsed, err := url.Parse(strings.TrimSpace(publicURL)) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", fmt.Errorf("invalid tunnel public URL") - } - if websocketURL { - switch parsed.Scheme { - case "https": - parsed.Scheme = "wss" - case "http": - parsed.Scheme = "ws" - default: - return "", fmt.Errorf("unsupported tunnel public URL scheme %q", parsed.Scheme) - } - } - parsed.RawQuery = "" - parsed.Fragment = "" - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/.liveagent-tunnel-probe/" + protocol - return parsed.String(), nil -} - -func tunnelProbeDiagnostic( - protocol string, - status string, - statusCode uint32, - errorCode string, - message string, - checkedAt int64, -) *gatewayv1.TunnelDiagnostic { - return &gatewayv1.TunnelDiagnostic{ - Protocol: strings.TrimSpace(protocol), - Status: strings.TrimSpace(status), - StatusCode: statusCode, - ErrorCode: strings.TrimSpace(errorCode), - Message: strings.TrimSpace(message), - CheckedAt: checkedAt, - } -} diff --git a/crates/agent-gateway/internal/session/tunnel_probe_test.go b/crates/agent-gateway/internal/session/tunnel_probe_test.go deleted file mode 100644 index 9fbed44f..00000000 --- a/crates/agent-gateway/internal/session/tunnel_probe_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package session - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" -) - -func TestBuildTunnelProbeURLUsesTunnelPathAndWebSocketScheme(t *testing.T) { - t.Parallel() - - got, err := buildTunnelProbeURL("https://gateway.example/t/slug/", "ws", true) - if err != nil { - t.Fatalf("buildTunnelProbeURL: %v", err) - } - if got != "wss://gateway.example/t/slug/.liveagent-tunnel-probe/ws" { - t.Fatalf("probe URL = %q", got) - } -} - -func TestProbePublicTunnelWebSocketClassifiesHTML200AsPathOrUpgradeMiss(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("not a websocket")) - })) - defer server.Close() - - diagnostic := probePublicTunnelWebSocket(context.Background(), server.URL+"/t/slug/", 123) - if diagnostic.GetStatus() != "failed" { - t.Fatalf("status = %q, want failed", diagnostic.GetStatus()) - } - if diagnostic.GetStatusCode() != http.StatusOK { - t.Fatalf("status code = %d, want 200", diagnostic.GetStatusCode()) - } - if diagnostic.GetErrorCode() != "path_or_upgrade_missed" { - t.Fatalf("error code = %q, want path_or_upgrade_missed", diagnostic.GetErrorCode()) - } -} diff --git a/crates/agent-gateway/internal/session/tunnel_state.go b/crates/agent-gateway/internal/session/tunnel_state.go new file mode 100644 index 00000000..f5bb8de0 --- /dev/null +++ b/crates/agent-gateway/internal/session/tunnel_state.go @@ -0,0 +1,626 @@ +package session + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +const ( + maxTunnelsPerAgent = 5 + maxTunnelConnections = 20 + tunnelSlugEntropyBytes = 24 + tunnelStreamChannelDepth = 256 + tunnelAgentSendTimeout = 10 * time.Second + tunnelRelayProbeTimeout = 5 * time.Second + tunnelExpirySweepPeriod = 30 * time.Second +) + +var tunnelSlugPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`) + +// tunnelRuntime is the gateway-side runtime view of the agent's desired +// tunnel set: slug allocation, live streams, connection counts, and health. +// The desired specs themselves are owned and persisted by the agent. +type tunnelRuntime struct { + mu sync.Mutex + records map[string]*tunnelRecord + slugToID map[string]string + streams map[string]*tunnelStream + revision uint64 + relay *gatewayv1.TunnelHealth + + subMu sync.Mutex + nextSubID int + subscribers map[int]chan *gatewayv1.TunnelStateSnapshot + + pingMu sync.Mutex + pendingPings map[string]chan int64 +} + +type tunnelRecord struct { + id string + slug string + name string + targetURL string + projectPathKey string + createdAt time.Time + expiresAt time.Time + activeConnections int + local *gatewayv1.TunnelHealth +} + +type tunnelStream struct { + streamID string + tunnelID string + ch chan *gatewayv1.TunnelFrame + done chan struct{} + once sync.Once +} + +// TunnelStreamLease is one visitor connection's claim on a tunnel. +type TunnelStreamLease struct { + manager *Manager + stream *tunnelStream + slug string + targetURL string + once sync.Once +} + +func newTunnelRuntime() *tunnelRuntime { + return &tunnelRuntime{ + records: make(map[string]*tunnelRecord), + slugToID: make(map[string]string), + streams: make(map[string]*tunnelStream), + subscribers: make(map[int]chan *gatewayv1.TunnelStateSnapshot), + pendingPings: make(map[string]chan int64), + } +} + +func (s *tunnelStream) close() { + if s == nil { + return + } + s.once.Do(func() { + close(s.done) + }) +} + +func (l *TunnelStreamLease) TunnelID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.tunnelID +} + +func (l *TunnelStreamLease) Slug() string { + if l == nil { + return "" + } + return l.slug +} + +func (l *TunnelStreamLease) TargetURL() string { + if l == nil { + return "" + } + return l.targetURL +} + +func (l *TunnelStreamLease) StreamID() string { + if l == nil || l.stream == nil { + return "" + } + return l.stream.streamID +} + +func (l *TunnelStreamLease) Frames() <-chan *gatewayv1.TunnelFrame { + if l == nil || l.stream == nil { + return nil + } + return l.stream.ch +} + +func (l *TunnelStreamLease) Done() <-chan struct{} { + if l == nil || l.stream == nil { + return nil + } + return l.stream.done +} + +func (l *TunnelStreamLease) Release() { + if l == nil { + return + } + l.once.Do(func() { + l.manager.releaseTunnelStream(l.stream) + }) +} + +func (m *Manager) WebTunnelsEnabled() bool { + m.syncHub.settingsSnapshotMu.RLock() + defer m.syncHub.settingsSnapshotMu.RUnlock() + + remote, ok := m.syncHub.settingsSnapshot["remote"].(map[string]any) + if !ok { + return false + } + enabled, ok := remote["enableWebTunnels"].(bool) + return ok && enabled +} + +// ApplyDesiredState reconciles the runtime against the agent's full desired +// tunnel set: allocates slugs for new tunnels (honoring valid unused hints), +// updates changed ones, and drops removed ones (canceling their streams). +func (m *Manager) ApplyDesiredState(desired *gatewayv1.TunnelDesiredState) { + if desired == nil { + return + } + now := time.Now() + specs := desired.GetTunnels() + if len(specs) > maxTunnelsPerAgent { + specs = specs[:maxTunnelsPerAgent] + } + + var canceled []*tunnelStream + m.tunnels.mu.Lock() + seen := make(map[string]bool, len(specs)) + for _, spec := range specs { + id := strings.TrimSpace(spec.GetId()) + targetURL := strings.TrimSpace(spec.GetTargetUrl()) + if id == "" || targetURL == "" || seen[id] { + continue + } + expiresAt := time.Time{} + if spec.GetExpiresAt() > 0 { + expiresAt = time.Unix(spec.GetExpiresAt(), 0) + if !expiresAt.After(now) { + continue + } + } + seen[id] = true + record := m.tunnels.records[id] + if record == nil { + record = &tunnelRecord{ + id: id, + slug: m.allocateTunnelSlugLocked(spec.GetSlugHint()), + createdAt: now, + } + m.tunnels.records[id] = record + m.tunnels.slugToID[record.slug] = id + } + record.name = strings.TrimSpace(spec.GetName()) + record.targetURL = targetURL + record.projectPathKey = strings.TrimSpace(spec.GetProjectPathKey()) + record.expiresAt = expiresAt + } + for id, record := range m.tunnels.records { + if seen[id] { + continue + } + canceled = append(canceled, m.dropTunnelRecordLocked(record)...) + } + m.tunnels.mu.Unlock() + + m.cancelTunnelStreams(canceled) + m.broadcastTunnelState() + go m.probeRelay() +} + +// ApplyProbeReport merges agent-reported local-service health into the runtime. +func (m *Manager) ApplyProbeReport(report *gatewayv1.TunnelProbeReport) { + if report == nil || len(report.GetResults()) == 0 { + return + } + changed := false + m.tunnels.mu.Lock() + for _, result := range report.GetResults() { + record := m.tunnels.records[strings.TrimSpace(result.GetTunnelId())] + if record == nil || result.GetLocal() == nil { + continue + } + record.local = cloneTunnelHealth(result.GetLocal()) + changed = true + } + m.tunnels.mu.Unlock() + if changed { + m.broadcastTunnelState() + } +} + +// dropTunnelRecordLocked removes a record and returns its now-closed streams +// so CANCEL frames can be sent to the agent outside the lock. +func (m *Manager) dropTunnelRecordLocked(record *tunnelRecord) []*tunnelStream { + if record == nil { + return nil + } + delete(m.tunnels.records, record.id) + delete(m.tunnels.slugToID, record.slug) + var dropped []*tunnelStream + for streamID, stream := range m.tunnels.streams { + if stream == nil || stream.tunnelID != record.id { + continue + } + delete(m.tunnels.streams, streamID) + stream.close() + dropped = append(dropped, stream) + } + return dropped +} + +func (m *Manager) cancelTunnelStreams(streams []*tunnelStream) { + for _, stream := range streams { + _ = m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: stream.streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + }) + } +} + +func (m *Manager) allocateTunnelSlugLocked(hint string) string { + hint = strings.TrimSpace(hint) + if tunnelSlugPattern.MatchString(hint) { + if _, taken := m.tunnels.slugToID[hint]; !taken { + return hint + } + } + for { + slug := randomURLToken(tunnelSlugEntropyBytes) + if slug == "" { + // crypto/rand failure; fall back to a UUID-derived token. + slug = strings.ReplaceAll(uuid.NewString(), "-", "") + } + if _, taken := m.tunnels.slugToID[slug]; !taken { + return slug + } + } +} + +func randomURLToken(byteCount int) string { + if byteCount <= 0 { + return "" + } + buf := make([]byte, byteCount) + if _, err := rand.Read(buf); err != nil { + return "" + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +// TunnelStateSnapshot builds the authoritative state pushed to every client. +func (m *Manager) TunnelStateSnapshot() *gatewayv1.TunnelStateSnapshot { + online := m.IsOnline() + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + return m.tunnelStateSnapshotLocked(online) +} + +func (m *Manager) tunnelStateSnapshotLocked(online bool) *gatewayv1.TunnelStateSnapshot { + tunnels := make([]*gatewayv1.TunnelStatus, 0, len(m.tunnels.records)) + for _, record := range m.tunnels.records { + tunnels = append(tunnels, &gatewayv1.TunnelStatus{ + Id: record.id, + Slug: record.slug, + Name: record.name, + TargetUrl: record.targetURL, + PublicPath: "/t/" + record.slug + "/", + CreatedAt: record.createdAt.Unix(), + ExpiresAt: unixOrZero(record.expiresAt), + ActiveConnections: uint32(max(record.activeConnections, 0)), + ProjectPathKey: record.projectPathKey, + Local: cloneTunnelHealth(record.local), + }) + } + sort.Slice(tunnels, func(i, j int) bool { + if tunnels[i].GetCreatedAt() != tunnels[j].GetCreatedAt() { + return tunnels[i].GetCreatedAt() < tunnels[j].GetCreatedAt() + } + return tunnels[i].GetId() < tunnels[j].GetId() + }) + m.tunnels.revision += 1 + return &gatewayv1.TunnelStateSnapshot{ + Tunnels: tunnels, + Revision: m.tunnels.revision, + AgentOnline: online, + Relay: cloneTunnelHealth(m.tunnels.relay), + } +} + +func (m *Manager) SubscribeTunnelState() (<-chan *gatewayv1.TunnelStateSnapshot, func()) { + ch := make(chan *gatewayv1.TunnelStateSnapshot, 16) + + m.tunnels.subMu.Lock() + subID := m.tunnels.nextSubID + m.tunnels.nextSubID += 1 + m.tunnels.subscribers[subID] = ch + m.tunnels.subMu.Unlock() + + cleanup := func() { + m.tunnels.subMu.Lock() + // Do not close the channel: broadcastTunnelState sends after copying + // subscribers, so closing can race with an in-flight send. + delete(m.tunnels.subscribers, subID) + m.tunnels.subMu.Unlock() + } + return ch, cleanup +} + +// broadcastTunnelState pushes the current snapshot to /ws subscribers and to +// the agent (which persists allocated slugs and re-emits it to the GUI). +func (m *Manager) broadcastTunnelState() { + snapshot := m.TunnelStateSnapshot() + + m.tunnels.subMu.Lock() + subscribers := make([]chan *gatewayv1.TunnelStateSnapshot, 0, len(m.tunnels.subscribers)) + for _, ch := range m.tunnels.subscribers { + subscribers = append(subscribers, ch) + } + m.tunnels.subMu.Unlock() + + for _, ch := range subscribers { + select { + case ch <- snapshot: + default: + } + } + + // Best-effort, non-blocking: the agent only mines snapshots for allocated + // slugs and UI display, and a fresher snapshot follows every state change. + m.registry.mu.RLock() + session := m.registry.session + m.registry.mu.RUnlock() + if session != nil { + _, _ = session.TrySendToAgent(&gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-state-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelState{ + TunnelState: snapshot, + }, + }) + } +} + +// AcquireTunnel claims a visitor stream slot on the tunnel behind slug. +func (m *Manager) AcquireTunnel(slug string, streamID string) (*TunnelStreamLease, error) { + slug = strings.TrimSpace(slug) + streamID = strings.TrimSpace(streamID) + if slug == "" || streamID == "" { + return nil, ErrTunnelNotFound + } + if !m.IsOnline() { + return nil, ErrAgentOffline + } + now := time.Now() + + m.tunnels.mu.Lock() + defer m.tunnels.mu.Unlock() + + record := m.tunnels.records[m.tunnels.slugToID[slug]] + if record == nil { + return nil, ErrTunnelNotFound + } + if !record.expiresAt.IsZero() && !record.expiresAt.After(now) { + return nil, ErrTunnelExpired + } + if record.activeConnections >= maxTunnelConnections { + return nil, ErrTunnelOverLimit + } + stream := &tunnelStream{ + streamID: streamID, + tunnelID: record.id, + ch: make(chan *gatewayv1.TunnelFrame, tunnelStreamChannelDepth), + done: make(chan struct{}), + } + if existing := m.tunnels.streams[streamID]; existing != nil { + existing.close() + } + m.tunnels.streams[streamID] = stream + record.activeConnections += 1 + + return &TunnelStreamLease{ + manager: m, + stream: stream, + slug: record.slug, + targetURL: record.targetURL, + }, nil +} + +func (m *Manager) releaseTunnelStream(stream *tunnelStream) { + if stream == nil { + return + } + m.tunnels.mu.Lock() + if existing := m.tunnels.streams[stream.streamID]; existing == stream { + delete(m.tunnels.streams, stream.streamID) + } + if record := m.tunnels.records[stream.tunnelID]; record != nil && record.activeConnections > 0 { + record.activeConnections -= 1 + } + stream.close() + m.tunnels.mu.Unlock() +} + +func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { + if frame == nil { + return fmt.Errorf("tunnel frame is required") + } + ctx, cancel := context.WithTimeout(context.Background(), tunnelAgentSendTimeout) + defer cancel() + return m.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ + RequestId: "tunnel-frame-" + uuid.NewString(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.GatewayEnvelope_TunnelFrame{ + TunnelFrame: frame, + }, + }) +} + +// dispatchTunnelFrame routes an agent frame to its visitor stream. It runs on +// the agent gRPC read loop, so it must never block: a full stream channel +// closes the stream (the visitor handler cancels) instead of waiting. +func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { + if frame == nil { + return + } + if frame.GetKind() == gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PONG { + m.resolveRelayPong(frame.GetStreamId()) + return + } + streamID := strings.TrimSpace(frame.GetStreamId()) + if streamID == "" { + return + } + m.tunnels.mu.Lock() + stream := m.tunnels.streams[streamID] + m.tunnels.mu.Unlock() + if stream == nil { + return + } + select { + case <-stream.done: + case stream.ch <- frame: + default: + m.releaseTunnelStream(stream) + go func() { + _ = m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_CANCEL, + Error: "tunnel stream backlog exceeded", + }) + }() + } +} + +// probeRelay measures the gateway<->agent frame path with a PING/PONG round +// trip and folds the result into the broadcast snapshot. +func (m *Manager) probeRelay() { + checkedAt := time.Now() + health := &gatewayv1.TunnelHealth{Status: "failed", CheckedAt: checkedAt.Unix()} + + if !m.IsOnline() { + health.Error = "agent offline" + m.setRelayHealth(health) + return + } + + pingID := "ping-" + uuid.NewString() + pongCh := make(chan int64, 1) + m.tunnels.pingMu.Lock() + m.tunnels.pendingPings[pingID] = pongCh + m.tunnels.pingMu.Unlock() + defer func() { + m.tunnels.pingMu.Lock() + delete(m.tunnels.pendingPings, pingID) + m.tunnels.pingMu.Unlock() + }() + + if err := m.SendTunnelFrameToAgent(&gatewayv1.TunnelFrame{ + StreamId: pingID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PING, + }); err != nil { + health.Error = err.Error() + m.setRelayHealth(health) + return + } + + timer := time.NewTimer(tunnelRelayProbeTimeout) + defer timer.Stop() + select { + case <-pongCh: + health.Status = "ok" + health.RttMs = uint32(min(time.Since(checkedAt).Milliseconds(), int64(^uint32(0)))) + case <-timer.C: + health.Error = "relay probe timed out" + } + m.setRelayHealth(health) +} + +func (m *Manager) resolveRelayPong(streamID string) { + m.tunnels.pingMu.Lock() + ch := m.tunnels.pendingPings[strings.TrimSpace(streamID)] + delete(m.tunnels.pendingPings, strings.TrimSpace(streamID)) + m.tunnels.pingMu.Unlock() + if ch != nil { + select { + case ch <- time.Now().UnixMilli(): + default: + } + } +} + +func (m *Manager) setRelayHealth(health *gatewayv1.TunnelHealth) { + m.tunnels.mu.Lock() + m.tunnels.relay = health + m.tunnels.mu.Unlock() + m.broadcastTunnelState() +} + +// onAgentSessionCleared drops live visitor streams (their frames can no longer +// be relayed) and pushes an offline snapshot; the specs stay so `/t/*` answers +// 503 instead of 404 and clients keep rendering the tunnels as offline. +func (m *Manager) onAgentSessionCleared() { + m.tunnels.mu.Lock() + for streamID, stream := range m.tunnels.streams { + delete(m.tunnels.streams, streamID) + stream.close() + } + m.tunnels.relay = nil + m.tunnels.mu.Unlock() + m.broadcastTunnelState() +} + +func (m *Manager) tunnelExpirySweepLoop() { + ticker := time.NewTicker(tunnelExpirySweepPeriod) + defer ticker.Stop() + for range ticker.C { + m.sweepExpiredTunnels(time.Now()) + } +} + +func (m *Manager) sweepExpiredTunnels(now time.Time) { + var canceled []*tunnelStream + removed := false + m.tunnels.mu.Lock() + for _, record := range m.tunnels.records { + if record.expiresAt.IsZero() || record.expiresAt.After(now) { + continue + } + canceled = append(canceled, m.dropTunnelRecordLocked(record)...) + removed = true + } + m.tunnels.mu.Unlock() + + if !removed { + return + } + m.cancelTunnelStreams(canceled) + m.broadcastTunnelState() +} + +func cloneTunnelHealth(health *gatewayv1.TunnelHealth) *gatewayv1.TunnelHealth { + if health == nil { + return nil + } + return &gatewayv1.TunnelHealth{ + Status: health.GetStatus(), + HttpStatus: health.GetHttpStatus(), + Error: health.GetError(), + CheckedAt: health.GetCheckedAt(), + RttMs: health.GetRttMs(), + } +} + +func unixOrZero(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.Unix() +} diff --git a/crates/agent-gateway/internal/session/tunnel_state_test.go b/crates/agent-gateway/internal/session/tunnel_state_test.go new file mode 100644 index 00000000..d6c4b3dd --- /dev/null +++ b/crates/agent-gateway/internal/session/tunnel_state_test.go @@ -0,0 +1,275 @@ +package session + +import ( + "strings" + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func newTunnelTestManager(t *testing.T) *Manager { + t.Helper() + m := NewManager() + m.SetSession(NewAgentSession(AuthSnapshot{AgentID: "test-agent"})) + return m +} + +func desiredState(specs ...*gatewayv1.TunnelSpec) *gatewayv1.TunnelDesiredState { + return &gatewayv1.TunnelDesiredState{Tunnels: specs} +} + +func findTunnelStatus(snapshot *gatewayv1.TunnelStateSnapshot, id string) *gatewayv1.TunnelStatus { + for _, tunnel := range snapshot.GetTunnels() { + if tunnel.GetId() == id { + return tunnel + } + } + return nil +} + +func TestApplyDesiredStateAddUpdateRemove(t *testing.T) { + m := newTunnelTestManager(t) + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000", Name: "a"}, + &gatewayv1.TunnelSpec{Id: "tun-b", TargetUrl: "http://localhost:4000"}, + )) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 2 { + t.Fatalf("tunnels = %d, want 2", len(snapshot.GetTunnels())) + } + statusA := findTunnelStatus(snapshot, "tun-a") + if statusA == nil || statusA.GetSlug() == "" { + t.Fatalf("tun-a missing or has no slug: %#v", statusA) + } + if statusA.GetPublicPath() != "/t/"+statusA.GetSlug()+"/" { + t.Fatalf("public path = %q", statusA.GetPublicPath()) + } + slugA := statusA.GetSlug() + + // Update keeps the allocated slug; removal drops the record. + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3001", Name: "renamed"}, + )) + snapshot = m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("tunnels after removal = %d, want 1", len(snapshot.GetTunnels())) + } + statusA = findTunnelStatus(snapshot, "tun-a") + if statusA.GetSlug() != slugA { + t.Fatalf("slug changed across update: %q -> %q", slugA, statusA.GetSlug()) + } + if statusA.GetTargetUrl() != "http://localhost:3001" || statusA.GetName() != "renamed" { + t.Fatalf("update not applied: %#v", statusA) + } +} + +func TestApplyDesiredStateHonorsSlugHintAndCollision(t *testing.T) { + m := newTunnelTestManager(t) + hint := strings.Repeat("a", 32) + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000", SlugHint: hint}, + &gatewayv1.TunnelSpec{Id: "tun-b", TargetUrl: "http://localhost:4000", SlugHint: hint}, + )) + snapshot := m.TunnelStateSnapshot() + statusA := findTunnelStatus(snapshot, "tun-a") + statusB := findTunnelStatus(snapshot, "tun-b") + if statusA.GetSlug() != hint { + t.Fatalf("tun-a slug = %q, want hint %q", statusA.GetSlug(), hint) + } + if statusB.GetSlug() == hint || statusB.GetSlug() == "" { + t.Fatalf("tun-b slug should be freshly allocated, got %q", statusB.GetSlug()) + } + + // Invalid hints are ignored. + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-c", TargetUrl: "http://localhost:5000", SlugHint: "short"}, + )) + statusC := findTunnelStatus(m.TunnelStateSnapshot(), "tun-c") + if statusC.GetSlug() == "short" { + t.Fatal("invalid slug hint must not be honored") + } +} + +func TestApplyDesiredStateEnforcesTunnelCap(t *testing.T) { + m := newTunnelTestManager(t) + specs := make([]*gatewayv1.TunnelSpec, 0, maxTunnelsPerAgent+2) + for i := 0; i < maxTunnelsPerAgent+2; i++ { + specs = append(specs, &gatewayv1.TunnelSpec{ + Id: "tun-" + string(rune('a'+i)), + TargetUrl: "http://localhost:3000", + }) + } + m.ApplyDesiredState(desiredState(specs...)) + if got := len(m.TunnelStateSnapshot().GetTunnels()); got != maxTunnelsPerAgent { + t.Fatalf("tunnels = %d, want cap %d", got, maxTunnelsPerAgent) + } +} + +func TestApplyDesiredStateSkipsExpiredAndInvalidSpecs(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "expired", TargetUrl: "http://localhost:3000", ExpiresAt: time.Now().Add(-time.Minute).Unix()}, + &gatewayv1.TunnelSpec{Id: "", TargetUrl: "http://localhost:3000"}, + &gatewayv1.TunnelSpec{Id: "no-target"}, + &gatewayv1.TunnelSpec{Id: "ok", TargetUrl: "http://localhost:3000"}, + )) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 || findTunnelStatus(snapshot, "ok") == nil { + t.Fatalf("snapshot = %#v, want only \"ok\"", snapshot.GetTunnels()) + } +} + +func TestSnapshotRevisionIsMonotonic(t *testing.T) { + m := newTunnelTestManager(t) + first := m.TunnelStateSnapshot().GetRevision() + second := m.TunnelStateSnapshot().GetRevision() + if second <= first { + t.Fatalf("revision not monotonic: %d then %d", first, second) + } +} + +func TestAcquireTunnelLifecycleAndLimits(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + + if _, err := m.AcquireTunnel("missing", "s-1"); err != ErrTunnelNotFound { + t.Fatalf("acquire missing = %v, want ErrTunnelNotFound", err) + } + + leases := make([]*TunnelStreamLease, 0, maxTunnelConnections) + for i := 0; i < maxTunnelConnections; i++ { + lease, err := m.AcquireTunnel(slug, "s-"+string(rune('a'+i))) + if err != nil { + t.Fatalf("acquire %d: %v", i, err) + } + leases = append(leases, lease) + } + if _, err := m.AcquireTunnel(slug, "s-over"); err != ErrTunnelOverLimit { + t.Fatalf("over-limit acquire = %v, want ErrTunnelOverLimit", err) + } + if got := m.TunnelStateSnapshot().GetTunnels()[0].GetActiveConnections(); got != maxTunnelConnections { + t.Fatalf("active connections = %d, want %d", got, maxTunnelConnections) + } + for _, lease := range leases { + lease.Release() + } + if got := m.TunnelStateSnapshot().GetTunnels()[0].GetActiveConnections(); got != 0 { + t.Fatalf("active connections after release = %d, want 0", got) + } + + if lease, err := m.AcquireTunnel(slug, "s-again"); err != nil { + t.Fatalf("re-acquire after release: %v", err) + } else { + if lease.TargetURL() != "http://localhost:3000" { + t.Fatalf("lease target = %q", lease.TargetURL()) + } + lease.Release() + } + + m.ClearSession(mustCurrentSession(t, m)) + if _, err := m.AcquireTunnel(slug, "s-offline"); err != ErrAgentOffline { + t.Fatalf("offline acquire = %v, want ErrAgentOffline", err) + } +} + +func mustCurrentSession(t *testing.T, m *Manager) *AgentSession { + t.Helper() + m.registry.mu.RLock() + defer m.registry.mu.RUnlock() + if m.registry.session == nil { + t.Fatal("no current agent session") + } + return m.registry.session +} + +func TestDispatchTunnelFrameDropsStreamWhenBacklogged(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + lease, err := m.AcquireTunnel(slug, "s-backlog") + if err != nil { + t.Fatalf("acquire: %v", err) + } + defer lease.Release() + + for i := 0; i < tunnelStreamChannelDepth+1; i++ { + m.dispatchTunnelFrame(&gatewayv1.TunnelFrame{ + StreamId: "s-backlog", + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + }) + } + select { + case <-lease.Done(): + case <-time.After(time.Second): + t.Fatal("backlogged stream was not closed") + } +} + +func TestSweepExpiredTunnelsRemovesRecords(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "short", TargetUrl: "http://localhost:3000", ExpiresAt: time.Now().Add(30 * time.Second).Unix()}, + &gatewayv1.TunnelSpec{Id: "forever", TargetUrl: "http://localhost:4000"}, + )) + if got := len(m.TunnelStateSnapshot().GetTunnels()); got != 2 { + t.Fatalf("tunnels = %d, want 2", got) + } + m.sweepExpiredTunnels(time.Now().Add(2 * time.Minute)) + snapshot := m.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 || findTunnelStatus(snapshot, "forever") == nil { + t.Fatalf("after sweep = %#v, want only \"forever\"", snapshot.GetTunnels()) + } +} + +func TestOnAgentSessionClearedClosesStreamsAndMarksOffline(t *testing.T) { + m := newTunnelTestManager(t) + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + slug := m.TunnelStateSnapshot().GetTunnels()[0].GetSlug() + lease, err := m.AcquireTunnel(slug, "s-1") + if err != nil { + t.Fatalf("acquire: %v", err) + } + + m.ClearSession(mustCurrentSession(t, m)) + select { + case <-lease.Done(): + case <-time.After(time.Second): + t.Fatal("stream not closed after agent session cleared") + } + snapshot := m.TunnelStateSnapshot() + if snapshot.GetAgentOnline() { + t.Fatal("snapshot still reports agent online") + } + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("specs must survive agent disconnect, got %d", len(snapshot.GetTunnels())) + } +} + +func TestSubscribeTunnelStateReceivesBroadcasts(t *testing.T) { + m := newTunnelTestManager(t) + ch, cleanup := m.SubscribeTunnelState() + defer cleanup() + + m.ApplyDesiredState(desiredState( + &gatewayv1.TunnelSpec{Id: "tun-a", TargetUrl: "http://localhost:3000"}, + )) + + select { + case snapshot := <-ch: + if findTunnelStatus(snapshot, "tun-a") == nil { + t.Fatalf("broadcast snapshot missing tunnel: %#v", snapshot.GetTunnels()) + } + case <-time.After(time.Second): + t.Fatal("no tunnel.state broadcast received") + } +} diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 06fdd872..1d01985a 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -64,13 +64,16 @@ message GatewayEnvelope { FsReadEditableTextRequest fs_read_editable_text = 62; FsReadWorkspaceImageRequest fs_read_workspace_image = 63; SftpRequest sftp_request = 64; - TunnelControlRequest tunnel_control = 67; - TunnelControlResponse tunnel_control_resp = 68; - TunnelFrame tunnel_frame = 69; SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; ChatQueueRequest chat_queue = 73; ChatEventReplayRequest chat_event_replay = 74; + TunnelStateSnapshot tunnel_state = 80; + TunnelMutation tunnel_mutation = 81; + TunnelFrame tunnel_frame = 82; } + + // Legacy tunnel control/frame payloads (pre-rewrite protocol). + reserved 67, 68, 69; } message AgentEnvelope { @@ -121,16 +124,20 @@ message AgentEnvelope { SftpEvent sftp_event = 74; ChatQueueResponse chat_queue_resp = 75; ChatQueueEvent chat_queue_event = 76; - TunnelControlRequest tunnel_control = 67; - TunnelControlResponse tunnel_control_resp = 68; - TunnelFrame tunnel_frame = 69; ChatControlEvent chat_control = 70; RuntimeStatusEvent runtime_status = 71; SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; ChatRuntimeSnapshot chat_runtime_snapshot = 77; ChatEventReplayResponse chat_event_replay_resp = 78; + TunnelDesiredState tunnel_desired = 80; + TunnelMutationResult tunnel_mutation_result = 81; + TunnelFrame tunnel_frame = 82; + TunnelProbeReport tunnel_probe_report = 83; ErrorResponse error = 99; } + + // Legacy tunnel control/frame payloads (pre-rewrite protocol). + reserved 67, 68, 69; } message ChatSelectedModel { @@ -179,50 +186,82 @@ message UploadedImagePreviewResponse { string data = 2; } -message TunnelControlRequest { - string action = 1; - string tunnel_id = 2; - string slug = 3; - string target_url = 4; - string name = 5; - uint32 ttl_seconds = 6; - int64 expires_at = 7; - string public_url = 8; - string public_base_url = 9; - string project_path_key = 10; +// ---- Tunnel desired state (agent -> gateway) ---- + +message TunnelSpec { + string id = 1; // agent-generated, stable across restarts + string slug_hint = 2; // last allocated slug; gateway honors when valid and unused + string name = 3; + string target_url = 4; // http://localhost:PORT[/base] + int64 expires_at = 5; // unix seconds, 0 = never + string project_path_key = 6; } -message TunnelControlResponse { - string action = 1; - repeated TunnelSummary tunnels = 2; - TunnelSummary tunnel = 3; - string error_code = 4; - string error_message = 5; +message TunnelDesiredState { + repeated TunnelSpec tunnels = 1; + uint64 revision = 2; // agent-side monotonic +} + +// ---- Tunnel runtime snapshot (gateway -> agent, mirrored to /ws JSON) ---- + +message TunnelHealth { + string status = 1; // "ok" | "failed" | "unknown" + uint32 http_status = 2; // local layer only + string error = 3; + int64 checked_at = 4; + uint32 rtt_ms = 5; // relay layer only } -message TunnelSummary { +message TunnelStatus { string id = 1; string slug = 2; string name = 3; string target_url = 4; - string public_url = 5; + string public_path = 5; // "/t/{slug}/"; clients compose the full URL int64 created_at = 6; int64 expires_at = 7; uint32 active_connections = 8; - string status = 9; - string project_path_key = 10; - repeated TunnelDiagnostic diagnostics = 11; + string project_path_key = 9; + TunnelHealth local = 10; // agent -> local service reachability +} + +message TunnelStateSnapshot { + repeated TunnelStatus tunnels = 1; + uint64 revision = 2; // gateway-side monotonic + bool agent_online = 3; + TunnelHealth relay = 4; // gateway <-> agent frame path +} + +// ---- Tunnel mutations forwarded gateway -> agent (webui-originated) ---- + +message TunnelMutation { + string action = 1; // "create" | "update" | "close" | "check" + string tunnel_id = 2; // update/close/check + string target_url = 3; + string name = 4; + optional uint32 ttl_seconds = 5; // absent on update = keep current expiry + string project_path_key = 6; +} + +message TunnelMutationResult { + string tunnel_id = 1; + string error_code = 2; // "" = ok; invalid_target|limit_exceeded|not_found|invalid_ttl + string error_message = 3; } -message TunnelDiagnostic { - string protocol = 1; - string status = 2; - uint32 status_code = 3; - string error_code = 4; - string message = 5; - int64 checked_at = 6; +// ---- Tunnel local-service probe results (agent -> gateway) ---- + +message TunnelProbeResult { + string tunnel_id = 1; + TunnelHealth local = 2; +} + +message TunnelProbeReport { + repeated TunnelProbeResult results = 1; } +// ---- Tunnel data plane ---- + message TunnelHeader { string name = 1; string value = 2; @@ -243,22 +282,30 @@ enum TunnelFrameKind { TUNNEL_FRAME_KIND_WS_CLOSE = 11; TUNNEL_FRAME_KIND_ERROR = 12; TUNNEL_FRAME_KIND_CANCEL = 13; + TUNNEL_FRAME_KIND_PING = 14; + TUNNEL_FRAME_KIND_PONG = 15; +} + +enum TunnelWsMessageType { + TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED = 0; + TUNNEL_WS_MESSAGE_TYPE_TEXT = 1; + TUNNEL_WS_MESSAGE_TYPE_BINARY = 2; } message TunnelFrame { string stream_id = 1; - string tunnel_id = 2; - string slug = 3; - TunnelFrameKind kind = 4; - string method = 5; - string path = 6; - repeated TunnelHeader headers = 7; - uint32 status_code = 8; - bytes body = 9; - bool end_stream = 10; - string error = 11; - string ws_message_type = 12; - string ws_protocol = 13; + TunnelFrameKind kind = 2; + string target_url = 3; // set on HTTP_REQUEST_START and WS_DIAL only + string method = 4; + string path = 5; // path+query relative to the target base + repeated TunnelHeader headers = 6; + uint32 status = 7; + bytes body = 8; + string error = 9; + TunnelWsMessageType ws_message_type = 10; + string ws_subprotocol = 11; + uint32 ws_close_code = 12; + string ws_close_reason = 13; } message MemoryManageRequest { diff --git a/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go new file mode 100644 index 00000000..1cddc8ba --- /dev/null +++ b/crates/agent-gateway/test/tunnel/tunnel_e2e_test.go @@ -0,0 +1,373 @@ +package tunnel_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/server" + "github.com/liveagent/agent-gateway/internal/session" +) + +// fakeAgent emulates the desktop agent's data-plane behavior in-process: it +// drains the session outbound queue and answers tunnel frames the way the +// Rust proxy does (HTTP echo, SSE stream, WS echo, PONG). +type fakeAgent struct { + sm *session.Manager + sess *session.AgentSession + done chan struct{} + once sync.Once +} + +func startFakeAgent(t *testing.T) (*session.Manager, *fakeAgent) { + t.Helper() + sm := session.NewManager() + sess := session.NewAgentSession(session.AuthSnapshot{AgentID: "fake-agent"}) + sm.SetSession(sess) + agent := &fakeAgent{sm: sm, sess: sess, done: make(chan struct{})} + go agent.run() + t.Cleanup(agent.stop) + return sm, agent +} + +func (a *fakeAgent) stop() { + a.once.Do(func() { close(a.done) }) +} + +func (a *fakeAgent) run() { + for { + select { + case <-a.done: + return + case outbound := <-a.sess.Outbound(): + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + outbound.Ack(nil) + frame := outbound.GatewayEnvelope.GetTunnelFrame() + if frame == nil { + continue + } + a.handleFrame(frame) + } + } +} + +func (a *fakeAgent) reply(frame *gatewayv1.TunnelFrame) { + a.sm.DispatchFromAgentForSession(a.sess, &gatewayv1.AgentEnvelope{ + RequestId: "fake-agent-frame", + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_TunnelFrame{TunnelFrame: frame}, + }) +} + +func (a *fakeAgent) handleFrame(frame *gatewayv1.TunnelFrame) { + streamID := frame.GetStreamId() + switch frame.GetKind() { + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PING: + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_PONG, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START: + if strings.HasPrefix(frame.GetPath(), "/sse") { + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/event-stream; charset=utf-8"}, + }, + }) + for _, chunk := range []string{"event: tick\ndata: 1\n\n", "event: tick\ndata: 2\n\n"} { + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte(chunk), + }) + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + return + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/plain; charset=utf-8"}, + }, + }) + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte("hello " + frame.GetMethod() + " " + frame.GetPath()), + }) + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL: + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_DIAL_OK, + }) + case gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME: + if string(frame.GetBody()) == "close-me" { + // Emulate the local service closing the socket with its own code. + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_CLOSE, + WsCloseCode: 4321, + WsCloseReason: "goodbye", + }) + return + } + a.reply(&gatewayv1.TunnelFrame{ + StreamId: streamID, + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_WS_FRAME, + Body: append([]byte("echo:"), frame.GetBody()...), + WsMessageType: frame.GetWsMessageType(), + }) + } +} + +func startTunnelTestServer(t *testing.T, sm *session.Manager) *httptest.Server { + t.Helper() + handler := server.NewHTTPServer(&config.Config{ + Token: "dev-token", + RequestTimeout: 2 * time.Second, + }, sm) + ts := httptest.NewServer(handler) + t.Cleanup(ts.Close) + return ts +} + +func applyOneTunnel(t *testing.T, sm *session.Manager) string { + t.Helper() + sm.ApplyDesiredState(&gatewayv1.TunnelDesiredState{ + Tunnels: []*gatewayv1.TunnelSpec{ + {Id: "tun-e2e", TargetUrl: "http://localhost:3999", Name: "e2e"}, + }, + }) + snapshot := sm.TunnelStateSnapshot() + if len(snapshot.GetTunnels()) != 1 { + t.Fatalf("tunnels = %d, want 1", len(snapshot.GetTunnels())) + } + slug := snapshot.GetTunnels()[0].GetSlug() + if slug == "" { + t.Fatal("no slug allocated") + } + return slug +} + +func TestTunnelEndToEndHTTP(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/app/page?x=1") + if err != nil { + t.Fatalf("GET through tunnel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d body=%s", resp.StatusCode, body) + } + if got := string(body); got != "hello GET /app/page?x=1" { + t.Fatalf("body = %q", got) + } +} + +func TestTunnelEndToEndSSEStreams(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/sse") + if err != nil { + t.Fatalf("GET sse through tunnel: %v", err) + } + defer resp.Body.Close() + if contentType := resp.Header.Get("Content-Type"); !strings.Contains(contentType, "text/event-stream") { + t.Fatalf("content-type = %q", contentType) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "data: 1") || !strings.Contains(string(body), "data: 2") { + t.Fatalf("sse body = %q", body) + } +} + +func TestTunnelEndToEndWebSocketEchoAndClose(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/t/" + slug + "/socket" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("dial tunnel websocket: %v", err) + } + defer conn.Close() + + if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil { + t.Fatalf("write: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + messageType, body, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read echo: %v", err) + } + if messageType != websocket.TextMessage || string(body) != "echo:ping" { + t.Fatalf("echo = (%d, %q)", messageType, body) + } + + // Upstream-initiated close: the local service's close code/reason must + // reach the visitor verbatim through the frame relay. + if err := conn.WriteMessage(websocket.TextMessage, []byte("close-me")); err != nil { + t.Fatalf("write close-me: %v", err) + } + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err = conn.ReadMessage() + closeErr, ok := err.(*websocket.CloseError) + if !ok { + t.Fatalf("expected close error, got %v", err) + } + if closeErr.Code != 4321 || closeErr.Text != "goodbye" { + t.Fatalf("close = (%d, %q), want (4321, goodbye)", closeErr.Code, closeErr.Text) + } +} + +// TestTunnelTrafficWhileControlPlaneBusy is the deadlock regression: the old +// design executed probes synchronously on the agent read loop, so any control +// activity starved the data plane. The new design must serve traffic while +// desired-state applies and relay probes run concurrently. +func TestTunnelTrafficWhileControlPlaneBusy(t *testing.T) { + sm, _ := startFakeAgent(t) + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + stop := make(chan struct{}) + var controlWG sync.WaitGroup + controlWG.Add(1) + go func() { + defer controlWG.Done() + for { + select { + case <-stop: + return + default: + sm.ApplyDesiredState(&gatewayv1.TunnelDesiredState{ + Tunnels: []*gatewayv1.TunnelSpec{ + {Id: "tun-e2e", TargetUrl: "http://localhost:3999", Name: "e2e", SlugHint: slug}, + }, + }) + } + } + }() + + deadline := time.Now().Add(2 * time.Second) + requests := 0 + for time.Now().Before(deadline) { + client := http.Client{Timeout: time.Second} + resp, err := client.Get(ts.URL + "/t/" + slug + "/") + if err != nil { + close(stop) + controlWG.Wait() + t.Fatalf("request %d during control churn: %v", requests, err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + close(stop) + controlWG.Wait() + t.Fatalf("request %d status = %d", requests, resp.StatusCode) + } + requests += 1 + } + close(stop) + controlWG.Wait() + if requests < 10 { + t.Fatalf("only %d requests completed during control churn", requests) + } +} + +func TestTunnelHTMLRewriteInjectsShimAndDropsContentLength(t *testing.T) { + sm := session.NewManager() + sess := session.NewAgentSession(session.AuthSnapshot{AgentID: "fake-agent"}) + sm.SetSession(sess) + agent := &fakeAgent{sm: sm, sess: sess, done: make(chan struct{})} + t.Cleanup(agent.stop) + + html := "x" + go func() { + for { + select { + case <-agent.done: + return + case outbound := <-sess.Outbound(): + if outbound == nil || outbound.GatewayEnvelope == nil { + continue + } + outbound.Ack(nil) + frame := outbound.GatewayEnvelope.GetTunnelFrame() + if frame == nil || + frame.GetKind() != gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_REQUEST_START { + continue + } + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_START, + Status: 200, + Headers: []*gatewayv1.TunnelHeader{ + {Name: "Content-Type", Value: "text/html; charset=utf-8"}, + {Name: "Content-Length", Value: "999"}, + {Name: "Content-Security-Policy", Value: "script-src 'self'"}, + }, + }) + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_BODY, + Body: []byte(html), + }) + agent.reply(&gatewayv1.TunnelFrame{ + StreamId: frame.GetStreamId(), + Kind: gatewayv1.TunnelFrameKind_TUNNEL_FRAME_KIND_HTTP_RESPONSE_END, + }) + } + } + }() + + ts := startTunnelTestServer(t, sm) + slug := applyOneTunnel(t, sm) + + resp, err := http.Get(ts.URL + "/t/" + slug + "/") + if err != nil { + t.Fatalf("GET html through tunnel: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.Header.Get("Content-Length") == "999" { + t.Fatal("stale Content-Length must be dropped for rewritten responses") + } + if !strings.Contains(string(body), "data-liveagent-tunnel-shim") { + t.Fatalf("shim not injected: %q", body) + } + if !strings.Contains(string(body), "/t/"+slug+"/about") { + t.Fatalf("href not rewritten: %q", body) + } + if policy := resp.Header.Get("Content-Security-Policy"); !strings.Contains(policy, "'sha256-") { + t.Fatalf("CSP not hash-amended: %q", policy) + } +} diff --git a/crates/agent-gateway/test/websocket/terminal_test.go b/crates/agent-gateway/test/websocket/terminal_test.go index ab98f669..ea49e8d6 100644 --- a/crates/agent-gateway/test/websocket/terminal_test.go +++ b/crates/agent-gateway/test/websocket/terminal_test.go @@ -39,17 +39,23 @@ func receiveNoTerminalEnvelope(t *testing.T, _ *websocket.Conn) { func assertNoTerminalEnvelopeAtEnd(t *testing.T, conn *websocket.Conn) { t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { - t.Fatalf("set websocket short deadline: %v", err) - } - var env wsEnvelope - err := conn.ReadJSON(&env) - if err == nil { - t.Fatalf("unexpected websocket envelope = %#v", env) - } - var netErr net.Error - if !errors.As(err, &netErr) || !netErr.Timeout() { - t.Fatalf("receive websocket envelope returned %v, want timeout", err) + for { + if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { + t.Fatalf("set websocket short deadline: %v", err) + } + var env wsEnvelope + err := conn.ReadJSON(&env) + if err == nil { + if env.Type == "tunnel.state" { + continue + } + t.Fatalf("unexpected websocket envelope = %#v", env) + } + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("receive websocket envelope returned %v, want timeout", err) + } + return } } diff --git a/crates/agent-gateway/test/websocket/websocket_helpers_test.go b/crates/agent-gateway/test/websocket/websocket_helpers_test.go index bf797ddb..9592f0cf 100644 --- a/crates/agent-gateway/test/websocket/websocket_helpers_test.go +++ b/crates/agent-gateway/test/websocket/websocket_helpers_test.go @@ -56,14 +56,21 @@ func sendEnvelope(t *testing.T, conn *websocket.Conn, id string, typ string, pay func receiveEnvelope(t *testing.T, conn *websocket.Conn) wsEnvelope { t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - t.Fatalf("receive websocket envelope: %v", err) + for { + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set websocket read deadline: %v", err) + } + var env wsEnvelope + if err := conn.ReadJSON(&env); err != nil { + t.Fatalf("receive websocket envelope: %v", err) + } + // tunnel.state is broadcast on auth and on unrelated state changes; + // tests assert on the envelopes they explicitly provoke. + if env.Type == "tunnel.state" { + continue + } + return env } - return env } func receiveEnvelopeWithID(t *testing.T, conn *websocket.Conn, id string) wsEnvelope { diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 842fb758..44d0a353 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -350,7 +350,6 @@ export default function GatewayApp() { const [userMenuOpen, setUserMenuOpen] = useState(false); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [rightDockOpen, setRightDockOpen] = useState(false); - const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const { confirm: requestConfirmDialog, dialog: confirmDialog } = useConfirmDialog(); const { scrollAreaRef: transcriptScrollAreaRef, @@ -974,11 +973,12 @@ export default function GatewayApp() { [activeWorkspaceProjectPath, setSettings], ); + // Tunnel list refreshes arrive through the tunnel.state push; the chat + // event only opens the tunnel tool tab when the agent creates a tunnel. const handleTunnelManagerChatEvent = useCallback( (event: ChatEvent) => { const change = readTunnelManagerToolChange(event); if (!change) return; - setTunnelRefreshToken((current) => current + 1); if (change.action === "create") { ensureTunnelToolTab(change.projectPathKey); } @@ -3759,15 +3759,15 @@ export default function GatewayApp() { const gitDisabledMessage = !settings.remote.enableWebGit ? "WebUI Git is disabled in desktop Remote settings." : undefined; - const tunnelEnabled = - settingsSyncReady && settings.remote.enableWebTunnels === true && status?.online === true; + // Agent offline no longer disables the panel: state still renders (the + // Link badge shows offline) and mutations fail server-side with a clear + // message. + const tunnelEnabled = settingsSyncReady && settings.remote.enableWebTunnels === true; const tunnelDisabledMessage = !settingsSyncReady ? translate("chat.runtime.tunnelSettingsSyncing", settings.locale) : !settings.remote.enableWebTunnels ? translate("projectTools.tunnelWebDisabled", settings.locale) - : status?.online !== true - ? translate("projectTools.tunnelRemoteOffline", settings.locale) - : undefined; + : undefined; useEffect(() => { if (activeView !== "chat") { return; @@ -4620,7 +4620,7 @@ export default function GatewayApp() { tunnelClient={isAgentMode ? api : null} tunnelEnabled={tunnelEnabled} tunnelDisabledMessage={tunnelDisabledMessage} - tunnelRefreshToken={tunnelRefreshToken} + tunnelPublicBaseUrl={window.location.origin} onWidthChange={(nextWidth) => setSettings((prev) => updateRightDockWidth(prev, nextWidth)) } diff --git a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx index a7415953..0be302c3 100644 --- a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx @@ -1,12 +1,19 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocale } from "@/i18n"; -import { workspaceProjectPathKey } from "@/lib/settings"; -import { cn } from "@/lib/shared/utils"; -import type { - TunnelCreateInput, - TunnelSummary as GatewayTunnelSummary, - TunnelUpdateInput, -} from "@/lib/gatewaySocket"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useLocale } from "../../i18n"; +import { workspaceProjectPathKey } from "../../lib/settings"; +import { cn } from "../../lib/shared/utils"; +import { + composePublicUrl, + type LocalTunnelClient, + TUNNEL_TTL_OPTIONS, + type TunnelCreateInput, + type TunnelHealth, + type TunnelStateSnapshot, + type TunnelStatus, + type TunnelTtlSeconds, + type TunnelUpdateInput, + validateLocalHttpTarget, +} from "../../lib/tunnels/constants"; import { AlertTriangle, Check, @@ -28,31 +35,26 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; -export type { TunnelCreateInput }; -export type TunnelSummary = Omit; -type TunnelDiagnostic = NonNullable[number]; - -export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; - -export type LocalTunnelClient = { - listTunnels(): Promise; - createTunnel(input: TunnelCreateInput): Promise; - updateTunnel(input: TunnelUpdateInput): Promise; - probeTunnel(id: string): Promise; - closeTunnel(id: string): Promise; -}; +export type { LocalTunnelClient } from "../../lib/tunnels/constants"; type LocalTunnelPanelProps = { - client: LocalTunnelClient; - enabled?: boolean; + client: LocalTunnelClient | null; + enabled: boolean; disabledMessage?: string; projectPathKey?: string; - refreshToken?: number; + publicBaseUrl: string; + onOpenExternal?: (url: string) => void; }; type TunnelScope = "project" | "global"; -const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; +type TunnelRowAction = "save" | "close" | "check"; + +type HealthDisplayStatus = TunnelHealth["status"]; + +// "keep" leaves ttl_seconds out of tunnel.update so the current expiry is +// preserved instead of silently re-bucketing it. +type EditTtlValue = TunnelTtlSeconds | "keep"; const TUNNEL_SCOPE_OPTIONS: Array<{ scope: TunnelScope; @@ -71,49 +73,43 @@ const TUNNEL_SCOPE_OPTIONS: Array<{ }, ]; -const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ - { value: 900, labelKey: "projectTools.tunnelTtl15m" }, - { value: 3600, labelKey: "projectTools.tunnelTtl1h" }, - { value: 14400, labelKey: "projectTools.tunnelTtl4h" }, - { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, -]; - -const TUNNEL_DIAGNOSTIC_PROTOCOLS: TunnelDiagnostic["protocol"][] = [ - "http", - "websocket", - "sse", -]; - const TUNNEL_INPUT_CLASS = "h-8 min-w-0 rounded-lg border-border/60 bg-background/80 text-[11px] placeholder:text-[11px] transition-[border-color,box-shadow,background-color] focus-visible:border-muted-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-muted-foreground/15 focus-visible:ring-offset-0"; +function ttlLabelKey(value: TunnelTtlSeconds) { + if (value === 900) return "projectTools.tunnelTtl15m"; + if (value === 3600) return "projectTools.tunnelTtl1h"; + if (value === 14400) return "projectTools.tunnelTtl4h"; + return "projectTools.tunnelTtlInfinite"; +} + function TtlSegmented({ value, onChange, disabled, }: { - value: TunnelTtlSeconds; + value: TunnelTtlSeconds | null; onChange: (value: TunnelTtlSeconds) => void; disabled?: boolean; }) { const { t } = useLocale(); return (
- {TTL_OPTIONS.map((option) => { - const active = value === option.value; + {TUNNEL_TTL_OPTIONS.map((option) => { + const active = value === option; return ( ); })} @@ -121,44 +117,46 @@ function TtlSegmented({ ); } -function normalizeTunnelHostname(hostname: string) { - return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); -} - -function isIpv4Address(hostname: string) { - const parts = hostname.split("."); - if (parts.length !== 4) return false; - return parts.every((part) => { - if (!/^\d+$/.test(part)) return false; - const value = Number(part); - return value >= 0 && value <= 255 && String(value) === part; - }); +function healthStatusLabelKey(status: HealthDisplayStatus) { + if (status === "ok") return "projectTools.tunnelHealthOk"; + if (status === "failed") return "projectTools.tunnelHealthFailed"; + return "projectTools.tunnelHealthUnknown"; } -function isIpAddress(hostname: string) { - if (isIpv4Address(hostname)) return true; - return hostname.includes(":"); -} - -function validateLocalHttpTarget(input: string) { - const value = input.trim(); - if (!value) return "projectTools.tunnelTargetRequired"; - try { - const url = new URL(value); - if (url.protocol !== "http:") { - return "projectTools.tunnelInvalidUrl"; - } - const hostname = normalizeTunnelHostname(url.hostname); - if (hostname !== "localhost" && !isIpAddress(hostname)) { - return "projectTools.tunnelLocalhostOnly"; - } - if (url.username || url.password || url.hash) { - return "projectTools.tunnelInvalidUrl"; - } - } catch { - return "projectTools.tunnelInvalidUrl"; - } - return null; +function HealthBadge({ + label, + status, + title, +}: { + label: string; + status: HealthDisplayStatus; + title?: string; +}) { + return ( + + + {label} + + ); } function asErrorMessage(error: unknown) { @@ -205,43 +203,21 @@ function fallbackWriteTextToClipboard(text: string) { } } -function displayTunnelName(tunnel: TunnelSummary) { +function displayTunnelName(tunnel: TunnelStatus) { return tunnel.name.trim() || tunnel.targetUrl; } -function tunnelStatusKey(status: TunnelSummary["status"]) { - if (status === "expired") return "projectTools.tunnelStatusExpired"; - if (status === "offline") return "projectTools.tunnelStatusOffline"; - return "projectTools.tunnelStatusActive"; -} - function normalizeProjectPathKey(value: string | undefined) { return workspaceProjectPathKey(value ?? ""); } -function ttlFromTunnel(tunnel: TunnelSummary, nowSeconds: number): TunnelTtlSeconds { - if (!tunnel.expiresAt) return 0; - const remaining = Math.max(0, tunnel.expiresAt - nowSeconds); - if (remaining <= 900) return 900; - if (remaining <= 3600) return 3600; - return 14400; -} - -function diagnosticFor(tunnel: TunnelSummary, protocol: TunnelDiagnostic["protocol"]) { - return tunnel.diagnostics?.find((item) => item.protocol === protocol); -} - -function diagnosticLabel(protocol: TunnelDiagnostic["protocol"]) { - if (protocol === "websocket") return "WS"; - return protocol.toUpperCase(); -} - export function LocalTunnelPanel({ client, - enabled = true, + enabled, disabledMessage, projectPathKey, - refreshToken, + publicBaseUrl, + onOpenExternal, }: LocalTunnelPanelProps) { const { t } = useLocale(); const normalizedProjectPathKey = useMemo( @@ -255,78 +231,52 @@ export function LocalTunnelPanel({ const [name, setName] = useState(""); const [ttlSeconds, setTtlSeconds] = useState(3600); const [createOpen, setCreateOpen] = useState(true); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); const [editingId, setEditingId] = useState(""); const [editTargetUrl, setEditTargetUrl] = useState(""); const [editName, setEditName] = useState(""); - const [editTtlSeconds, setEditTtlSeconds] = useState(3600); - const [tunnels, setTunnels] = useState([]); - const [loading, setLoading] = useState(false); - const [creating, setCreating] = useState(false); - const [savingId, setSavingId] = useState(""); - const [closingId, setClosingId] = useState(""); - const [probingId, setProbingId] = useState(""); + const [editTtlSeconds, setEditTtlSeconds] = useState("keep"); + const [snapshot, setSnapshot] = useState(null); + const [pendingActions, setPendingActions] = useState>({}); + const [rowErrors, setRowErrors] = useState>({}); + const [checkingAll, setCheckingAll] = useState(false); const [copiedId, setCopiedId] = useState(""); - const [error, setError] = useState(null); const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000)); - const refreshTokenRef = useRef(refreshToken); const targetValidationKey = useMemo(() => validateLocalHttpTarget(targetUrl), [targetUrl]); const editTargetValidationKey = useMemo( () => (editingId ? validateLocalHttpTarget(editTargetUrl) : null), [editTargetUrl, editingId], ); - const refresh = useCallback( - (options?: { showLoading?: boolean }) => { - const showLoading = options?.showLoading ?? true; - if (showLoading) { - setLoading(true); - } - setError(null); - return client - .listTunnels() - .then((items) => setTunnels(items)) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => { - if (showLoading) { - setLoading(false); - } - }); - }, - [client], - ); - useEffect(() => { - void refresh(); - }, [refresh]); - - useEffect(() => { - if (refreshTokenRef.current === refreshToken) return; - refreshTokenRef.current = refreshToken; - void refresh({ showLoading: false }); - }, [refresh, refreshToken]); - - useEffect(() => { - const handleTunnelManagerChanged = () => { - void refresh({ showLoading: false }); - }; - window.addEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); - return () => - window.removeEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); - }, [refresh]); + if (!client) return; + return client.subscribeTunnelState((next) => { + setSnapshot((current) => (current && next.revision <= current.revision ? current : next)); + }); + }, [client]); useEffect(() => { if (!normalizedProjectPathKey && scope === "project") { setScope("global"); - setError(null); + setCreateError(null); } }, [normalizedProjectPathKey, scope]); + const tunnels = useMemo(() => snapshot?.tunnels ?? [], [snapshot]); + const hasFiniteExpiry = useMemo( + () => tunnels.some((tunnel) => tunnel.expiresAt > 0), + [tunnels], + ); + useEffect(() => { + if (!hasFiniteExpiry) return; + setNowSeconds(Math.floor(Date.now() / 1000)); const timer = window.setInterval(() => { setNowSeconds(Math.floor(Date.now() / 1000)); }, 1000); return () => window.clearInterval(timer); - }, []); + }, [hasFiniteExpiry]); useEffect(() => { if (!copiedId) return; @@ -334,13 +284,37 @@ export function LocalTunnelPanel({ return () => window.clearTimeout(timer); }, [copiedId]); + const gatewayUnsupported = snapshot?.gatewayUnsupported === true; + const mutationsEnabled = enabled && !gatewayUnsupported && Boolean(client); + + const beginRowAction = useCallback((id: string, action: TunnelRowAction) => { + setPendingActions((current) => ({ ...current, [id]: action })); + setRowErrors((current) => { + if (!(id in current)) return current; + const { [id]: _removed, ...rest } = current; + return rest; + }); + }, []); + + const endRowAction = useCallback((id: string) => { + setPendingActions((current) => { + if (!(id in current)) return current; + const { [id]: _removed, ...rest } = current; + return rest; + }); + }, []); + + const setRowError = useCallback((id: string, message: string) => { + setRowErrors((current) => ({ ...current, [id]: message })); + }, []); + const createTunnel = useCallback(() => { const validationKey = validateLocalHttpTarget(targetUrl); if (validationKey) { - setError(t(validationKey)); + setCreateError(t(validationKey)); return; } - if (!enabled || creating) return; + if (!client || !mutationsEnabled || creating) return; const input: TunnelCreateInput = { targetUrl: targetUrl.trim(), name: name.trim() || undefined, @@ -350,134 +324,168 @@ export function LocalTunnelPanel({ input.projectPathKey = normalizedProjectPathKey; } setCreating(true); - setError(null); + setCreateError(null); void client .createTunnel(input) - .then((created) => { - setTunnels((current) => [ - created, - ...current.filter((item) => item.id !== created.id && item.slug !== created.slug), - ]); - setName(""); - void refresh({ showLoading: false }); - }) - .catch((err) => setError(asErrorMessage(err))) + .then(() => setName("")) + .catch((err) => setCreateError(asErrorMessage(err))) .finally(() => setCreating(false)); }, [ client, creating, - enabled, + mutationsEnabled, name, normalizedProjectPathKey, - refresh, scope, t, targetUrl, ttlSeconds, ]); - const beginEdit = useCallback( - (tunnel: TunnelSummary) => { - setEditingId(tunnel.id); - setEditTargetUrl(tunnel.targetUrl); - setEditName(tunnel.name); - setEditTtlSeconds(ttlFromTunnel(tunnel, nowSeconds)); - setError(null); - }, - [nowSeconds], - ); + const beginEdit = useCallback((tunnel: TunnelStatus) => { + setEditingId(tunnel.id); + setEditTargetUrl(tunnel.targetUrl); + setEditName(tunnel.name); + setEditTtlSeconds("keep"); + setRowErrors((current) => { + if (!(tunnel.id in current)) return current; + const { [tunnel.id]: _removed, ...rest } = current; + return rest; + }); + }, []); const cancelEdit = useCallback(() => { setEditingId(""); setEditTargetUrl(""); setEditName(""); - setEditTtlSeconds(3600); - setError(null); + setEditTtlSeconds("keep"); }, []); const updateTunnel = useCallback( - (tunnel: TunnelSummary) => { + (tunnel: TunnelStatus) => { const validationKey = validateLocalHttpTarget(editTargetUrl); if (validationKey) { - setError(t(validationKey)); + setRowError(tunnel.id, t(validationKey)); return; } - if (!enabled || savingId) return; + if (!client || !mutationsEnabled || pendingActions[tunnel.id]) return; const input: TunnelUpdateInput = { id: tunnel.id, targetUrl: editTargetUrl.trim(), name: editName.trim() || undefined, - ttlSeconds: editTtlSeconds, }; + // Only re-bucket the expiry when the user explicitly picked a TTL. + if (editTtlSeconds !== "keep") { + input.ttlSeconds = editTtlSeconds; + } const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); if (tunnelProjectPathKey) { input.projectPathKey = tunnelProjectPathKey; } - setSavingId(tunnel.id); - setError(null); + beginRowAction(tunnel.id, "save"); void client .updateTunnel(input) - .then((updated) => { - setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))); - cancelEdit(); - }) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setSavingId((current) => (current === tunnel.id ? "" : current))); + .then(() => cancelEdit()) + .catch((err) => setRowError(tunnel.id, asErrorMessage(err))) + .finally(() => endRowAction(tunnel.id)); }, - [cancelEdit, client, editName, editTargetUrl, editTtlSeconds, enabled, savingId, t], + [ + beginRowAction, + cancelEdit, + client, + editName, + editTargetUrl, + editTtlSeconds, + endRowAction, + mutationsEnabled, + pendingActions, + setRowError, + t, + ], ); const closeTunnel = useCallback( (id: string) => { - if (!enabled || closingId) return; - setClosingId(id); - setError(null); + if (!client || !mutationsEnabled || pendingActions[id]) return; + beginRowAction(id, "close"); void client .closeTunnel(id) - .then((closed) => { - setTunnels((current) => - current - .filter((item) => item.id !== id) - .concat(closed.status === "active" ? [closed] : []), - ); - }) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setClosingId((current) => (current === id ? "" : current))); + .catch((err) => setRowError(id, asErrorMessage(err))) + .finally(() => endRowAction(id)); }, - [client, closingId, enabled], + [beginRowAction, client, endRowAction, mutationsEnabled, pendingActions, setRowError], ); - const probeTunnel = useCallback( + const checkTunnel = useCallback( (id: string) => { - if (!enabled || probingId) return; - setProbingId(id); - setError(null); + if (!client || !mutationsEnabled || pendingActions[id]) return; + beginRowAction(id, "check"); void client - .probeTunnel(id) - .then((updated) => - setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))), - ) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setProbingId((current) => (current === id ? "" : current))); + .checkTunnel(id) + .catch((err) => setRowError(id, asErrorMessage(err))) + .finally(() => endRowAction(id)); }, - [client, enabled, probingId], + [beginRowAction, client, endRowAction, mutationsEnabled, pendingActions, setRowError], ); - const copyLink = useCallback((tunnel: TunnelSummary) => { - if (!tunnel.publicUrl) return; - void writeTextToClipboard(tunnel.publicUrl) - .then((copied) => { - if (copied) { - setCopiedId(tunnel.id); - } - }) - .catch(() => {}); - }, []); + const checkAllTunnels = useCallback(() => { + if (!client || !mutationsEnabled || checkingAll) return; + setCheckingAll(true); + setCreateError(null); + void client + .checkTunnel() + .catch((err) => setCreateError(asErrorMessage(err))) + .finally(() => setCheckingAll(false)); + }, [checkingAll, client, mutationsEnabled]); - const openLink = useCallback((tunnel: TunnelSummary) => { - if (!tunnel.publicUrl) return; - window.open(tunnel.publicUrl, "_blank", "noopener,noreferrer"); - }, []); + const publicUrlFor = useCallback( + (tunnel: TunnelStatus) => composePublicUrl(publicBaseUrl, tunnel.publicPath), + [publicBaseUrl], + ); + + const copyLink = useCallback( + (tunnel: TunnelStatus) => { + const url = publicUrlFor(tunnel); + if (!url) return; + void writeTextToClipboard(url) + .then((copied) => { + if (copied) { + setCopiedId(tunnel.id); + } + }) + .catch(() => {}); + }, + [publicUrlFor], + ); + + const openLink = useCallback( + (tunnel: TunnelStatus) => { + const url = publicUrlFor(tunnel); + if (!url) return; + if (onOpenExternal) { + onOpenExternal(url); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }, + [onOpenExternal, publicUrlFor], + ); + + const healthTitle = useCallback( + (health: TunnelHealth | null) => { + if (!health) return t("projectTools.tunnelHealthUnknown"); + return [ + t(healthStatusLabelKey(health.status)), + health.httpStatus > 0 ? `HTTP ${health.httpStatus}` : "", + health.rttMs > 0 ? `${health.rttMs}ms` : "", + health.error, + health.checkedAt > 0 ? formatDateTime(health.checkedAt) : "", + ] + .filter(Boolean) + .join(" · "); + }, + [t], + ); const scopedTunnels = useMemo( () => @@ -496,13 +504,17 @@ export function LocalTunnelPanel({ () => [...scopedTunnels].sort((a, b) => b.createdAt - a.createdAt), [scopedTunnels], ); + const loading = Boolean(client) && snapshot === null; + const agentOnline = snapshot?.agentOnline === true; + const linkStatus: HealthDisplayStatus = snapshot ? (agentOnline ? "ok" : "failed") : "unknown"; + const relayStatus: HealthDisplayStatus = snapshot?.relay?.status ?? "unknown"; const canCreate = - enabled && + mutationsEnabled && !creating && !targetValidationKey && (scope !== "project" || Boolean(normalizedProjectPathKey)); const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); - const createFieldsDisabled = !showCreateForm || !createOpen || !enabled || creating; + const createFieldsDisabled = !showCreateForm || !createOpen || !mutationsEnabled || creating; return (
@@ -520,6 +532,35 @@ export function LocalTunnelPanel({
+
+ + + + +
{ setScope(option.scope); - setError(null); + setCreateError(null); }} className={cn( "relative z-10 flex h-7 min-w-0 transform-gpu items-center justify-center gap-1.5 rounded-[7px] px-2 text-xs text-muted-foreground transition-[color,transform] duration-200 ease-out hover:text-foreground active:scale-[0.98] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100", @@ -568,6 +609,13 @@ export function LocalTunnelPanel({
) : null} + {gatewayUnsupported ? ( +
+ + {t("projectTools.tunnelGatewayUnsupported")} +
+ ) : null} + {normalizedProjectPathKey ? (
) : null} - {error ? ( + {createError ? (
- {error} + {createError}
) : null} @@ -739,9 +787,15 @@ export function LocalTunnelPanel({ {sortedTunnels.map((tunnel) => { const hasExpiry = tunnel.expiresAt > 0; const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; - const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const expired = hasExpiry && remaining <= 0; + const offline = snapshot !== null && !agentOnline; const isEditing = editingId === tunnel.id; - const updating = savingId === tunnel.id; + const pendingAction = pendingActions[tunnel.id]; + const updating = pendingAction === "save"; + const rowError = rowErrors[tunnel.id]; + const publicUrl = publicUrlFor(tunnel); + const localHealth = tunnel.local; + const localStatus: HealthDisplayStatus = localHealth?.status ?? "unknown"; const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); const handleEditKeyDown = (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing) return; @@ -765,7 +819,7 @@ export function LocalTunnelPanel({ - {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + {t( + offline + ? "projectTools.tunnelStatusOffline" + : expired + ? "projectTools.tunnelStatusExpired" + : "projectTools.tunnelStatusActive", + )}
@@ -801,7 +861,7 @@ export function LocalTunnelPanel({ value={editTargetUrl} onChange={(event) => setEditTargetUrl(event.target.value)} onKeyDown={handleEditKeyDown} - disabled={!enabled || updating} + disabled={!mutationsEnabled || updating} inputMode="url" autoComplete="off" spellCheck={false} @@ -827,7 +887,7 @@ export function LocalTunnelPanel({ onChange={(event) => setEditName(event.target.value)} onKeyDown={handleEditKeyDown} placeholder={t("projectTools.tunnelNamePlaceholder")} - disabled={!enabled || updating} + disabled={!mutationsEnabled || updating} autoComplete="off" className={TUNNEL_INPUT_CLASS} /> @@ -836,10 +896,23 @@ export function LocalTunnelPanel({ +
@@ -860,7 +933,7 @@ export function LocalTunnelPanel({ type="button" size="sm" className="h-7 gap-1 rounded-lg px-2.5 text-xs" - disabled={!enabled || updating || Boolean(editTargetValidationKey)} + disabled={!mutationsEnabled || updating || Boolean(editTargetValidationKey)} onClick={() => updateTunnel(tunnel)} title={ updating @@ -882,7 +955,7 @@ export function LocalTunnelPanel({
-
- {TUNNEL_DIAGNOSTIC_PROTOCOLS.map((protocol) => { - const diagnostic = diagnosticFor(tunnel, protocol); - const status = diagnostic?.status ?? "unknown"; - const title = diagnostic - ? [ - diagnosticLabel(protocol), - t( - status === "ok" - ? "projectTools.tunnelDiagnosticOk" - : status === "failed" - ? "projectTools.tunnelDiagnosticFailed" - : "projectTools.tunnelDiagnosticUnknown", - ), - diagnostic.statusCode ? String(diagnostic.statusCode) : "", - diagnostic.errorCode, - diagnostic.message, - ] - .filter(Boolean) - .join(" · ") - : t("projectTools.tunnelDiagnosticUnknown"); - return ( -
- - {diagnosticLabel(protocol)} -
- ); - })} +
+
+ + {t("projectTools.tunnelServiceLabel")} + {localStatus === "ok" && localHealth && localHealth.httpStatus > 0 ? ( + HTTP {localHealth.httpStatus} + ) : ( + {t(healthStatusLabelKey(localStatus))} + )} +
+ {rowError ? ( +
+ {rowError} +
+ ) : null}
probeTunnel(tunnel.id)} - title={!enabled ? disabledMessage : t("projectTools.tunnelProbe")} - aria-label={t("projectTools.tunnelProbe")} + disabled={!mutationsEnabled || expired || Boolean(pendingAction)} + onClick={() => checkTunnel(tunnel.id)} + title={!enabled ? disabledMessage : t("projectTools.tunnelCheckAction")} + aria-label={t("projectTools.tunnelCheckAction")} > - {probingId === tunnel.id ? ( + {pendingAction === "check" ? ( ) : ( @@ -1012,7 +1071,7 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground" - disabled={!enabled || expired} + disabled={!mutationsEnabled || expired} onClick={() => beginEdit(tunnel)} title={!enabled ? disabledMessage : t("projectTools.tunnelEdit")} aria-label={t("projectTools.tunnelEdit")} @@ -1024,7 +1083,7 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground" - disabled={!tunnel.publicUrl || expired} + disabled={!publicUrl || expired} onClick={() => openLink(tunnel)} title={t("projectTools.tunnelOpenLink")} aria-label={t("projectTools.tunnelOpenLink")} @@ -1036,12 +1095,12 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive" - disabled={!enabled || closingId === tunnel.id} + disabled={!mutationsEnabled || Boolean(pendingAction)} onClick={() => closeTunnel(tunnel.id)} title={!enabled ? disabledMessage : t("projectTools.tunnelClose")} aria-label={t("projectTools.tunnelClose")} > - {closingId === tunnel.id ? ( + {pendingAction === "close" ? ( ) : ( diff --git a/crates/agent-gateway/web/src/components/project-tools/RightDockContent.tsx b/crates/agent-gateway/web/src/components/project-tools/RightDockContent.tsx index 7d8d2e1f..a50a52ed 100644 --- a/crates/agent-gateway/web/src/components/project-tools/RightDockContent.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/RightDockContent.tsx @@ -40,7 +40,7 @@ type RightDockContentProps = { tunnelClient?: LocalTunnelClient | null; tunnelEnabled: boolean; tunnelDisabledMessage?: string; - tunnelRefreshToken?: number; + tunnelPublicBaseUrl: string; sshHosts: SshHostConfig[]; associatedSshHostIds: string[]; client: TerminalClient; @@ -87,7 +87,7 @@ export function RightDockContent(props: RightDockContentProps) { tunnelClient, tunnelEnabled, tunnelDisabledMessage, - tunnelRefreshToken, + tunnelPublicBaseUrl, sshHosts, associatedSshHostIds, client, @@ -172,7 +172,7 @@ export function RightDockContent(props: RightDockContentProps) { enabled={tunnelEnabled} disabledMessage={tunnelDisabledMessage} projectPathKey={projectPathKey} - refreshToken={tunnelRefreshToken} + publicBaseUrl={tunnelPublicBaseUrl} />
) : null} diff --git a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx index 9d8b3f70..90b27db4 100644 --- a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx @@ -56,7 +56,7 @@ type RightDockPanelProps = { tunnelClient?: LocalTunnelClient | null; tunnelEnabled?: boolean; tunnelDisabledMessage?: string; - tunnelRefreshToken?: number; + tunnelPublicBaseUrl: string; onWidthChange: (width: number) => void; onProjectStateChange: ( updater: (current: RightDockProjectState) => RightDockProjectState, @@ -326,7 +326,7 @@ export function RightDockPanel(props: RightDockPanelProps) { tunnelClient, tunnelEnabled = true, tunnelDisabledMessage, - tunnelRefreshToken, + tunnelPublicBaseUrl, onWidthChange, onProjectStateChange, onFileTreeStateChange, @@ -643,7 +643,7 @@ export function RightDockPanel(props: RightDockPanelProps) { tunnelClient={tunnelClient} tunnelEnabled={tunnelEnabled} tunnelDisabledMessage={tunnelDisabledMessage} - tunnelRefreshToken={tunnelRefreshToken} + tunnelPublicBaseUrl={tunnelPublicBaseUrl} sshHosts={sshHosts} associatedSshHostIds={associatedSshHostIds} client={client} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 92c69978..5b472f96 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -352,7 +352,6 @@ export const translations: Record> = { "projectTools.tunnelTargetRequired": "请输入 HTTP 服务地址。", "projectTools.tunnelInvalidUrl": "请输入有效的 http 地址,不能包含账号、密码或片段。", "projectTools.tunnelLocalhostOnly": "仅支持 localhost 或 IPv4/IPv6 地址。", - "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", "projectTools.tunnelStatusActive": "运行中", "projectTools.tunnelStatusExpired": "已过期", @@ -365,10 +364,15 @@ export const translations: Record> = { "projectTools.tunnelCopyLink": "复制链接", "projectTools.tunnelCopied": "已复制", "projectTools.tunnelOpenLink": "打开链接", - "projectTools.tunnelProbe": "重新检测", - "projectTools.tunnelDiagnosticOk": "可用", - "projectTools.tunnelDiagnosticFailed": "失败", - "projectTools.tunnelDiagnosticUnknown": "未检测", + "projectTools.tunnelLinkLabel": "链路", + "projectTools.tunnelRelayLabel": "中继", + "projectTools.tunnelServiceLabel": "本地服务", + "projectTools.tunnelHealthOk": "正常", + "projectTools.tunnelHealthFailed": "异常", + "projectTools.tunnelHealthUnknown": "未知", + "projectTools.tunnelGatewayUnsupported": "网关版本过旧,请升级后使用隧道", + "projectTools.tunnelKeepCurrentTtl": "保持当前有效期", + "projectTools.tunnelCheckAction": "检查", "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", @@ -1827,8 +1831,6 @@ export const translations: Record> = { "projectTools.tunnelTargetRequired": "Enter an HTTP service URL.", "projectTools.tunnelInvalidUrl": "Enter a valid http URL without credentials or fragments.", "projectTools.tunnelLocalhostOnly": "Only localhost or IPv4/IPv6 addresses are supported.", - "projectTools.tunnelRemoteOffline": - "Remote Gateway is not connected. Connect it before creating or closing tunnels.", "projectTools.tunnelWebDisabled": "Desktop Remote settings do not allow WebUI tunnel create or close.", "projectTools.tunnelStatusActive": "Active", @@ -1842,10 +1844,16 @@ export const translations: Record> = { "projectTools.tunnelCopyLink": "Copy link", "projectTools.tunnelCopied": "Copied", "projectTools.tunnelOpenLink": "Open link", - "projectTools.tunnelProbe": "Recheck", - "projectTools.tunnelDiagnosticOk": "Available", - "projectTools.tunnelDiagnosticFailed": "Failed", - "projectTools.tunnelDiagnosticUnknown": "Not checked", + "projectTools.tunnelLinkLabel": "Link", + "projectTools.tunnelRelayLabel": "Relay", + "projectTools.tunnelServiceLabel": "Service", + "projectTools.tunnelHealthOk": "OK", + "projectTools.tunnelHealthFailed": "Failed", + "projectTools.tunnelHealthUnknown": "Unknown", + "projectTools.tunnelGatewayUnsupported": + "The gateway is outdated; upgrade it to use tunnels", + "projectTools.tunnelKeepCurrentTtl": "Keep current expiry", + "projectTools.tunnelCheckAction": "Check", "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 4f40eca8..799d33ca 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -53,6 +53,13 @@ import type { HistoryWorkdirsResponse, MemoryManagePayload, } from "./gatewayTypes"; +import type { + TunnelCreateInput, + TunnelHealth, + TunnelStateSnapshot, + TunnelStatus, + TunnelUpdateInput, +} from "@/lib/tunnels/constants"; import { ConversationStreamClient } from "@/lib/chat/stream/conversationStreamClient"; import type { ChatCommandAccepted, @@ -202,79 +209,44 @@ export type UploadedImagePreviewResponse = { data: string; }; -export type TunnelCreateInput = { - targetUrl: string; - name?: string; - ttlSeconds: 0 | 900 | 3600 | 14400; - projectPathKey?: string; -}; +export type { + LocalTunnelClient, + TunnelCreateInput, + TunnelHealth, + TunnelStateSnapshot, + TunnelStatus, + TunnelTtlSeconds, + TunnelUpdateInput, +} from "@/lib/tunnels/constants"; -export type TunnelUpdateInput = { - id: string; - targetUrl: string; - name?: string; - ttlSeconds: 0 | 900 | 3600 | 14400; - projectPathKey?: string; -}; +type TunnelStateListener = (snapshot: TunnelStateSnapshot) => void; -export type TunnelSummary = { - id: string; - slug: string; - name: string; - targetUrl: string; - publicUrl: string; - createdAt: number; - expiresAt: number; - activeConnections: number; - status: "active" | "expired" | "offline"; - projectPathKey: string; - diagnostics: TunnelDiagnostic[]; -}; - -export type TunnelDiagnostic = { - protocol: "http" | "websocket" | "sse"; - status: "ok" | "failed" | "unknown"; - statusCode: number; - errorCode: string; - message: string; - checkedAt: number; +type RawTunnelHealth = { + status?: string; + http_status?: number; + error?: string; + checked_at?: number; + rtt_ms?: number; }; -type RawTunnelSummary = { +type RawTunnelStatus = { id?: string; slug?: string; name?: string; - targetUrl?: string; target_url?: string; - publicUrl?: string; - public_url?: string; - createdAt?: number; + public_path?: string; created_at?: number; - expiresAt?: number; expires_at?: number; - activeConnections?: number; active_connections?: number; - status?: string; - projectPathKey?: string; project_path_key?: string; - diagnostics?: RawTunnelDiagnostic[]; + local?: RawTunnelHealth | null; }; -type RawTunnelDiagnostic = { - protocol?: string; - status?: string; - statusCode?: number; - status_code?: number; - errorCode?: string; - error_code?: string; - message?: string; - checkedAt?: number; - checked_at?: number; -}; - -type RawTunnelResponse = { - tunnel?: RawTunnelSummary; - tunnels?: RawTunnelSummary[]; +type RawTunnelStatePayload = { + revision?: number; + agent_online?: boolean; + relay?: RawTunnelHealth | null; + tunnels?: RawTunnelStatus[]; }; type HistoryGetOptions = { @@ -1004,62 +976,45 @@ function replayTerminalSnapshot( } } -function normalizeTunnelStatus(input: unknown): TunnelSummary["status"] { - return input === "expired" || input === "offline" ? input : "active"; +function normalizeTunnelHealthStatus(input: unknown): TunnelHealth["status"] { + return input === "ok" || input === "failed" ? input : "unknown"; } -function normalizeTunnelDiagnosticProtocol(input: unknown): TunnelDiagnostic["protocol"] { - if (input === "websocket" || input === "sse") return input; - return "http"; -} - -function normalizeTunnelDiagnosticStatus(input: unknown): TunnelDiagnostic["status"] { - if (input === "ok" || input === "failed") return input; - return "unknown"; -} - -function normalizeTunnelDiagnostic(input: RawTunnelDiagnostic): TunnelDiagnostic { +function normalizeTunnelHealth(input: RawTunnelHealth | null | undefined): TunnelHealth | null { + if (!input || typeof input !== "object") { + return null; + } return { - protocol: normalizeTunnelDiagnosticProtocol(input.protocol), - status: normalizeTunnelDiagnosticStatus(input.status), - statusCode: Number(input.statusCode ?? input.status_code ?? 0), - errorCode: (input.errorCode ?? input.error_code ?? "").trim(), - message: (input.message ?? "").trim(), - checkedAt: Number(input.checkedAt ?? input.checked_at ?? 0), + status: normalizeTunnelHealthStatus(input.status), + httpStatus: Number(input.http_status ?? 0), + error: (input.error ?? "").trim(), + checkedAt: Number(input.checked_at ?? 0), + rttMs: Number(input.rtt_ms ?? 0), }; } -function fallbackTunnelPublicUrl(slug: string) { - const origin = getRuntimeOrigin().replace(/\/$/, ""); - return origin && slug ? `${origin}/t/${slug}/` : ""; -} - -function normalizeTunnelSummary(input: RawTunnelSummary): TunnelSummary { - const slug = input.slug?.trim() ?? ""; +function normalizeTunnelStatus(input: RawTunnelStatus): TunnelStatus { return { id: input.id?.trim() ?? "", - slug, + slug: input.slug?.trim() ?? "", name: input.name?.trim() ?? "", - targetUrl: input.targetUrl ?? input.target_url ?? "", - publicUrl: input.publicUrl ?? input.public_url ?? fallbackTunnelPublicUrl(slug), - createdAt: Number(input.createdAt ?? input.created_at ?? 0), - expiresAt: Number(input.expiresAt ?? input.expires_at ?? 0), - activeConnections: Number(input.activeConnections ?? input.active_connections ?? 0), - status: normalizeTunnelStatus(input.status), - projectPathKey: (input.projectPathKey ?? input.project_path_key ?? "").trim(), - diagnostics: (input.diagnostics ?? []).map(normalizeTunnelDiagnostic), + targetUrl: input.target_url ?? "", + publicPath: input.public_path ?? "", + createdAt: Number(input.created_at ?? 0), + expiresAt: Number(input.expires_at ?? 0), + activeConnections: Number(input.active_connections ?? 0), + projectPathKey: (input.project_path_key ?? "").trim(), + local: normalizeTunnelHealth(input.local), }; } -function normalizeTunnelListResponse(input: RawTunnelResponse): TunnelSummary[] { - return (input.tunnels ?? []).map(normalizeTunnelSummary); -} - -function normalizeTunnelResponse(input: RawTunnelResponse): TunnelSummary { - if (!input.tunnel) { - throw new Error("Tunnel response did not include a tunnel"); - } - return normalizeTunnelSummary(input.tunnel); +function normalizeTunnelStateSnapshot(input: RawTunnelStatePayload): TunnelStateSnapshot { + return { + revision: Number(input.revision ?? 0), + agentOnline: input.agent_online === true, + relay: normalizeTunnelHealth(input.relay), + tunnels: (input.tunnels ?? []).map(normalizeTunnelStatus), + }; } function normalizeOptionalOffset(value: unknown) { @@ -1126,6 +1081,14 @@ export class GatewayWebSocketClient { private chatQueueListeners = new Set(); private chatActivityListeners = new Set(); private chatCommandUpdateListeners = new Set(); + private tunnelStateListeners = new Set(); + private lastTunnelState: TunnelStateSnapshot | null = null; + // Server tunnel.state revisions are only monotonic within one gateway + // process; this guard is reset on disconnect so a restarted gateway's + // snapshots are not dropped. Subscribers see a client-side monotonic + // revision instead. + private lastTunnelStateServerRevision = 0; + private tunnelStateRevisionCounter = 0; readonly conversationStreams = new ConversationStreamClient({ request: (type, payload) => this.request(type, payload), }); @@ -1227,6 +1190,16 @@ export class GatewayWebSocketClient { }; } + subscribeTunnelState(listener: TunnelStateListener): () => void { + this.tunnelStateListeners.add(listener); + if (this.lastTunnelState) { + listener(this.lastTunnelState); + } + return () => { + this.tunnelStateListeners.delete(listener); + }; + } + subscribeSftpTransfers(listener: SftpTransferListener): () => void { this.sftpTransferListeners.add(listener); return () => { @@ -1697,55 +1670,54 @@ export class GatewayWebSocketClient { return (response.sessions ?? []).map(normalizeTerminalSession); } - async listTunnels(): Promise { - return normalizeTunnelListResponse( - await this.requestWithRecovery("tunnel.list", {}), - ); - } - - async createTunnel(input: TunnelCreateInput): Promise { + async createTunnel(input: TunnelCreateInput): Promise { const payload: Record = { - targetUrl: input.targetUrl, - ttlSeconds: input.ttlSeconds, - name: input.name, + target_url: input.targetUrl, + ttl_seconds: input.ttlSeconds, }; - if (input.projectPathKey?.trim()) { - payload.projectPathKey = input.projectPathKey.trim(); + const name = input.name?.trim(); + if (name) { + payload.name = name; } - return normalizeTunnelResponse( - await this.request("tunnel.create", payload), - ); + const projectPathKey = input.projectPathKey?.trim(); + if (projectPathKey) { + payload.project_path_key = projectPathKey; + } + await this.request("tunnel.create", payload); } - async updateTunnel(input: TunnelUpdateInput): Promise { + async updateTunnel(input: TunnelUpdateInput): Promise { const payload: Record = { - id: input.id, - targetUrl: input.targetUrl, - ttlSeconds: input.ttlSeconds, - name: input.name, + tunnel_id: input.id, + target_url: input.targetUrl, }; - if (input.projectPathKey?.trim()) { - payload.projectPathKey = input.projectPathKey.trim(); + // Omitting ttl_seconds keeps the current expiry; sending it re-buckets + // the expiry from now. + if (input.ttlSeconds !== undefined) { + payload.ttl_seconds = input.ttlSeconds; } - return normalizeTunnelResponse( - await this.request("tunnel.update", payload), - ); + const name = input.name?.trim(); + if (name) { + payload.name = name; + } + const projectPathKey = input.projectPathKey?.trim(); + if (projectPathKey) { + payload.project_path_key = projectPathKey; + } + await this.request("tunnel.update", payload); } - async closeTunnel(id: string): Promise { - return normalizeTunnelResponse( - await this.request("tunnel.close", { - id, - }), - ); + async closeTunnel(id: string): Promise { + await this.request("tunnel.close", { tunnel_id: id }); } - async probeTunnel(id: string): Promise { - return normalizeTunnelResponse( - await this.request("tunnel.probe", { - id, - }), - ); + async checkTunnel(id?: string): Promise { + const payload: Record = {}; + const tunnelId = id?.trim(); + if (tunnelId) { + payload.tunnel_id = tunnelId; + } + await this.request("tunnel.check", payload); } async listHistory( @@ -2112,6 +2084,7 @@ export class GatewayWebSocketClient { this.terminalListeners.size > 0 || this.sftpTransferListeners.size > 0 || this.chatActivityListeners.size > 0 || + this.tunnelStateListeners.size > 0 || this.conversationStreams.size > 0) ); } @@ -2237,6 +2210,23 @@ export class GatewayWebSocketClient { } } + private emitTunnelState(snapshot: TunnelStateSnapshot) { + // Drop stale/reordered snapshots from the current gateway process. + if (snapshot.revision <= this.lastTunnelStateServerRevision) { + return; + } + this.lastTunnelStateServerRevision = snapshot.revision; + this.tunnelStateRevisionCounter += 1; + const next: TunnelStateSnapshot = { + ...snapshot, + revision: this.tunnelStateRevisionCounter, + }; + this.lastTunnelState = next; + for (const listener of this.tunnelStateListeners) { + listener(next); + } + } + private async requestWithRecovery(type: string, payload: unknown): Promise { try { return await this.request(type, payload); @@ -2451,6 +2441,17 @@ export class GatewayWebSocketClient { return; } + if (envelope.type === "tunnel.state") { + const payload = + envelope.payload && typeof envelope.payload === "object" + ? (envelope.payload as RawTunnelStatePayload) + : null; + if (payload) { + this.emitTunnelState(normalizeTunnelStateSnapshot(payload)); + } + return; + } + if (envelope.type === "chat_queue.event") { const snapshot = normalizeChatQueueEvent(envelope.payload as RawChatQueueEvent); if (snapshot) { @@ -2551,6 +2552,9 @@ export class GatewayWebSocketClient { } } this.lastInboundAt = 0; + // A reconnect may land on a restarted gateway whose tunnel.state + // revisions start over; accept the fresh post-auth snapshot. + this.lastTunnelStateServerRevision = 0; if (!this.disposed) { this.scheduleReconnect(); } @@ -2739,11 +2743,11 @@ export type GatewayWebSocketClientLike = { ): Promise; closeTerminal(sessionId: string, projectPathKey?: string): Promise; closeProjectTerminals(projectPathKey: string): Promise; - listTunnels(): Promise; - createTunnel(input: TunnelCreateInput): Promise; - updateTunnel(input: TunnelUpdateInput): Promise; - probeTunnel(id: string): Promise; - closeTunnel(id: string): Promise; + subscribeTunnelState(listener: (snapshot: TunnelStateSnapshot) => void): () => void; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; + checkTunnel(id?: string): Promise; listHistory(page: number, pageSize: number, filter?: HistoryListFilter): Promise; listHistoryWorkdirs(): Promise; listSharedHistory(page: number, pageSize: number): Promise; diff --git a/crates/agent-gateway/web/src/lib/tunnels/constants.ts b/crates/agent-gateway/web/src/lib/tunnels/constants.ts new file mode 100644 index 00000000..0299a10f --- /dev/null +++ b/crates/agent-gateway/web/src/lib/tunnels/constants.ts @@ -0,0 +1,109 @@ +// Shared tunnel types and helpers for the local-tunnel panel. This file is +// byte-copied between the webui and the desktop GUI codebases — keep it free +// of any codebase-specific imports. + +export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export const TUNNEL_TTL_OPTIONS: TunnelTtlSeconds[] = [900, 3600, 14400, 0]; + +export type TunnelHealth = { + status: "ok" | "failed" | "unknown"; + httpStatus: number; + error: string; + checkedAt: number; + rttMs: number; +}; + +export type TunnelStatus = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicPath: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + projectPathKey: string; + local: TunnelHealth | null; +}; + +export type TunnelStateSnapshot = { + revision: number; + agentOnline: boolean; + relay: TunnelHealth | null; + tunnels: TunnelStatus[]; + gatewayUnsupported?: boolean; +}; + +export type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export type TunnelUpdateInput = { + id: string; + targetUrl: string; + name?: string; + // Omitted = keep the current expiry; present = re-bucket from now. + ttlSeconds?: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export interface LocalTunnelClient { + // Fires immediately with the last known snapshot when available. + subscribeTunnelState(listener: (snapshot: TunnelStateSnapshot) => void): () => void; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; + checkTunnel(id?: string): Promise; +} + +function normalizeTunnelHostname(hostname: string) { + return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); +} + +function isIpv4Address(hostname: string) { + const parts = hostname.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + if (!/^\d+$/.test(part)) return false; + const value = Number(part); + return value >= 0 && value <= 255 && String(value) === part; + }); +} + +function isIpAddress(hostname: string) { + if (isIpv4Address(hostname)) return true; + return hostname.includes(":"); +} + +// Returns an i18n key describing the validation failure, or null when valid. +export function validateLocalHttpTarget(input: string): string | null { + const value = input.trim(); + if (!value) return "projectTools.tunnelTargetRequired"; + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return "projectTools.tunnelInvalidUrl"; + } + const hostname = normalizeTunnelHostname(url.hostname); + if (hostname !== "localhost" && !isIpAddress(hostname)) { + return "projectTools.tunnelLocalhostOnly"; + } + if (url.username || url.password || url.hash) { + return "projectTools.tunnelInvalidUrl"; + } + } catch { + return "projectTools.tunnelInvalidUrl"; + } + return null; +} + +export function composePublicUrl(baseUrl: string, publicPath: string): string { + const base = baseUrl.trim().replace(/\/+$/, ""); + const path = publicPath.trim(); + if (!base || !path) return ""; + return `${base}${path.startsWith("/") ? path : `/${path}`}`; +} diff --git a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx index 04a05647..9257e663 100644 --- a/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx +++ b/crates/agent-gateway/web/src/pages/StatusDashboardPage.tsx @@ -33,7 +33,7 @@ import { getGatewayWebSocketClient, resetGatewayWebSocketClient, type GatewayWebSocketClientLike, - type TunnelSummary, + type TunnelStateSnapshot, } from "@/lib/gatewaySocket"; import type { AgentStatus, @@ -591,7 +591,7 @@ export function StatusDashboardPage() { const [statusError, setStatusError] = useState(null); const [history, setHistory] = useState(null); const [workdirs, setWorkdirs] = useState([]); - const [tunnels, setTunnels] = useState([]); + const [tunnelState, setTunnelState] = useState(null); const [terminals, setTerminals] = useState([]); const [providers, setProviders] = useState([]); const [settingsSnapshot, setSettingsSnapshot] = useState(null); @@ -654,12 +654,18 @@ export function StatusDashboardPage() { const unsubscribeSettings = api.subscribeSettings((payload) => { setSettingsSnapshot(payload); }); + const unsubscribeTunnelState = api.subscribeTunnelState((snapshot) => { + setTunnelState((current) => + current && snapshot.revision <= current.revision ? current : snapshot, + ); + }); return () => { unsubscribeStatus(); unsubscribeHistory(); unsubscribeTerminal(); unsubscribeSettings(); + unsubscribeTunnelState(); }; }, [api]); @@ -674,7 +680,6 @@ export function StatusDashboardPage() { statusResult, historyResult, workdirsResult, - tunnelsResult, terminalsResult, providersResult, settingsResult, @@ -682,7 +687,6 @@ export function StatusDashboardPage() { runSnapshotRequest(currentApi.getStatus()), runSnapshotRequest(currentApi.listHistory(1, HISTORY_PAGE_SIZE)), runSnapshotRequest(currentApi.listHistoryWorkdirs()), - runSnapshotRequest(currentApi.listTunnels()), runSnapshotRequest(currentApi.listTerminals()), runSnapshotRequest(currentApi.listProviders()), runSnapshotRequest(currentApi.getSettings()), @@ -707,11 +711,6 @@ export function StatusDashboardPage() { } else { errors.push(asErrorMessage(workdirsResult.error, "项目活动读取失败")); } - if (tunnelsResult.ok) { - setTunnels(tunnelsResult.value); - } else { - errors.push(asErrorMessage(tunnelsResult.error, "公网通道读取失败")); - } if (terminalsResult.ok) { setTerminals(terminalsResult.value); } else { @@ -743,8 +742,9 @@ export function StatusDashboardPage() { }, [api, refreshVersion]); const runningConversations = useMemo(() => buildRunningConversations(history), [history]); + const tunnels = useMemo(() => tunnelState?.tunnels ?? [], [tunnelState]); const activeTunnels = useMemo( - () => tunnels.filter((item) => item.status === "active" && (!item.expiresAt || item.expiresAt > now)), + () => tunnels.filter((item) => !item.expiresAt || item.expiresAt > now / 1000), [now, tunnels], ); const runningTerminals = useMemo(() => terminals.filter((item) => item.running), [terminals]); @@ -1298,7 +1298,7 @@ export function StatusDashboardPage() {
- Sources: status.get / settings.get / history.list / terminal.list / tunnel.list / providers.list + Sources: status.get / settings.get / history.list / terminal.list / tunnel.state / providers.list diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs index f061e97c..be8b3cdc 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs @@ -116,6 +116,11 @@ pub(crate) fn initialize_schema(conn: &Connection) -> Result<(), String> { payload_json TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS tunnel_settings ( + tunnel_id TEXT PRIMARY KEY, + payload_json TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); CREATE INDEX IF NOT EXISTS idx_cron_execution_logs_task_started_at ON cron_execution_logs (task_id, started_at DESC); ", diff --git a/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs b/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs index de8b059e..050af29b 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/gateway.rs @@ -5,8 +5,10 @@ use serde_json::Value; use crate::commands::settings::{load_remote_settings, open_db, parse_remote_settings_payload}; use crate::services::gateway::{ GatewayChatClaimedRequest, GatewayChatQueueEventInput, GatewayChatQueueResponseInput, - GatewayChatRuntimeSnapshot, GatewayController, GatewayStatusSnapshot, GatewayTunnelCreateInput, - GatewayTunnelSummary, GatewayTunnelUpdateInput, + GatewayChatRuntimeSnapshot, GatewayController, GatewayStatusSnapshot, +}; +use crate::services::tunnel::{ + GatewayTunnelCreateInput, GatewayTunnelUpdateInput, TunnelStatePayload, }; #[tauri::command] @@ -211,17 +213,17 @@ pub async fn gateway_publish_settings_sync( } #[tauri::command] -pub async fn gateway_tunnel_list( +pub fn gateway_tunnel_state( gateway_controller: tauri::State<'_, Arc>, -) -> Result, String> { - gateway_controller.tunnel_list().await +) -> Result { + Ok(gateway_controller.tunnel_state()) } #[tauri::command] pub async fn gateway_tunnel_create( input: GatewayTunnelCreateInput, gateway_controller: tauri::State<'_, Arc>, -) -> Result { +) -> Result<(), String> { gateway_controller.tunnel_create(input).await } @@ -229,22 +231,22 @@ pub async fn gateway_tunnel_create( pub async fn gateway_tunnel_update( input: GatewayTunnelUpdateInput, gateway_controller: tauri::State<'_, Arc>, -) -> Result { +) -> Result<(), String> { gateway_controller.tunnel_update(input).await } #[tauri::command(rename_all = "snake_case")] -pub async fn gateway_tunnel_probe( +pub async fn gateway_tunnel_close( tunnel_id: String, gateway_controller: tauri::State<'_, Arc>, -) -> Result { - gateway_controller.tunnel_probe(tunnel_id).await +) -> Result<(), String> { + gateway_controller.tunnel_close(tunnel_id).await } #[tauri::command(rename_all = "snake_case")] -pub async fn gateway_tunnel_close( - tunnel_id: String, +pub async fn gateway_tunnel_check( + tunnel_id: Option, gateway_controller: tauri::State<'_, Arc>, -) -> Result { - gateway_controller.tunnel_close(tunnel_id).await +) -> Result<(), String> { + gateway_controller.tunnel_check(tunnel_id).await } diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 087e1066..be7df467 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -235,11 +235,11 @@ macro_rules! app_invoke_handler { commands::gateway::gateway_publish_chat_queue_event, commands::gateway::gateway_publish_chat_runtime_snapshot, commands::gateway::gateway_publish_settings_sync, - commands::gateway::gateway_tunnel_list, + commands::gateway::gateway_tunnel_state, commands::gateway::gateway_tunnel_create, commands::gateway::gateway_tunnel_update, - commands::gateway::gateway_tunnel_probe, commands::gateway::gateway_tunnel_close, + commands::gateway::gateway_tunnel_check, services::proxy::proxy_get_server_info, ] }; diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index 79184b08..3c1619f7 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -1,23 +1,15 @@ use std::collections::HashMap; use std::future::Future; -use std::io; -use std::net::IpAddr; use std::sync::{Arc, Mutex, Once}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use futures_util::{SinkExt, StreamExt}; -use reqwest::header::{HeaderName, HeaderValue}; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tauri::Emitter; use tokio::sync::{mpsc, oneshot, watch}; use tokio_stream::wrappers::ReceiverStream; -use tokio_tungstenite::{ - connect_async, - tungstenite::{client::IntoClientRequest, http::header::SEC_WEBSOCKET_PROTOCOL, Message}, -}; use tonic::metadata::MetadataValue; use tonic::transport::{ClientTlsConfig, Endpoint}; use uuid::Uuid; @@ -44,6 +36,7 @@ use crate::runtime::terminal::{ use crate::services::cron::CronManager; use crate::services::gateway_bridge; use crate::services::memory::MemoryStore; +use crate::services::tunnel::{TunnelProxy, TunnelStore}; pub mod proto { tonic::include_proto!("liveagent.gateway.v1"); @@ -66,7 +59,6 @@ const GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL: Duration = Duration::from_secs const GATEWAY_CHAT_LEASE_MS: u64 = 15_000; const GATEWAY_CHAT_RUNNING_LEASE_MS: u64 = 30 * 60_000; const GATEWAY_CHAT_LEASE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); -const TUNNEL_BODY_CHUNK_SIZE: usize = 64 * 1024; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -82,69 +74,6 @@ pub struct GatewayStatusSnapshot { pub last_error: Option, } -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayTunnelCreateInput { - pub target_url: String, - #[serde(default)] - pub name: Option, - pub ttl_seconds: u32, - #[serde(default)] - pub project_path_key: Option, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayTunnelUpdateInput { - pub id: String, - pub target_url: String, - #[serde(default)] - pub name: Option, - pub ttl_seconds: u32, - #[serde(default)] - pub project_path_key: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayTunnelSummary { - pub id: String, - pub slug: String, - pub name: String, - pub target_url: String, - pub public_url: String, - pub created_at: i64, - pub expires_at: i64, - pub active_connections: u32, - pub status: String, - pub project_path_key: String, - pub diagnostics: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayTunnelDiagnostic { - pub protocol: String, - pub status: String, - pub status_code: u32, - pub error_code: String, - pub message: String, - pub checked_at: i64, -} - -#[derive(Debug, Clone)] -struct LocalTunnelRecord { - summary: GatewayTunnelSummary, - target: TunnelTarget, -} - -#[derive(Debug, Clone)] -struct TunnelTarget { - url: Url, -} - -type TunnelHttpBodySender = mpsc::Sender, io::Error>>; - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct GatewaySelectedModelEvent { @@ -356,15 +285,14 @@ pub struct GatewayController { terminal_stream_tx: Mutex>>, settings_snapshot: Mutex>, remote_chat_inbox: Mutex>, - tunnels: Mutex>, - tunnel_http_streams: Mutex>, - tunnel_ws_streams: Mutex>>, - pending_tunnel_controls: Mutex>>, + pub(crate) tunnel_store: TunnelStore, + pub(crate) tunnel_proxy: TunnelProxy, pending_chat_queue_requests: Mutex>>, terminal_forwarder_once: Once, terminal_stream_forwarder_once: Once, sftp_forwarder_once: Once, remote_chat_inbox_sweeper_once: Once, + pub(crate) tunnel_store_once: Once, } impl GatewayController { @@ -377,6 +305,7 @@ impl GatewayController { ) -> Self { let initial_config = RemoteSettingsPayload::default(); let (config_tx, _) = watch::channel(initial_config); + let tunnel_store = TunnelStore::new(app_handle.clone()); Self { app_handle, cron_manager, @@ -400,15 +329,14 @@ impl GatewayController { terminal_stream_tx: Mutex::new(None), settings_snapshot: Mutex::new(None), remote_chat_inbox: Mutex::new(HashMap::new()), - tunnels: Mutex::new(HashMap::new()), - tunnel_http_streams: Mutex::new(HashMap::new()), - tunnel_ws_streams: Mutex::new(HashMap::new()), - pending_tunnel_controls: Mutex::new(HashMap::new()), + tunnel_store, + tunnel_proxy: TunnelProxy::new(), pending_chat_queue_requests: Mutex::new(HashMap::new()), terminal_forwarder_once: Once::new(), terminal_stream_forwarder_once: Once::new(), sftp_forwarder_once: Once::new(), remote_chat_inbox_sweeper_once: Once::new(), + tunnel_store_once: Once::new(), } } @@ -417,6 +345,7 @@ impl GatewayController { self.start_terminal_stream_forwarder(); self.start_sftp_forwarder(); self.start_remote_chat_inbox_sweeper(); + self.start_tunnel_store(); self.ensure_runner() } @@ -645,166 +574,6 @@ impl GatewayController { self.send_agent_envelope(envelope).await } - pub async fn tunnel_list(&self) -> Result, String> { - if !self.status().online { - return self.local_tunnel_summaries("offline"); - } - let response = self - .send_tunnel_control_request(proto::TunnelControlRequest { - action: "list".to_string(), - ..Default::default() - }) - .await?; - if !response.error_message.trim().is_empty() { - return Err(response.error_message); - } - let summaries = response - .tunnels - .into_iter() - .map(gateway_tunnel_summary_from_proto) - .collect::>(); - self.merge_tunnel_summaries(&summaries)?; - Ok(summaries) - } - - pub async fn tunnel_create( - &self, - input: GatewayTunnelCreateInput, - ) -> Result { - if !self.status().online { - return Err("gateway outbound stream is offline".to_string()); - } - let target = validate_tunnel_target_url(&input.target_url)?; - let target_url = target.url.to_string(); - let public_base_url = self.config_tx.borrow().gateway_url.clone(); - let response = self - .send_tunnel_control_request(proto::TunnelControlRequest { - action: "create".to_string(), - target_url: target_url.clone(), - name: input.name.unwrap_or_default().trim().to_string(), - ttl_seconds: normalize_tunnel_ttl(input.ttl_seconds)?, - public_base_url, - project_path_key: normalize_project_path_key( - &input.project_path_key.unwrap_or_default(), - ), - ..Default::default() - }) - .await?; - if !response.error_message.trim().is_empty() { - return Err(response.error_message); - } - let summary = response - .tunnel - .map(gateway_tunnel_summary_from_proto) - .ok_or_else(|| "gateway tunnel create returned no tunnel".to_string())?; - self.store_local_tunnel(summary.clone(), target)?; - Ok(self - .tunnel_probe(summary.id.clone()) - .await - .unwrap_or(summary)) - } - - pub async fn tunnel_update( - &self, - input: GatewayTunnelUpdateInput, - ) -> Result { - if !self.status().online { - return Err("gateway outbound stream is offline".to_string()); - } - let tunnel_id = input.id.trim().to_string(); - if tunnel_id.is_empty() { - return Err("tunnel id is required".to_string()); - } - let target = validate_tunnel_target_url(&input.target_url)?; - let ttl_seconds = normalize_tunnel_ttl(input.ttl_seconds)?; - let expires_at = tunnel_expires_at(ttl_seconds); - let response = self - .send_tunnel_control_request(proto::TunnelControlRequest { - action: "update".to_string(), - tunnel_id, - target_url: target.url.to_string(), - name: input.name.unwrap_or_default().trim().to_string(), - ttl_seconds, - expires_at, - project_path_key: normalize_project_path_key( - &input.project_path_key.unwrap_or_default(), - ), - ..Default::default() - }) - .await?; - if !response.error_message.trim().is_empty() { - return Err(response.error_message); - } - let summary = response - .tunnel - .map(gateway_tunnel_summary_from_proto) - .ok_or_else(|| "gateway tunnel update returned no tunnel".to_string())?; - self.store_local_tunnel(summary.clone(), target)?; - Ok(self - .tunnel_probe(summary.id.clone()) - .await - .unwrap_or(summary)) - } - - pub async fn tunnel_probe(&self, tunnel_id: String) -> Result { - let tunnel_id = tunnel_id.trim().to_string(); - if tunnel_id.is_empty() { - return Err("tunnel id is required".to_string()); - } - if !self.status().online { - return self - .local_tunnel_summaries("offline")? - .into_iter() - .find(|summary| summary.id == tunnel_id || summary.slug == tunnel_id) - .ok_or_else(|| "tunnel not found".to_string()); - } - let public_base_url = self.config_tx.borrow().gateway_url.clone(); - let response = self - .send_tunnel_control_request(proto::TunnelControlRequest { - action: "probe".to_string(), - tunnel_id, - public_base_url, - ..Default::default() - }) - .await?; - if !response.error_message.trim().is_empty() { - return Err(response.error_message); - } - let summary = response - .tunnel - .map(gateway_tunnel_summary_from_proto) - .ok_or_else(|| "gateway tunnel probe returned no tunnel".to_string())?; - self.merge_tunnel_summaries(&[summary.clone()])?; - Ok(summary) - } - - pub async fn tunnel_close(&self, tunnel_id: String) -> Result { - let tunnel_id = tunnel_id.trim().to_string(); - if tunnel_id.is_empty() { - return Err("tunnel id is required".to_string()); - } - if !self.status().online { - let local = self.remove_local_tunnel(&tunnel_id)?; - return Ok(local.summary); - } - let response = self - .send_tunnel_control_request(proto::TunnelControlRequest { - action: "close".to_string(), - tunnel_id: tunnel_id.clone(), - ..Default::default() - }) - .await?; - if !response.error_message.trim().is_empty() { - return Err(response.error_message); - } - let local = self.remove_local_tunnel(&tunnel_id).ok(); - Ok(response - .tunnel - .map(gateway_tunnel_summary_from_proto) - .or_else(|| local.map(|record| record.summary)) - .ok_or_else(|| "gateway tunnel close returned no tunnel".to_string())?) - } - async fn run(self: Arc, mut config_rx: watch::Receiver) { loop { let config = config_rx.borrow().clone(); @@ -948,9 +717,10 @@ impl GatewayController { if let Err(error) = self.publish_current_terminal_sessions().await { eprintln!("publish gateway terminal sessions failed: {error}"); } - if let Err(error) = self.publish_current_tunnels().await { - eprintln!("publish gateway tunnels failed: {error}"); + if let Err(error) = self.publish_desired_tunnels().await { + eprintln!("publish gateway tunnel desired state failed: {error}"); } + self.spawn_tunnel_probes(None, false); let timeout_seconds = i64::try_from(config.heartbeat_interval.max(5)).unwrap_or(30) * 3; @@ -1195,15 +965,16 @@ impl GatewayController { }) .await } - Some(proto::gateway_envelope::Payload::TunnelControlResp(response)) => { - self.resolve_pending_tunnel_control(request_id, response) + Some(proto::gateway_envelope::Payload::TunnelState(snapshot)) => { + self.handle_tunnel_state_snapshot(snapshot); + Ok(()) } - Some(proto::gateway_envelope::Payload::TunnelControl(request)) => { - self.handle_tunnel_control_request(request_id, request) - .await + Some(proto::gateway_envelope::Payload::TunnelMutation(mutation)) => { + self.handle_tunnel_mutation_request(request_id, mutation); + Ok(()) } Some(proto::gateway_envelope::Payload::TunnelFrame(frame)) => { - self.handle_tunnel_frame(frame).await + self.tunnel_proxy.handle_frame(self, frame) } Some(proto::gateway_envelope::Payload::ChatCommand(command)) => { self.handle_chat_command(request_id, command).await @@ -1217,9 +988,9 @@ impl GatewayController { self.send_agent_envelope(proto::AgentEnvelope { request_id, timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::ChatEventReplayResp(response), - ), + payload: Some(proto::agent_envelope::Payload::ChatEventReplayResp( + response, + )), }) .await } @@ -1263,9 +1034,9 @@ impl GatewayController { .send_agent_envelope(proto::AgentEnvelope { request_id: request_id.clone(), timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryListResp(response), - ), + payload: Some(proto::agent_envelope::Payload::HistoryListResp( + response, + )), }) .await } @@ -1319,9 +1090,9 @@ impl GatewayController { .send_agent_envelope(proto::AgentEnvelope { request_id: request_id.clone(), timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryGetResp(response), - ), + payload: Some(proto::agent_envelope::Payload::HistoryGetResp( + response, + )), }) .await } @@ -1347,9 +1118,7 @@ impl GatewayController { request_id: request_id.clone(), timestamp: now_unix_seconds(), payload: Some( - proto::agent_envelope::Payload::HistoryPrefixResp( - response, - ), + proto::agent_envelope::Payload::HistoryPrefixResp(response), ), }) .await @@ -1416,9 +1185,9 @@ impl GatewayController { .send_agent_envelope(proto::AgentEnvelope { request_id: request_id.clone(), timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryPinResp(response), - ), + payload: Some(proto::agent_envelope::Payload::HistoryPinResp( + response, + )), }) .await } @@ -2520,7 +2289,10 @@ impl GatewayController { } } - async fn send_agent_envelope(&self, envelope: proto::AgentEnvelope) -> Result<(), String> { + pub(crate) async fn send_agent_envelope( + &self, + envelope: proto::AgentEnvelope, + ) -> Result<(), String> { let sender = self.current_outbound_sender()?; send_agent_envelope_to(sender, envelope).await } @@ -2643,34 +2415,6 @@ impl GatewayController { Ok(()) } - async fn publish_current_tunnels(&self) -> Result<(), String> { - let tunnels = self.local_tunnel_summaries("")?; - for tunnel in tunnels { - if tunnel.status == "expired" { - continue; - } - self.send_agent_envelope(proto::AgentEnvelope { - request_id: format!("tunnel-resume-{}", tunnel.id), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TunnelControl( - proto::TunnelControlRequest { - action: "resume".to_string(), - tunnel_id: tunnel.id, - slug: tunnel.slug, - target_url: tunnel.target_url, - public_url: tunnel.public_url, - name: tunnel.name, - expires_at: tunnel.expires_at, - project_path_key: tunnel.project_path_key, - ..Default::default() - }, - )), - }) - .await?; - } - Ok(()) - } - pub async fn refresh_settings_sync_from_db(&self) -> Result { let snapshot = self.current_settings_snapshot().await?; self.app_handle @@ -3759,1346 +3503,68 @@ impl GatewayController { }) .await } +} - async fn send_tunnel_control_request( - &self, - request: proto::TunnelControlRequest, - ) -> Result { - let request_id = format!("tunnel-control-{}", Uuid::new_v4()); - let (tx, rx) = oneshot::channel(); - self.pending_tunnel_controls - .lock() - .map_err(|_| "gateway tunnel control lock poisoned".to_string())? - .insert(request_id.clone(), tx); - - let send_result = self - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TunnelControl(request)), - }) - .await; - if let Err(error) = send_result { - let _ = self - .pending_tunnel_controls - .lock() - .map(|mut pending| pending.remove(&request_id)); - return Err(error); - } +async fn await_abortable_on_reconfigure( + config: &RemoteSettingsPayload, + config_rx: &mut watch::Receiver, + fut: impl Future>, +) -> Result, String> { + tokio::pin!(fut); - match tokio::time::timeout(Duration::from_secs(30), rx).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(_)) => Err("gateway tunnel control response dropped".to_string()), - Err(_) => { - let _ = self - .pending_tunnel_controls - .lock() - .map(|mut pending| pending.remove(&request_id)); - Err("gateway tunnel control timed out".to_string()) + loop { + tokio::select! { + result = &mut fut => return result.map(Some), + changed = config_rx.changed() => { + if changed.is_err() { + return Ok(None); + } + let next = config_rx.borrow().clone(); + if next != *config { + return Ok(None); + } } } } +} - fn resolve_pending_tunnel_control( - &self, - request_id: String, - response: proto::TunnelControlResponse, - ) -> Result<(), String> { - let sender = self - .pending_tunnel_controls - .lock() - .map_err(|_| "gateway tunnel control lock poisoned".to_string())? - .remove(request_id.trim()); - if let Some(sender) = sender { - let _ = sender.send(response); - } - Ok(()) - } - - async fn handle_tunnel_control_request( - self: &Arc, - request_id: String, - request: proto::TunnelControlRequest, - ) -> Result<(), String> { - let response = self.handle_tunnel_control_request_inner(request); - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TunnelControlResp(response)), - }) - .await - } +fn merge_settings_sync_snapshot(snapshot: Value, cached: Option<&Value>) -> Result { + let mut merged = match snapshot { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; - fn handle_tunnel_control_request_inner( - &self, - request: proto::TunnelControlRequest, - ) -> proto::TunnelControlResponse { - let action = request.action.trim().to_ascii_lowercase(); - match action.as_str() { - "list" => proto::TunnelControlResponse { - action, - tunnels: self - .local_tunnel_summaries("active") - .unwrap_or_default() - .into_iter() - .map(proto_tunnel_summary_from_gateway) - .collect(), - tunnel: None, - error_code: String::new(), - error_message: String::new(), - }, - "create" => match self.create_local_tunnel_from_control(&request) { - Ok(summary) => proto::TunnelControlResponse { - action, - tunnels: self - .local_tunnel_summaries("active") - .unwrap_or_default() - .into_iter() - .map(proto_tunnel_summary_from_gateway) - .collect(), - tunnel: Some(proto_tunnel_summary_from_gateway(summary)), - error_code: String::new(), - error_message: String::new(), - }, - Err(error) => tunnel_control_error_response(action, "invalid_target", error), - }, - "update" => match self.update_local_tunnel_from_control(&request) { - Ok(summary) => proto::TunnelControlResponse { - action, - tunnels: self - .local_tunnel_summaries("active") - .unwrap_or_default() - .into_iter() - .map(proto_tunnel_summary_from_gateway) - .collect(), - tunnel: Some(proto_tunnel_summary_from_gateway(summary)), - error_code: String::new(), - error_message: String::new(), - }, - Err(error) => tunnel_control_error_response(action, "not_found", error), - }, - "close" => { - let identifier = if request.tunnel_id.trim().is_empty() { - request.slug.trim().to_string() - } else { - request.tunnel_id.trim().to_string() - }; - match self.remove_local_tunnel(&identifier) { - Ok(record) => proto::TunnelControlResponse { - action, - tunnels: self - .local_tunnel_summaries("active") - .unwrap_or_default() - .into_iter() - .map(proto_tunnel_summary_from_gateway) - .collect(), - tunnel: Some(proto_tunnel_summary_from_gateway(record.summary)), - error_code: String::new(), - error_message: String::new(), - }, - Err(error) => tunnel_control_error_response(action, "not_found", error), - } + if let Some(Value::Object(cached_map)) = cached { + for field in UI_ONLY_SETTINGS_SYNC_FIELDS { + if let Some(value) = cached_map.get(*field) { + merged.insert((*field).to_string(), value.clone()); } - "resume" => match self.resume_local_tunnel_from_control(&request) { - Ok(summary) => proto::TunnelControlResponse { - action, - tunnels: self - .local_tunnel_summaries("active") - .unwrap_or_default() - .into_iter() - .map(proto_tunnel_summary_from_gateway) - .collect(), - tunnel: Some(proto_tunnel_summary_from_gateway(summary)), - error_code: String::new(), - error_message: String::new(), - }, - Err(error) => tunnel_control_error_response(action, "not_found", error), - }, - _ => tunnel_control_error_response( - action, - "invalid_action", - "unsupported tunnel action".to_string(), - ), } } - async fn handle_tunnel_frame( - self: &Arc, - frame: proto::TunnelFrame, - ) -> Result<(), String> { - match frame.kind() { - proto::TunnelFrameKind::HttpRequestStart => { - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = frame.tunnel_id.clone(); - let slug = frame.slug.clone(); - if let Err(error) = self.start_tunnel_http_stream(frame).await { - self.spawn_tunnel_frame_error(stream_id, tunnel_id, slug, error); - } - Ok(()) - } - proto::TunnelFrameKind::HttpRequestBody => { - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = frame.tunnel_id.clone(); - let slug = frame.slug.clone(); - let sender = self - .tunnel_http_streams - .lock() - .map_err(|_| "gateway tunnel http stream lock poisoned".to_string())? - .get(&stream_id) - .cloned(); - if let Some(sender) = sender { - match sender.try_send(Ok(frame.body)) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - self.remove_tunnel_http_stream(&stream_id); - self.spawn_tunnel_frame_error( - stream_id, - tunnel_id, - slug, - "local tunnel request body queue is full".to_string(), - ); - } - Err(mpsc::error::TrySendError::Closed(_)) => {} - } - } - Ok(()) - } - proto::TunnelFrameKind::HttpRequestEnd => { - self.remove_tunnel_http_stream(&frame.stream_id); - Ok(()) - } - proto::TunnelFrameKind::WsDial => { - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = frame.tunnel_id.clone(); - let slug = frame.slug.clone(); - if let Err(error) = self.start_tunnel_ws_stream(frame).await { - self.spawn_tunnel_frame_error(stream_id, tunnel_id, slug, error); - } - Ok(()) - } - proto::TunnelFrameKind::WsFrame - | proto::TunnelFrameKind::WsClose - | proto::TunnelFrameKind::Cancel => { - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = frame.tunnel_id.clone(); - let slug = frame.slug.clone(); - let terminal_frame = matches!( - frame.kind(), - proto::TunnelFrameKind::WsClose | proto::TunnelFrameKind::Cancel - ); - if matches!(frame.kind(), proto::TunnelFrameKind::Cancel) { - self.remove_tunnel_http_stream(&stream_id); - } - let sender = self - .tunnel_ws_streams - .lock() - .map_err(|_| "gateway tunnel websocket stream lock poisoned".to_string())? - .get(&stream_id) - .cloned(); - if let Some(sender) = sender { - match sender.try_send(frame) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - self.remove_tunnel_ws_stream(&stream_id); - if !terminal_frame { - self.spawn_tunnel_frame_error( - stream_id, - tunnel_id, - slug, - "local tunnel websocket queue is full".to_string(), - ); - } - } - Err(mpsc::error::TrySendError::Closed(_)) => {} - } - } - Ok(()) - } - _ => Ok(()), - } - } + Ok(Value::Object(merged)) +} - fn spawn_tunnel_frame_error( - self: &Arc, - stream_id: String, - tunnel_id: String, - slug: String, - error: String, - ) { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: proto::TunnelFrameKind::Error as i32, - error, - ..Default::default() - }, - ) - .await; - }); +pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistorySyncEvent { + GatewayHistorySyncEvent { + kind: "upsert".to_string(), + conversation_id: summary.id.clone(), + conversation: Some(GatewayHistorySyncConversation { + id: summary.id.clone(), + title: summary.title.clone(), + provider_id: Some(summary.provider_id.clone()), + model: Some(summary.model.clone()), + session_id: summary.session_id.clone(), + cwd: summary.cwd.clone(), + created_at: summary.created_at, + updated_at: summary.updated_at, + message_count: summary.message_count, + is_pinned: summary.is_pinned, + pinned_at: summary.pinned_at, + is_shared: summary.is_shared, + }), } - - async fn start_tunnel_http_stream( - self: &Arc, - frame: proto::TunnelFrame, - ) -> Result<(), String> { - let record = self.find_local_tunnel(&frame.tunnel_id, &frame.slug)?; - let upstream_url = build_tunnel_upstream_url(&record.target.url, &frame.path)?; - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = record.summary.id.clone(); - let slug = record.summary.slug.clone(); - let method = frame.method.trim().to_string(); - let headers = frame.headers.clone(); - let (body_tx, body_rx) = mpsc::channel::, io::Error>>(64); - self.tunnel_http_streams - .lock() - .map_err(|_| "gateway tunnel http stream lock poisoned".to_string())? - .insert(stream_id.clone(), body_tx); - - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - run_tunnel_http_request( - controller, - stream_id, - tunnel_id, - slug, - method, - upstream_url, - headers, - body_rx, - ) - .await; - }); - Ok(()) - } - - async fn start_tunnel_ws_stream( - self: &Arc, - frame: proto::TunnelFrame, - ) -> Result<(), String> { - let record = self.find_local_tunnel(&frame.tunnel_id, &frame.slug)?; - let upstream_url = build_tunnel_upstream_ws_url(&record.target.url, &frame.path)?; - let stream_id = frame.stream_id.trim().to_string(); - let tunnel_id = record.summary.id.clone(); - let slug = record.summary.slug.clone(); - let headers = frame.headers.clone(); - let (gateway_tx, gateway_rx) = mpsc::channel::(128); - self.tunnel_ws_streams - .lock() - .map_err(|_| "gateway tunnel websocket stream lock poisoned".to_string())? - .insert(stream_id.clone(), gateway_tx); - - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - run_tunnel_websocket( - controller, - stream_id, - tunnel_id, - slug, - upstream_url, - headers, - gateway_rx, - ) - .await; - }); - Ok(()) - } - - fn create_local_tunnel_from_control( - &self, - request: &proto::TunnelControlRequest, - ) -> Result { - if request.tunnel_id.trim().is_empty() || request.slug.trim().is_empty() { - return Err("tunnel id and slug are required".to_string()); - } - let target = validate_tunnel_target_url(&request.target_url)?; - let summary = GatewayTunnelSummary { - id: request.tunnel_id.trim().to_string(), - slug: request.slug.trim().to_string(), - name: request.name.trim().to_string(), - target_url: target.url.to_string(), - public_url: request.public_url.trim().to_string(), - created_at: now_unix_seconds(), - expires_at: request.expires_at, - active_connections: 0, - status: "active".to_string(), - project_path_key: normalize_project_path_key(&request.project_path_key), - diagnostics: Vec::new(), - }; - self.store_local_tunnel(summary.clone(), target)?; - Ok(summary) - } - - fn resume_local_tunnel_from_control( - &self, - request: &proto::TunnelControlRequest, - ) -> Result { - let identifier = if request.tunnel_id.trim().is_empty() { - request.slug.trim() - } else { - request.tunnel_id.trim() - }; - let mut guard = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())?; - let record = guard - .values_mut() - .find(|record| record.summary.id == identifier || record.summary.slug == identifier) - .ok_or_else(|| "tunnel not found".to_string())?; - if !request.target_url.trim().is_empty() { - let target = validate_tunnel_target_url(&request.target_url)?; - record.summary.target_url = target.url.to_string(); - record.target = target; - } - if !request.public_url.trim().is_empty() { - record.summary.public_url = request.public_url.trim().to_string(); - } - if request.expires_at > 0 { - record.summary.expires_at = request.expires_at; - } - if !request.project_path_key.trim().is_empty() { - record.summary.project_path_key = normalize_project_path_key(&request.project_path_key); - } - record.summary.project_path_key = - normalize_project_path_key(&record.summary.project_path_key); - record.summary.status = "active".to_string(); - Ok(record.summary.clone()) - } - - fn update_local_tunnel_from_control( - &self, - request: &proto::TunnelControlRequest, - ) -> Result { - let identifier = if request.tunnel_id.trim().is_empty() { - request.slug.trim() - } else { - request.tunnel_id.trim() - }; - if identifier.is_empty() { - return Err("tunnel id is required".to_string()); - } - let target = validate_tunnel_target_url(&request.target_url)?; - let ttl_seconds = normalize_tunnel_ttl(request.ttl_seconds)?; - let expires_at = if request.expires_at > 0 { - request.expires_at - } else { - tunnel_expires_at(ttl_seconds) - }; - let mut guard = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())?; - let record = guard - .values_mut() - .find(|record| record.summary.id == identifier || record.summary.slug == identifier) - .ok_or_else(|| "tunnel not found".to_string())?; - record.summary.target_url = target.url.to_string(); - record.target = target; - record.summary.name = request.name.trim().to_string(); - record.summary.expires_at = expires_at; - if !request.public_url.trim().is_empty() { - record.summary.public_url = request.public_url.trim().to_string(); - } - if !request.project_path_key.trim().is_empty() { - record.summary.project_path_key = normalize_project_path_key(&request.project_path_key); - } - record.summary.project_path_key = - normalize_project_path_key(&record.summary.project_path_key); - record.summary.status = "active".to_string(); - Ok(record.summary.clone()) - } - - fn store_local_tunnel( - &self, - mut summary: GatewayTunnelSummary, - target: TunnelTarget, - ) -> Result<(), String> { - if summary.id.trim().is_empty() { - return Err("tunnel id is required".to_string()); - } - summary.project_path_key = normalize_project_path_key(&summary.project_path_key); - self.tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())? - .insert(summary.id.clone(), LocalTunnelRecord { summary, target }); - Ok(()) - } - - fn merge_tunnel_summaries(&self, summaries: &[GatewayTunnelSummary]) -> Result<(), String> { - let mut guard = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())?; - for summary in summaries { - if let Some(record) = guard.get_mut(&summary.id) { - record.summary = summary.clone(); - } - } - Ok(()) - } - - fn local_tunnel_summaries( - &self, - offline_status: &str, - ) -> Result, String> { - let now = now_unix_seconds(); - let mut summaries = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())? - .values() - .map(|record| { - let mut summary = record.summary.clone(); - summary.project_path_key = normalize_project_path_key(&summary.project_path_key); - if summary.expires_at > 0 && summary.expires_at <= now { - summary.status = "expired".to_string(); - } else if !offline_status.trim().is_empty() { - summary.status = offline_status.trim().to_string(); - } - summary - }) - .collect::>(); - summaries.sort_by_key(|summary| summary.created_at); - Ok(summaries) - } - - fn remove_local_tunnel(&self, identifier: &str) -> Result { - let identifier = identifier.trim(); - if identifier.is_empty() { - return Err("tunnel id is required".to_string()); - } - let mut guard = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())?; - if let Some(record) = guard.remove(identifier) { - return Ok(record); - } - let matched_id = guard - .iter() - .find_map(|(id, record)| (record.summary.slug == identifier).then(|| id.clone())) - .ok_or_else(|| "tunnel not found".to_string())?; - guard - .remove(&matched_id) - .ok_or_else(|| "tunnel not found".to_string()) - } - - fn find_local_tunnel(&self, tunnel_id: &str, slug: &str) -> Result { - let tunnel_id = tunnel_id.trim(); - let slug = slug.trim(); - let now = now_unix_seconds(); - let guard = self - .tunnels - .lock() - .map_err(|_| "gateway tunnel registry lock poisoned".to_string())?; - let record = if !tunnel_id.is_empty() { - guard.get(tunnel_id) - } else { - guard.values().find(|record| record.summary.slug == slug) - } - .ok_or_else(|| "tunnel not found".to_string())?; - if record.summary.expires_at > 0 && record.summary.expires_at <= now { - return Err("tunnel expired".to_string()); - } - Ok(record.clone()) - } - - fn remove_tunnel_http_stream(&self, stream_id: &str) { - if let Ok(mut streams) = self.tunnel_http_streams.lock() { - streams.remove(stream_id.trim()); - } - } - - fn remove_tunnel_ws_stream(&self, stream_id: &str) { - if let Ok(mut streams) = self.tunnel_ws_streams.lock() { - streams.remove(stream_id.trim()); - } - } -} - -fn normalize_tunnel_ttl(input: u32) -> Result { - match input { - 0 | 900 | 3600 | 14400 => Ok(input), - _ => Err("ttlSeconds must be one of 0, 900, 3600, or 14400".to_string()), - } -} - -fn tunnel_expires_at(ttl_seconds: u32) -> i64 { - if ttl_seconds == 0 { - return 0; - } - now_unix_seconds() + i64::from(ttl_seconds) -} - -fn validate_tunnel_target_url(input: &str) -> Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - return Err("targetUrl is required".to_string()); - } - let mut url = Url::parse(trimmed).map_err(|e| format!("invalid targetUrl: {e}"))?; - if url.scheme() != "http" { - return Err("targetUrl must use http".to_string()); - } - if !url.username().is_empty() || url.password().is_some() { - return Err("targetUrl must not include credentials".to_string()); - } - if url.fragment().is_some() { - return Err("targetUrl must not include a fragment".to_string()); - } - let host = url - .host_str() - .map(|value| value.trim().to_ascii_lowercase()) - .ok_or_else(|| "targetUrl host is required".to_string())?; - let host = host.trim_start_matches('[').trim_end_matches(']'); - if host != "localhost" && host.parse::().is_err() { - return Err("targetUrl host must be localhost or an IP address".to_string()); - } - url.set_fragment(None); - Ok(TunnelTarget { url }) -} - -fn gateway_tunnel_summary_from_proto(summary: proto::TunnelSummary) -> GatewayTunnelSummary { - GatewayTunnelSummary { - id: summary.id, - slug: summary.slug, - name: summary.name, - target_url: summary.target_url, - public_url: summary.public_url, - created_at: summary.created_at, - expires_at: summary.expires_at, - active_connections: summary.active_connections, - status: summary.status, - project_path_key: normalize_project_path_key(&summary.project_path_key), - diagnostics: summary - .diagnostics - .into_iter() - .map(gateway_tunnel_diagnostic_from_proto) - .collect(), - } -} - -fn proto_tunnel_summary_from_gateway(summary: GatewayTunnelSummary) -> proto::TunnelSummary { - proto::TunnelSummary { - id: summary.id, - slug: summary.slug, - name: summary.name, - target_url: summary.target_url, - public_url: summary.public_url, - created_at: summary.created_at, - expires_at: summary.expires_at, - active_connections: summary.active_connections, - status: summary.status, - project_path_key: normalize_project_path_key(&summary.project_path_key), - diagnostics: summary - .diagnostics - .into_iter() - .map(proto_tunnel_diagnostic_from_gateway) - .collect(), - } -} - -fn gateway_tunnel_diagnostic_from_proto( - diagnostic: proto::TunnelDiagnostic, -) -> GatewayTunnelDiagnostic { - GatewayTunnelDiagnostic { - protocol: diagnostic.protocol, - status: diagnostic.status, - status_code: diagnostic.status_code, - error_code: diagnostic.error_code, - message: diagnostic.message, - checked_at: diagnostic.checked_at, - } -} - -fn proto_tunnel_diagnostic_from_gateway( - diagnostic: GatewayTunnelDiagnostic, -) -> proto::TunnelDiagnostic { - proto::TunnelDiagnostic { - protocol: diagnostic.protocol, - status: diagnostic.status, - status_code: diagnostic.status_code, - error_code: diagnostic.error_code, - message: diagnostic.message, - checked_at: diagnostic.checked_at, - } -} - -fn tunnel_control_error_response( - action: String, - code: &str, - message: String, -) -> proto::TunnelControlResponse { - proto::TunnelControlResponse { - action, - tunnels: Vec::new(), - tunnel: None, - error_code: code.to_string(), - error_message: message, - } -} - -fn split_tunnel_path_and_query(input: &str) -> (&str, Option<&str>) { - let trimmed = input.trim(); - match trimmed.split_once('?') { - Some((path, query)) => (path, Some(query)), - None => (trimmed, None), - } -} - -fn normalize_tunnel_path(path: &str) -> String { - let trimmed = path.trim(); - if trimmed.is_empty() { - return "/".to_string(); - } - if trimmed.starts_with('/') { - trimmed.to_string() - } else { - format!("/{trimmed}") - } -} - -fn join_tunnel_paths(base_path: &str, rest_path: &str) -> String { - let base = normalize_tunnel_path(base_path); - let rest = normalize_tunnel_path(rest_path); - if base == "/" { - return rest; - } - if rest == "/" { - return format!("{}/", base.trim_end_matches('/')); - } - format!( - "{}/{}", - base.trim_end_matches('/'), - rest.trim_start_matches('/') - ) -} - -fn build_tunnel_upstream_url(base: &Url, public_path: &str) -> Result { - let (path, query) = split_tunnel_path_and_query(public_path); - let mut url = base.clone(); - let joined_path = if tunnel_probe_kind_from_path(path).is_some() { - normalize_tunnel_path(path) - } else { - join_tunnel_paths(url.path(), path) - }; - url.set_path(&joined_path); - url.set_query(query.filter(|value| !value.is_empty())); - url.set_fragment(None); - Ok(url) -} - -fn build_tunnel_upstream_ws_url(base: &Url, public_path: &str) -> Result { - let mut url = build_tunnel_upstream_url(base, public_path)?; - url.set_scheme("ws") - .map_err(|_| "failed to build websocket tunnel target URL".to_string())?; - Ok(url) -} - -fn should_drop_tunnel_header(name: &str, request: bool) -> bool { - match name.to_ascii_lowercase().as_str() { - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "proxy-connection" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" => true, - "host" => request, - _ => false, - } -} - -fn apply_tunnel_request_headers( - mut builder: reqwest::RequestBuilder, - headers: &[proto::TunnelHeader], -) -> reqwest::RequestBuilder { - for header in headers { - let name = header.name.trim(); - if name.is_empty() || should_drop_tunnel_header(name, true) { - continue; - } - let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { - continue; - }; - let Ok(header_value) = HeaderValue::from_str(&header.value) else { - continue; - }; - builder = builder.header(header_name, header_value); - } - builder -} - -fn tunnel_response_headers(headers: &reqwest::header::HeaderMap) -> Vec { - let mut out = Vec::new(); - for (name, value) in headers.iter() { - let name_text = name.as_str(); - if should_drop_tunnel_header(name_text, false) { - continue; - } - let Ok(value_text) = value.to_str() else { - continue; - }; - out.push(proto::TunnelHeader { - name: name_text.to_string(), - value: value_text.to_string(), - }); - } - out -} - -async fn run_tunnel_http_request( - controller: Arc, - stream_id: String, - tunnel_id: String, - slug: String, - method: String, - upstream_url: Url, - headers: Vec, - body_rx: mpsc::Receiver, io::Error>>, -) { - let result = async { - if let Some(probe) = tunnel_probe_kind_from_path(upstream_url.path()) { - return send_tunnel_http_probe_response( - &controller, - &stream_id, - &tunnel_id, - &slug, - probe, - ) - .await; - } - - let method = reqwest::Method::from_bytes(method.trim().as_bytes()) - .map_err(|e| format!("invalid tunnel request method: {e}"))?; - let body = reqwest::Body::wrap_stream(ReceiverStream::new(body_rx)); - let client = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| format!("failed to build local tunnel HTTP client: {e}"))?; - let request = - apply_tunnel_request_headers(client.request(method, upstream_url).body(body), &headers); - let response = request - .send() - .await - .map_err(|e| format!("local tunnel request failed: {e}"))?; - let status_code = u32::from(response.status().as_u16()); - let response_headers = tunnel_response_headers(response.headers()); - send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::HttpResponseStart as i32, - status_code, - headers: response_headers, - ..Default::default() - }, - ) - .await?; - - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| format!("local tunnel response stream failed: {e}"))?; - if chunk.is_empty() { - continue; - } - for part in chunk.chunks(TUNNEL_BODY_CHUNK_SIZE) { - send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::HttpResponseBody as i32, - body: part.to_vec(), - ..Default::default() - }, - ) - .await?; - } - } - - send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::HttpResponseEnd as i32, - end_stream: true, - ..Default::default() - }, - ) - .await - } - .await; - - controller.remove_tunnel_http_stream(&stream_id); - if let Err(error) = result { - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: proto::TunnelFrameKind::Error as i32, - error, - ..Default::default() - }, - ) - .await; - } -} - -async fn run_tunnel_websocket( - controller: Arc, - stream_id: String, - tunnel_id: String, - slug: String, - upstream_url: Url, - headers: Vec, - mut gateway_rx: mpsc::Receiver, -) { - if tunnel_probe_kind_from_path(upstream_url.path()).as_deref() == Some("ws") { - run_tunnel_websocket_probe(controller, stream_id, tunnel_id, slug, gateway_rx).await; - return; - } - - let mut request = match upstream_url.as_str().into_client_request() { - Ok(request) => request, - Err(error) => { - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: proto::TunnelFrameKind::WsDialError as i32, - error: format!("invalid local tunnel websocket request: {error}"), - ..Default::default() - }, - ) - .await; - return; - } - }; - apply_tunnel_ws_request_headers(&mut request, &headers, &upstream_url); - let (ws_stream, response) = match connect_async(request).await { - Ok(result) => result, - Err(error) => { - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: proto::TunnelFrameKind::WsDialError as i32, - error: format!("local tunnel websocket failed: {error}"), - ..Default::default() - }, - ) - .await; - return; - } - }; - let selected_protocol = response - .headers() - .get(SEC_WEBSOCKET_PROTOCOL) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .trim() - .to_string(); - - if let Err(error) = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::WsDialOk as i32, - ws_protocol: selected_protocol, - ..Default::default() - }, - ) - .await - { - controller.remove_tunnel_ws_stream(&stream_id); - let _ = error; - return; - } - - let result = async { - let (mut local_write, mut local_read) = ws_stream.split(); - loop { - tokio::select! { - incoming = gateway_rx.recv() => { - let Some(frame) = incoming else { - break; - }; - match frame.kind() { - proto::TunnelFrameKind::WsFrame => { - if frame.ws_message_type.eq_ignore_ascii_case("text") { - let text = String::from_utf8_lossy(&frame.body).to_string(); - local_write - .send(Message::Text(text.into())) - .await - .map_err(|e| format!("local websocket send failed: {e}"))?; - } else { - local_write - .send(Message::Binary(frame.body.into())) - .await - .map_err(|e| format!("local websocket send failed: {e}"))?; - } - } - proto::TunnelFrameKind::WsClose | proto::TunnelFrameKind::Cancel => { - let _ = local_write.send(Message::Close(None)).await; - break; - } - _ => {} - } - } - local = local_read.next() => { - let Some(local) = local else { - break; - }; - let message = local.map_err(|e| format!("local websocket read failed: {e}"))?; - match message { - Message::Text(text) => { - send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::WsFrame as i32, - ws_message_type: "text".to_string(), - body: text.to_string().into_bytes(), - ..Default::default() - }, - ) - .await?; - } - Message::Binary(data) => { - send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::WsFrame as i32, - ws_message_type: "binary".to_string(), - body: data.to_vec(), - ..Default::default() - }, - ) - .await?; - } - Message::Ping(data) => { - let _ = local_write.send(Message::Pong(data)).await; - } - Message::Close(_) => { - break; - } - Message::Pong(_) | Message::Frame(_) => {} - } - } - } - } - Ok::<(), String>(()) - } - .await; - - controller.remove_tunnel_ws_stream(&stream_id); - let kind = if result.is_ok() { - proto::TunnelFrameKind::WsClose - } else { - proto::TunnelFrameKind::Error - }; - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: kind as i32, - error: result.err().unwrap_or_default(), - ..Default::default() - }, - ) - .await; -} - -async fn send_tunnel_http_probe_response( - controller: &GatewayController, - stream_id: &str, - tunnel_id: &str, - slug: &str, - probe: String, -) -> Result<(), String> { - let (status_code, content_type, body) = match probe.as_str() { - "sse" => ( - 200, - "text/event-stream; charset=utf-8", - b"event: liveagent-probe\ndata: ok\n\n".to_vec(), - ), - _ => (204, "text/plain; charset=utf-8", Vec::new()), - }; - send_tunnel_frame( - controller, - proto::TunnelFrame { - stream_id: stream_id.to_string(), - tunnel_id: tunnel_id.to_string(), - slug: slug.to_string(), - kind: proto::TunnelFrameKind::HttpResponseStart as i32, - status_code, - headers: vec![proto::TunnelHeader { - name: "content-type".to_string(), - value: content_type.to_string(), - }], - ..Default::default() - }, - ) - .await?; - if !body.is_empty() { - send_tunnel_frame( - controller, - proto::TunnelFrame { - stream_id: stream_id.to_string(), - tunnel_id: tunnel_id.to_string(), - slug: slug.to_string(), - kind: proto::TunnelFrameKind::HttpResponseBody as i32, - body, - ..Default::default() - }, - ) - .await?; - } - send_tunnel_frame( - controller, - proto::TunnelFrame { - stream_id: stream_id.to_string(), - tunnel_id: tunnel_id.to_string(), - slug: slug.to_string(), - kind: proto::TunnelFrameKind::HttpResponseEnd as i32, - end_stream: true, - ..Default::default() - }, - ) - .await -} - -async fn run_tunnel_websocket_probe( - controller: Arc, - stream_id: String, - tunnel_id: String, - slug: String, - mut gateway_rx: mpsc::Receiver, -) { - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::WsDialOk as i32, - ..Default::default() - }, - ) - .await; - while let Some(frame) = gateway_rx.recv().await { - match frame.kind() { - proto::TunnelFrameKind::WsFrame => { - let body = if frame.body == b"ping" { - b"pong".to_vec() - } else { - frame.body - }; - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id: stream_id.clone(), - tunnel_id: tunnel_id.clone(), - slug: slug.clone(), - kind: proto::TunnelFrameKind::WsFrame as i32, - ws_message_type: frame.ws_message_type, - body, - ..Default::default() - }, - ) - .await; - } - proto::TunnelFrameKind::WsClose | proto::TunnelFrameKind::Cancel => break, - _ => {} - } - } - controller.remove_tunnel_ws_stream(&stream_id); - let _ = send_tunnel_frame( - &controller, - proto::TunnelFrame { - stream_id, - tunnel_id, - slug, - kind: proto::TunnelFrameKind::WsClose as i32, - ..Default::default() - }, - ) - .await; -} - -fn tunnel_probe_kind_from_path(path: &str) -> Option { - let normalized = normalize_tunnel_path(path); - normalized - .strip_prefix("/.liveagent-tunnel-probe/") - .map(str::trim) - .filter(|value| matches!(*value, "http" | "sse" | "ws")) - .map(ToString::to_string) -} - -fn apply_tunnel_ws_request_headers( - request: &mut tokio_tungstenite::tungstenite::http::Request<()>, - headers: &[proto::TunnelHeader], - upstream_url: &Url, -) { - let target_origin = tunnel_target_origin(upstream_url); - for header in headers { - let name = header.name.trim(); - if name.is_empty() || should_drop_tunnel_ws_header(name) { - continue; - } - let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { - continue; - }; - let value = if header_name.as_str().eq_ignore_ascii_case("origin") { - target_origin.as_str() - } else { - header.value.as_str() - }; - let Ok(header_value) = HeaderValue::from_str(value) else { - continue; - }; - request.headers_mut().append(header_name, header_value); - } - if !headers - .iter() - .any(|header| header.name.eq_ignore_ascii_case("origin")) - { - if let Ok(header_value) = HeaderValue::from_str(&target_origin) { - request - .headers_mut() - .insert(HeaderName::from_static("origin"), header_value); - } - } -} - -fn should_drop_tunnel_ws_header(name: &str) -> bool { - if should_drop_tunnel_header(name, true) { - return true; - } - matches!( - name.to_ascii_lowercase().as_str(), - "sec-websocket-key" - | "sec-websocket-version" - | "sec-websocket-extensions" - | "sec-websocket-accept" - ) -} - -fn tunnel_target_origin(url: &Url) -> String { - match url.port() { - Some(port) => format!( - "{}://{}:{port}", - url.scheme(), - url.host_str().unwrap_or("localhost") - ), - None => format!( - "{}://{}", - url.scheme(), - url.host_str().unwrap_or("localhost") - ), - } -} - -async fn send_tunnel_frame( - controller: &GatewayController, - frame: proto::TunnelFrame, -) -> Result<(), String> { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: format!("tunnel-frame-{}", frame.stream_id.trim()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TunnelFrame(frame)), - }) - .await -} - -async fn await_abortable_on_reconfigure( - config: &RemoteSettingsPayload, - config_rx: &mut watch::Receiver, - fut: impl Future>, -) -> Result, String> { - tokio::pin!(fut); - - loop { - tokio::select! { - result = &mut fut => return result.map(Some), - changed = config_rx.changed() => { - if changed.is_err() { - return Ok(None); - } - let next = config_rx.borrow().clone(); - if next != *config { - return Ok(None); - } - } - } - } -} - -fn merge_settings_sync_snapshot(snapshot: Value, cached: Option<&Value>) -> Result { - let mut merged = match snapshot { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - - if let Some(Value::Object(cached_map)) = cached { - for field in UI_ONLY_SETTINGS_SYNC_FIELDS { - if let Some(value) = cached_map.get(*field) { - merged.insert((*field).to_string(), value.clone()); - } - } - } - - Ok(Value::Object(merged)) -} - -pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistorySyncEvent { - GatewayHistorySyncEvent { - kind: "upsert".to_string(), - conversation_id: summary.id.clone(), - conversation: Some(GatewayHistorySyncConversation { - id: summary.id.clone(), - title: summary.title.clone(), - provider_id: Some(summary.provider_id.clone()), - model: Some(summary.model.clone()), - session_id: summary.session_id.clone(), - cwd: summary.cwd.clone(), - created_at: summary.created_at, - updated_at: summary.updated_at, - message_count: summary.message_count, - is_pinned: summary.is_pinned, - pinned_at: summary.pinned_at, - is_shared: summary.is_shared, - }), - } -} +} pub fn build_history_sync_delete(conversation_id: impl Into) -> GatewayHistorySyncEvent { let conversation_id = conversation_id.into(); @@ -5664,14 +4130,12 @@ fn serialize_settings_sync_payload(payload: &Value) -> Result { mod tests { use super::{ build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, - build_grpc_url, build_local_settings_update_event_payload, build_tunnel_upstream_url, + build_grpc_url, build_local_settings_update_event_payload, format_gateway_terminal_stream_rpc_error, history_share_resolve_error_code, - merge_settings_sync_snapshot, normalize_tunnel_ttl, proto, - queue_terminal_stream_handshake_frame, required_terminal_project_path_key, - set_disconnected_status, tunnel_expires_at, validate_tunnel_target_url, - GatewayChatRequestEvent, GatewayChatRuntimeSnapshot, GatewayController, - GatewayStatusSnapshot, RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, - GATEWAY_CHAT_RUNNING_LEASE_MS, + merge_settings_sync_snapshot, proto, queue_terminal_stream_handshake_frame, + required_terminal_project_path_key, set_disconnected_status, GatewayChatRequestEvent, + GatewayChatRuntimeSnapshot, GatewayController, GatewayStatusSnapshot, + RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, GATEWAY_CHAT_RUNNING_LEASE_MS, }; use crate::commands::settings::RemoteSettingsPayload; use serde_json::{json, Value}; @@ -5968,61 +4432,6 @@ mod tests { ); } - #[test] - fn validate_tunnel_target_url_accepts_localhost_and_ip_http_targets() { - for value in [ - "http://localhost:3000", - "http://127.0.0.1:8080/app", - "http://[::1]:5173", - "http://192.168.1.5:3000", - "http://10.0.0.20:8080/app", - "http://[fd00::1]:5173", - ] { - assert!(validate_tunnel_target_url(value).is_ok(), "{value}"); - } - - for value in [ - "https://localhost:3000", - "http://example.com", - "http://user:pass@localhost:3000", - "http://localhost:3000/#fragment", - ] { - assert!(validate_tunnel_target_url(value).is_err(), "{value}"); - } - } - - #[test] - fn build_tunnel_upstream_url_preserves_base_path_and_query() { - let target = validate_tunnel_target_url("http://localhost:3000/app").unwrap(); - let upstream = build_tunnel_upstream_url(&target.url, "/api/users?page=1").unwrap(); - assert_eq!( - upstream.as_str(), - "http://localhost:3000/app/api/users?page=1" - ); - - let root_target = validate_tunnel_target_url("http://127.0.0.1:5173").unwrap(); - let root_upstream = build_tunnel_upstream_url(&root_target.url, "/").unwrap(); - assert_eq!(root_upstream.as_str(), "http://127.0.0.1:5173/"); - } - - #[test] - fn build_tunnel_upstream_url_keeps_probe_paths_outside_target_base() { - let target = validate_tunnel_target_url("http://localhost:3000/app").unwrap(); - let upstream = - build_tunnel_upstream_url(&target.url, "/.liveagent-tunnel-probe/ws?check=1").unwrap(); - assert_eq!( - upstream.as_str(), - "http://localhost:3000/.liveagent-tunnel-probe/ws?check=1" - ); - } - - #[test] - fn tunnel_ttl_allows_infinite_expiry() { - assert_eq!(normalize_tunnel_ttl(0).unwrap(), 0); - assert_eq!(tunnel_expires_at(0), 0); - assert!(normalize_tunnel_ttl(1).is_err()); - } - #[test] fn merge_settings_sync_snapshot_keeps_cached_ui_only_fields() { let db_snapshot = json!({ @@ -6837,7 +5246,7 @@ fn set_disconnected_status( status.last_error = last_error; } -fn now_unix_seconds() -> i64 { +pub(crate) fn now_unix_seconds() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_else(|_| Duration::from_secs(0)); diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index cd158246..78520df2 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -5,3 +5,4 @@ pub mod memory; pub mod power_activity; pub mod proxy; pub mod skills; +pub mod tunnel; diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs new file mode 100644 index 00000000..2073b5ee --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/tunnel/mod.rs @@ -0,0 +1,589 @@ +//! NAT tunnel agent: the desktop agent owns the desired tunnel set (persisted +//! in the settings DB), publishes it to the gateway, answers webui mutations, +//! probes local targets, and serves the stateless tunnel data plane. + +pub mod proxy; +pub mod store; + +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Url; +use serde::{Deserialize, Serialize}; + +use crate::services::gateway::{now_unix_seconds, proto, GatewayController}; + +pub use proxy::TunnelProxy; +pub use store::TunnelStore; + +pub const GATEWAY_TUNNEL_STATE_EVENT: &str = "gateway:tunnel-state"; +const TUNNEL_GATEWAY_SUPPORT_TIMEOUT: Duration = Duration::from_secs(10); +const TUNNEL_LOCAL_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone)] +pub struct TunnelTarget { + pub url: Url, +} + +pub(crate) fn validate_tunnel_target_url(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("targetUrl is required".to_string()); + } + let mut url = Url::parse(trimmed).map_err(|e| format!("invalid targetUrl: {e}"))?; + if url.scheme() != "http" { + return Err("targetUrl must use http".to_string()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("targetUrl must not include credentials".to_string()); + } + if url.fragment().is_some() { + return Err("targetUrl must not include a fragment".to_string()); + } + let host = url + .host_str() + .map(|value| value.trim().to_ascii_lowercase()) + .ok_or_else(|| "targetUrl host is required".to_string())?; + let host = host.trim_start_matches('[').trim_end_matches(']'); + if host != "localhost" && host.parse::().is_err() { + return Err("targetUrl host must be localhost or an IP address".to_string()); + } + url.set_fragment(None); + Ok(TunnelTarget { url }) +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayTunnelCreateInput { + pub target_url: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub ttl_seconds: Option, + #[serde(default)] + pub project_path_key: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayTunnelUpdateInput { + pub id: String, + pub target_url: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub ttl_seconds: Option, + #[serde(default)] + pub project_path_key: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelHealthPayload { + pub status: String, + pub http_status: u32, + pub error: String, + pub checked_at: i64, + pub rtt_ms: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelStatusPayload { + pub id: String, + pub slug: String, + pub name: String, + pub target_url: String, + pub public_path: String, + pub created_at: i64, + pub expires_at: i64, + pub active_connections: u32, + pub project_path_key: String, + pub local: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TunnelStatePayload { + pub revision: u64, + pub agent_online: bool, + pub relay: Option, + pub tunnels: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_unsupported: Option, +} + +impl TunnelStatePayload { + pub fn offline_empty() -> Self { + Self { + revision: 0, + agent_online: false, + relay: None, + tunnels: Vec::new(), + gateway_unsupported: None, + } + } +} + +#[derive(Debug, Clone)] +pub struct TunnelMutationError { + pub code: &'static str, + pub message: String, +} + +impl TunnelMutationError { + pub(crate) fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + pub(crate) fn internal(message: impl Into) -> Self { + Self::new("internal", message) + } + + pub(crate) fn not_found() -> Self { + Self::new("not_found", "tunnel not found") + } +} + +fn tunnel_health_payload_to_proto(health: &TunnelHealthPayload) -> proto::TunnelHealth { + proto::TunnelHealth { + status: health.status.clone(), + http_status: health.http_status, + error: health.error.clone(), + checked_at: health.checked_at, + rtt_ms: health.rtt_ms, + } +} + +pub(crate) fn tunnel_health_payload_from_proto( + health: &proto::TunnelHealth, +) -> TunnelHealthPayload { + TunnelHealthPayload { + status: health.status.clone(), + http_status: health.http_status, + error: health.error.clone(), + checked_at: health.checked_at, + rtt_ms: health.rtt_ms, + } +} + +impl GatewayController { + fn tunnel_store(&self) -> &TunnelStore { + &self.tunnel_store + } + + pub(crate) fn start_tunnel_store(self: &Arc) { + let controller = Arc::clone(self); + self.tunnel_store_once.call_once(move || { + tauri::async_runtime::spawn(async move { + if let Err(error) = controller.tunnel_store().initialize().await { + eprintln!("initialize gateway tunnel store failed: {error}"); + } + controller.emit_local_tunnel_state(); + }); + }); + } + + pub(crate) fn emit_local_tunnel_state(&self) { + let agent_online = self.status().online; + match self.tunnel_store().build_local_state(agent_online) { + Ok(payload) => self.tunnel_store().cache_and_emit(payload), + Err(error) => eprintln!("build local gateway tunnel state failed: {error}"), + } + } + + /// Sweeps expired specs and pushes the full desired tunnel set to the + /// gateway. Called on gRPC connect and after every local mutation. + pub(crate) async fn publish_desired_tunnels(self: &Arc) -> Result<(), String> { + let expired = self.tunnel_store().take_expired_specs()?; + if !expired.is_empty() { + if let Err(error) = store::delete_tunnel_specs(expired).await { + eprintln!("delete expired gateway tunnel specs failed: {error}"); + } + } + let (tunnels, revision) = self.tunnel_store().desired_state()?; + // Bump the watch epoch before sending so a snapshot that races the + // publish cannot be misread as missing. + let watch_epoch = self.tunnel_store().begin_publish_watch()?; + self.send_agent_envelope(proto::AgentEnvelope { + request_id: format!("tunnel-desired-{revision}"), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TunnelDesired( + proto::TunnelDesiredState { tunnels, revision }, + )), + }) + .await?; + self.watch_tunnel_gateway_support(watch_epoch); + Ok(()) + } + + /// Flags the gateway as tunnel-unsupported when no TunnelState snapshot + /// follows a desired-state publish within the timeout. + fn watch_tunnel_gateway_support(self: &Arc, epoch: u64) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(TUNNEL_GATEWAY_SUPPORT_TIMEOUT).await; + match controller + .tunnel_store() + .mark_gateway_unsupported_if_stale(epoch) + { + Ok(true) => controller.emit_local_tunnel_state(), + Ok(false) => {} + Err(error) => eprintln!("check gateway tunnel support failed: {error}"), + } + }); + } + + pub(crate) fn handle_tunnel_state_snapshot( + self: &Arc, + snapshot: proto::TunnelStateSnapshot, + ) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + match controller.tunnel_store().record_snapshot(&snapshot) { + Ok(changed_specs) => { + for spec in changed_specs { + if let Err(error) = store::persist_tunnel_spec(spec).await { + eprintln!("persist gateway tunnel slug failed: {error}"); + } + } + } + Err(error) => eprintln!("record gateway tunnel snapshot failed: {error}"), + } + }); + } + + pub(crate) fn handle_tunnel_mutation_request( + self: &Arc, + request_id: String, + mutation: proto::TunnelMutation, + ) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let action = mutation.action.trim().to_ascii_lowercase(); + let requested_tunnel_id = mutation.tunnel_id.trim().to_string(); + let result = match action.as_str() { + "create" => { + controller + .tunnel_create_inner(GatewayTunnelCreateInput { + target_url: mutation.target_url, + name: Some(mutation.name), + ttl_seconds: mutation.ttl_seconds, + project_path_key: Some(mutation.project_path_key), + }) + .await + } + "update" => { + controller + .tunnel_update_inner(GatewayTunnelUpdateInput { + id: mutation.tunnel_id, + target_url: mutation.target_url, + name: Some(mutation.name), + ttl_seconds: mutation.ttl_seconds, + project_path_key: Some(mutation.project_path_key), + }) + .await + } + "close" => controller.tunnel_close_inner(mutation.tunnel_id).await, + "check" => { + let tunnel_id = + (!requested_tunnel_id.is_empty()).then(|| requested_tunnel_id.clone()); + controller.tunnel_check_inner(tunnel_id).await + } + other => Err(TunnelMutationError::new( + "invalid_action", + format!("unsupported tunnel action: {other}"), + )), + }; + let (tunnel_id, error_code, error_message) = match result { + Ok(tunnel_id) => (tunnel_id, String::new(), String::new()), + Err(error) => (requested_tunnel_id, error.code.to_string(), error.message), + }; + // The webui correlates the verdict by this envelope echoing the + // gateway's request_id verbatim. + let send_result = controller + .send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TunnelMutationResult( + proto::TunnelMutationResult { + tunnel_id, + error_code, + error_message, + }, + )), + }) + .await; + if let Err(error) = send_result { + eprintln!("send gateway tunnel mutation result failed: {error}"); + } + }); + } + + pub fn tunnel_state(&self) -> TunnelStatePayload { + self.tunnel_store() + .cached_state() + .unwrap_or_else(TunnelStatePayload::offline_empty) + } + + pub async fn tunnel_create( + self: &Arc, + input: GatewayTunnelCreateInput, + ) -> Result<(), String> { + self.tunnel_create_inner(input) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + pub async fn tunnel_update( + self: &Arc, + input: GatewayTunnelUpdateInput, + ) -> Result<(), String> { + self.tunnel_update_inner(input) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + pub async fn tunnel_close(self: &Arc, tunnel_id: String) -> Result<(), String> { + self.tunnel_close_inner(tunnel_id) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + pub async fn tunnel_check(self: &Arc, tunnel_id: Option) -> Result<(), String> { + self.tunnel_check_inner(tunnel_id) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + async fn tunnel_create_inner( + self: &Arc, + input: GatewayTunnelCreateInput, + ) -> Result { + let spec = self.tunnel_store().prepare_create(input)?; + store::persist_tunnel_spec(spec.clone()) + .await + .map_err(TunnelMutationError::internal)?; + self.tunnel_store() + .commit_spec(spec.clone()) + .map_err(TunnelMutationError::internal)?; + self.after_tunnel_mutation(Some(vec![spec.id.clone()])) + .await; + Ok(spec.id) + } + + async fn tunnel_update_inner( + self: &Arc, + input: GatewayTunnelUpdateInput, + ) -> Result { + let spec = self.tunnel_store().prepare_update(input)?; + store::persist_tunnel_spec(spec.clone()) + .await + .map_err(TunnelMutationError::internal)?; + self.tunnel_store() + .commit_spec(spec.clone()) + .map_err(TunnelMutationError::internal)?; + self.after_tunnel_mutation(Some(vec![spec.id.clone()])) + .await; + Ok(spec.id) + } + + async fn tunnel_close_inner( + self: &Arc, + tunnel_id: String, + ) -> Result { + let tunnel_id = tunnel_id.trim().to_string(); + if tunnel_id.is_empty() { + return Err(TunnelMutationError::not_found()); + } + if !self + .tunnel_store() + .spec_exists(&tunnel_id) + .map_err(TunnelMutationError::internal)? + { + return Err(TunnelMutationError::not_found()); + } + store::delete_tunnel_specs(vec![tunnel_id.clone()]) + .await + .map_err(TunnelMutationError::internal)?; + self.tunnel_store() + .remove_spec(&tunnel_id) + .map_err(TunnelMutationError::internal)?; + self.after_tunnel_mutation(None).await; + Ok(tunnel_id) + } + + async fn tunnel_check_inner( + self: &Arc, + tunnel_id: Option, + ) -> Result { + let tunnel_id = tunnel_id + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()); + if let Some(id) = tunnel_id.as_ref() { + if !self + .tunnel_store() + .spec_exists(id) + .map_err(TunnelMutationError::internal)? + { + return Err(TunnelMutationError::not_found()); + } + } + self.run_tunnel_probes(tunnel_id.clone().map(|id| vec![id]), true) + .await; + Ok(tunnel_id.unwrap_or_default()) + } + + async fn after_tunnel_mutation(self: &Arc, probe_ids: Option>) { + let mut published = false; + if self.status().online { + match self.publish_desired_tunnels().await { + Ok(()) => published = true, + Err(error) => eprintln!("publish gateway tunnel desired state failed: {error}"), + } + } + if !published || self.tunnel_store().is_gateway_unsupported() { + self.emit_local_tunnel_state(); + } + if probe_ids.is_some() { + self.spawn_tunnel_probes(probe_ids, false); + } + } + + pub(crate) fn spawn_tunnel_probes( + self: &Arc, + tunnel_ids: Option>, + bypass_throttle: bool, + ) { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + controller + .run_tunnel_probes(tunnel_ids, bypass_throttle) + .await; + }); + } + + async fn run_tunnel_probes( + self: &Arc, + tunnel_ids: Option>, + bypass_throttle: bool, + ) { + let targets = match self + .tunnel_store() + .claim_probe_targets(tunnel_ids, bypass_throttle) + { + Ok(targets) => targets, + Err(error) => { + eprintln!("collect gateway tunnel probe targets failed: {error}"); + return; + } + }; + if targets.is_empty() { + return; + } + let checks = targets + .into_iter() + .map(|(tunnel_id, target_url)| async move { + let health = probe_tunnel_target(&target_url).await; + (tunnel_id, health) + }); + let results = futures_util::future::join_all(checks).await; + if let Err(error) = self.tunnel_store().record_local_health(&results) { + eprintln!("record gateway tunnel probe results failed: {error}"); + } + let report = proto::TunnelProbeReport { + results: results + .iter() + .map(|(tunnel_id, health)| proto::TunnelProbeResult { + tunnel_id: tunnel_id.clone(), + local: Some(tunnel_health_payload_to_proto(health)), + }) + .collect(), + }; + let online = self.status().online; + if online { + let send_result = self + .send_agent_envelope(proto::AgentEnvelope { + request_id: format!("tunnel-probe-{}", now_unix_seconds()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TunnelProbeReport(report)), + }) + .await; + if let Err(error) = send_result { + eprintln!("send gateway tunnel probe report failed: {error}"); + } + } + if !online || self.tunnel_store().is_gateway_unsupported() { + self.emit_local_tunnel_state(); + } + } +} + +async fn probe_tunnel_target(target_url: &str) -> TunnelHealthPayload { + let checked_at = now_unix_seconds(); + let failed = |error: String| TunnelHealthPayload { + status: "failed".to_string(), + http_status: 0, + error, + checked_at, + rtt_ms: 0, + }; + let client = match reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(TUNNEL_LOCAL_PROBE_TIMEOUT) + .build() + { + Ok(client) => client, + Err(error) => { + return failed(format!( + "failed to build local tunnel probe client: {error}" + )) + } + }; + match client.get(target_url).send().await { + Ok(response) => TunnelHealthPayload { + status: "ok".to_string(), + http_status: u32::from(response.status().as_u16()), + error: String::new(), + checked_at, + rtt_ms: 0, + }, + Err(error) => failed(format!("local tunnel probe failed: {error}")), + } +} + +#[cfg(test)] +mod tests { + use super::validate_tunnel_target_url; + + #[test] + fn validate_tunnel_target_url_accepts_localhost_and_ip_http_targets() { + for value in [ + "http://localhost:3000", + "http://127.0.0.1:8080/app", + "http://[::1]:5173", + "http://192.168.1.5:3000", + "http://10.0.0.20:8080/app", + "http://[fd00::1]:5173", + ] { + assert!(validate_tunnel_target_url(value).is_ok(), "{value}"); + } + + for value in [ + "https://localhost:3000", + "http://example.com", + "http://user:pass@localhost:3000", + "http://localhost:3000/#fragment", + ] { + assert!(validate_tunnel_target_url(value).is_err(), "{value}"); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs b/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs new file mode 100644 index 00000000..f0ed71f3 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/tunnel/proxy.rs @@ -0,0 +1,737 @@ +//! Stateless tunnel data plane: every HTTP_REQUEST_START / WS_DIAL frame +//! carries its own target URL, so per-stream registries are keyed only by +//! stream_id. + +use std::collections::HashMap; +use std::io; +use std::sync::{Arc, Mutex}; + +use futures_util::{SinkExt, StreamExt}; +use reqwest::header::{HeaderName, HeaderValue}; +use reqwest::Url; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tokio_tungstenite::{ + connect_async, + tungstenite::{ + client::IntoClientRequest, + http::header::SEC_WEBSOCKET_PROTOCOL, + protocol::{frame::coding::CloseCode, CloseFrame}, + Message, + }, +}; + +use crate::services::gateway::{now_unix_seconds, proto, GatewayController}; + +use super::validate_tunnel_target_url; + +const TUNNEL_BODY_CHUNK_SIZE: usize = 64 * 1024; +const TUNNEL_HTTP_BODY_CHANNEL_DEPTH: usize = 64; +const TUNNEL_WS_CHANNEL_DEPTH: usize = 128; + +type TunnelHttpBodySender = mpsc::Sender, io::Error>>; + +#[derive(Default)] +pub struct TunnelProxy { + http_streams: Mutex>, + ws_streams: Mutex>>, +} + +impl TunnelProxy { + pub fn new() -> Self { + Self::default() + } + + pub(crate) fn handle_frame( + &self, + controller: &Arc, + frame: proto::TunnelFrame, + ) -> Result<(), String> { + let stream_id = frame.stream_id.trim().to_string(); + if stream_id.is_empty() { + return Ok(()); + } + match frame.kind() { + proto::TunnelFrameKind::HttpRequestStart => { + if let Err(error) = self.start_http_stream(controller, &stream_id, &frame) { + spawn_tunnel_frame_error(controller, stream_id, error); + } + Ok(()) + } + proto::TunnelFrameKind::HttpRequestBody => { + let sender = self + .http_streams + .lock() + .map_err(|_| "gateway tunnel http stream lock poisoned".to_string())? + .get(&stream_id) + .cloned(); + if let Some(sender) = sender { + match sender.try_send(Ok(frame.body)) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + self.remove_http_stream(&stream_id); + spawn_tunnel_frame_error( + controller, + stream_id, + "local tunnel request body queue is full".to_string(), + ); + } + Err(mpsc::error::TrySendError::Closed(_)) => {} + } + } + Ok(()) + } + proto::TunnelFrameKind::HttpRequestEnd => { + self.remove_http_stream(&stream_id); + Ok(()) + } + proto::TunnelFrameKind::WsDial => { + if let Err(error) = self.start_ws_stream(controller, &stream_id, &frame) { + spawn_tunnel_ws_dial_error(controller, stream_id, error); + } + Ok(()) + } + proto::TunnelFrameKind::WsFrame + | proto::TunnelFrameKind::WsClose + | proto::TunnelFrameKind::Cancel => { + let terminal_frame = matches!( + frame.kind(), + proto::TunnelFrameKind::WsClose | proto::TunnelFrameKind::Cancel + ); + if matches!(frame.kind(), proto::TunnelFrameKind::Cancel) { + self.remove_http_stream(&stream_id); + } + let sender = self + .ws_streams + .lock() + .map_err(|_| "gateway tunnel websocket stream lock poisoned".to_string())? + .get(&stream_id) + .cloned(); + if let Some(sender) = sender { + match sender.try_send(frame) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + self.remove_ws_stream(&stream_id); + if !terminal_frame { + spawn_tunnel_frame_error( + controller, + stream_id, + "local tunnel websocket queue is full".to_string(), + ); + } + } + Err(mpsc::error::TrySendError::Closed(_)) => {} + } + } + Ok(()) + } + proto::TunnelFrameKind::Ping => { + let controller = Arc::clone(controller); + tauri::async_runtime::spawn(async move { + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::Pong as i32, + ..Default::default() + }, + ) + .await; + }); + Ok(()) + } + _ => Ok(()), + } + } + + fn start_http_stream( + &self, + controller: &Arc, + stream_id: &str, + frame: &proto::TunnelFrame, + ) -> Result<(), String> { + let target = validate_tunnel_target_url(&frame.target_url)?; + let upstream_url = build_tunnel_upstream_url(&target.url, &frame.path)?; + let method = frame.method.trim().to_string(); + let headers = frame.headers.clone(); + let (body_tx, body_rx) = + mpsc::channel::, io::Error>>(TUNNEL_HTTP_BODY_CHANNEL_DEPTH); + self.http_streams + .lock() + .map_err(|_| "gateway tunnel http stream lock poisoned".to_string())? + .insert(stream_id.to_string(), body_tx); + + let controller = Arc::clone(controller); + let stream_id = stream_id.to_string(); + tauri::async_runtime::spawn(async move { + run_tunnel_http_request( + controller, + stream_id, + method, + upstream_url, + headers, + body_rx, + ) + .await; + }); + Ok(()) + } + + fn start_ws_stream( + &self, + controller: &Arc, + stream_id: &str, + frame: &proto::TunnelFrame, + ) -> Result<(), String> { + let target = validate_tunnel_target_url(&frame.target_url)?; + let upstream_url = build_tunnel_upstream_ws_url(&target.url, &frame.path)?; + let headers = frame.headers.clone(); + let (gateway_tx, gateway_rx) = mpsc::channel::(TUNNEL_WS_CHANNEL_DEPTH); + self.ws_streams + .lock() + .map_err(|_| "gateway tunnel websocket stream lock poisoned".to_string())? + .insert(stream_id.to_string(), gateway_tx); + + let controller = Arc::clone(controller); + let stream_id = stream_id.to_string(); + tauri::async_runtime::spawn(async move { + run_tunnel_websocket(controller, stream_id, upstream_url, headers, gateway_rx).await; + }); + Ok(()) + } + + fn remove_http_stream(&self, stream_id: &str) { + if let Ok(mut streams) = self.http_streams.lock() { + streams.remove(stream_id.trim()); + } + } + + fn remove_ws_stream(&self, stream_id: &str) { + if let Ok(mut streams) = self.ws_streams.lock() { + streams.remove(stream_id.trim()); + } + } +} + +fn spawn_tunnel_frame_error(controller: &Arc, stream_id: String, error: String) { + let controller = Arc::clone(controller); + tauri::async_runtime::spawn(async move { + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::Error as i32, + error, + ..Default::default() + }, + ) + .await; + }); +} + +fn spawn_tunnel_ws_dial_error( + controller: &Arc, + stream_id: String, + error: String, +) { + let controller = Arc::clone(controller); + tauri::async_runtime::spawn(async move { + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::WsDialError as i32, + error, + ..Default::default() + }, + ) + .await; + }); +} + +async fn run_tunnel_http_request( + controller: Arc, + stream_id: String, + method: String, + upstream_url: Url, + headers: Vec, + body_rx: mpsc::Receiver, io::Error>>, +) { + let result = async { + let method = reqwest::Method::from_bytes(method.trim().as_bytes()) + .map_err(|e| format!("invalid tunnel request method: {e}"))?; + let body = reqwest::Body::wrap_stream(ReceiverStream::new(body_rx)); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("failed to build local tunnel HTTP client: {e}"))?; + let request = + apply_tunnel_request_headers(client.request(method, upstream_url).body(body), &headers); + let response = request + .send() + .await + .map_err(|e| format!("local tunnel request failed: {e}"))?; + let status = u32::from(response.status().as_u16()); + let response_headers = tunnel_response_headers(response.headers()); + send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::HttpResponseStart as i32, + status, + headers: response_headers, + ..Default::default() + }, + ) + .await?; + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("local tunnel response stream failed: {e}"))?; + if chunk.is_empty() { + continue; + } + for part in chunk.chunks(TUNNEL_BODY_CHUNK_SIZE) { + send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::HttpResponseBody as i32, + body: part.to_vec(), + ..Default::default() + }, + ) + .await?; + } + } + + send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::HttpResponseEnd as i32, + ..Default::default() + }, + ) + .await + } + .await; + + controller.tunnel_proxy.remove_http_stream(&stream_id); + if let Err(error) = result { + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::Error as i32, + error, + ..Default::default() + }, + ) + .await; + } +} + +async fn run_tunnel_websocket( + controller: Arc, + stream_id: String, + upstream_url: Url, + headers: Vec, + mut gateway_rx: mpsc::Receiver, +) { + let mut request = match upstream_url.as_str().into_client_request() { + Ok(request) => request, + Err(error) => { + controller.tunnel_proxy.remove_ws_stream(&stream_id); + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::WsDialError as i32, + error: format!("invalid local tunnel websocket request: {error}"), + ..Default::default() + }, + ) + .await; + return; + } + }; + apply_tunnel_ws_request_headers(&mut request, &headers, &upstream_url); + let (ws_stream, response) = match connect_async(request).await { + Ok(result) => result, + Err(error) => { + controller.tunnel_proxy.remove_ws_stream(&stream_id); + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::WsDialError as i32, + error: format!("local tunnel websocket failed: {error}"), + ..Default::default() + }, + ) + .await; + return; + } + }; + let ws_subprotocol = response + .headers() + .get(SEC_WEBSOCKET_PROTOCOL) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .trim() + .to_string(); + + if send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::WsDialOk as i32, + ws_subprotocol, + ..Default::default() + }, + ) + .await + .is_err() + { + controller.tunnel_proxy.remove_ws_stream(&stream_id); + return; + } + + // Ok(Some(...)) carries the upstream close code/reason when the local + // service initiated the shutdown. + let result: Result, String> = async { + let mut upstream_close: Option<(u32, String)> = None; + let (mut local_write, mut local_read) = ws_stream.split(); + loop { + tokio::select! { + incoming = gateway_rx.recv() => { + let Some(frame) = incoming else { + break; + }; + match frame.kind() { + proto::TunnelFrameKind::WsFrame => { + if frame.ws_message_type() == proto::TunnelWsMessageType::Text { + let text = String::from_utf8_lossy(&frame.body).to_string(); + local_write + .send(Message::Text(text.into())) + .await + .map_err(|e| format!("local websocket send failed: {e}"))?; + } else { + local_write + .send(Message::Binary(frame.body.into())) + .await + .map_err(|e| format!("local websocket send failed: {e}"))?; + } + } + proto::TunnelFrameKind::WsClose => { + let close_frame = (frame.ws_close_code > 0).then(|| CloseFrame { + code: CloseCode::from( + u16::try_from(frame.ws_close_code).unwrap_or(1000), + ), + reason: frame.ws_close_reason.clone().into(), + }); + let _ = local_write.send(Message::Close(close_frame)).await; + break; + } + proto::TunnelFrameKind::Cancel => { + let _ = local_write.send(Message::Close(None)).await; + break; + } + _ => {} + } + } + local = local_read.next() => { + let Some(local) = local else { + break; + }; + let message = local.map_err(|e| format!("local websocket read failed: {e}"))?; + match message { + Message::Text(text) => { + send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::WsFrame as i32, + ws_message_type: proto::TunnelWsMessageType::Text as i32, + body: text.to_string().into_bytes(), + ..Default::default() + }, + ) + .await?; + } + Message::Binary(data) => { + send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id: stream_id.clone(), + kind: proto::TunnelFrameKind::WsFrame as i32, + ws_message_type: proto::TunnelWsMessageType::Binary as i32, + body: data.to_vec(), + ..Default::default() + }, + ) + .await?; + } + Message::Ping(data) => { + let _ = local_write.send(Message::Pong(data)).await; + } + Message::Close(close) => { + upstream_close = close.map(|frame| { + ( + u32::from(u16::from(frame.code)), + frame.reason.as_str().to_string(), + ) + }); + break; + } + Message::Pong(_) | Message::Frame(_) => {} + } + } + } + } + Ok(upstream_close) + } + .await; + + controller.tunnel_proxy.remove_ws_stream(&stream_id); + match result { + Ok(upstream_close) => { + let (ws_close_code, ws_close_reason) = upstream_close.unwrap_or((0, String::new())); + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::WsClose as i32, + ws_close_code, + ws_close_reason, + ..Default::default() + }, + ) + .await; + } + Err(error) => { + let _ = send_tunnel_frame( + &controller, + proto::TunnelFrame { + stream_id, + kind: proto::TunnelFrameKind::Error as i32, + error, + ..Default::default() + }, + ) + .await; + } + } +} + +fn split_tunnel_path_and_query(input: &str) -> (&str, Option<&str>) { + let trimmed = input.trim(); + match trimmed.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (trimmed, None), + } +} + +fn normalize_tunnel_path(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.is_empty() { + return "/".to_string(); + } + if trimmed.starts_with('/') { + trimmed.to_string() + } else { + format!("/{trimmed}") + } +} + +fn join_tunnel_paths(base_path: &str, rest_path: &str) -> String { + let base = normalize_tunnel_path(base_path); + let rest = normalize_tunnel_path(rest_path); + if base == "/" { + return rest; + } + if rest == "/" { + return format!("{}/", base.trim_end_matches('/')); + } + format!( + "{}/{}", + base.trim_end_matches('/'), + rest.trim_start_matches('/') + ) +} + +fn build_tunnel_upstream_url(base: &Url, public_path: &str) -> Result { + let (path, query) = split_tunnel_path_and_query(public_path); + let mut url = base.clone(); + let joined_path = join_tunnel_paths(url.path(), path); + url.set_path(&joined_path); + url.set_query(query.filter(|value| !value.is_empty())); + url.set_fragment(None); + Ok(url) +} + +fn build_tunnel_upstream_ws_url(base: &Url, public_path: &str) -> Result { + let mut url = build_tunnel_upstream_url(base, public_path)?; + url.set_scheme("ws") + .map_err(|_| "failed to build websocket tunnel target URL".to_string())?; + Ok(url) +} + +fn should_drop_tunnel_header(name: &str, request: bool) -> bool { + match name.to_ascii_lowercase().as_str() { + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" => true, + "host" => request, + _ => false, + } +} + +fn apply_tunnel_request_headers( + mut builder: reqwest::RequestBuilder, + headers: &[proto::TunnelHeader], +) -> reqwest::RequestBuilder { + for header in headers { + let name = header.name.trim(); + if name.is_empty() || should_drop_tunnel_header(name, true) { + continue; + } + let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { + continue; + }; + let Ok(header_value) = HeaderValue::from_str(&header.value) else { + continue; + }; + builder = builder.header(header_name, header_value); + } + builder +} + +fn tunnel_response_headers(headers: &reqwest::header::HeaderMap) -> Vec { + let mut out = Vec::new(); + for (name, value) in headers.iter() { + let name_text = name.as_str(); + if should_drop_tunnel_header(name_text, false) { + continue; + } + let Ok(value_text) = value.to_str() else { + continue; + }; + out.push(proto::TunnelHeader { + name: name_text.to_string(), + value: value_text.to_string(), + }); + } + out +} + +fn apply_tunnel_ws_request_headers( + request: &mut tokio_tungstenite::tungstenite::http::Request<()>, + headers: &[proto::TunnelHeader], + upstream_url: &Url, +) { + let target_origin = tunnel_target_origin(upstream_url); + for header in headers { + let name = header.name.trim(); + if name.is_empty() || should_drop_tunnel_ws_header(name) { + continue; + } + let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { + continue; + }; + let value = if header_name.as_str().eq_ignore_ascii_case("origin") { + target_origin.as_str() + } else { + header.value.as_str() + }; + let Ok(header_value) = HeaderValue::from_str(value) else { + continue; + }; + request.headers_mut().append(header_name, header_value); + } + if !headers + .iter() + .any(|header| header.name.eq_ignore_ascii_case("origin")) + { + if let Ok(header_value) = HeaderValue::from_str(&target_origin) { + request + .headers_mut() + .insert(HeaderName::from_static("origin"), header_value); + } + } +} + +fn should_drop_tunnel_ws_header(name: &str) -> bool { + if should_drop_tunnel_header(name, true) { + return true; + } + matches!( + name.to_ascii_lowercase().as_str(), + "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-accept" + ) +} + +fn tunnel_target_origin(url: &Url) -> String { + match url.port() { + Some(port) => format!( + "{}://{}:{port}", + url.scheme(), + url.host_str().unwrap_or("localhost") + ), + None => format!( + "{}://{}", + url.scheme(), + url.host_str().unwrap_or("localhost") + ), + } +} + +async fn send_tunnel_frame( + controller: &GatewayController, + frame: proto::TunnelFrame, +) -> Result<(), String> { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: format!("tunnel-frame-{}", frame.stream_id.trim()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TunnelFrame(frame)), + }) + .await +} + +#[cfg(test)] +mod tests { + use super::{build_tunnel_upstream_url, build_tunnel_upstream_ws_url}; + use crate::services::tunnel::validate_tunnel_target_url; + + #[test] + fn build_tunnel_upstream_url_preserves_base_path_and_query() { + let target = validate_tunnel_target_url("http://localhost:3000/app").unwrap(); + let upstream = build_tunnel_upstream_url(&target.url, "/api/users?page=1").unwrap(); + assert_eq!( + upstream.as_str(), + "http://localhost:3000/app/api/users?page=1" + ); + + let root_target = validate_tunnel_target_url("http://127.0.0.1:5173").unwrap(); + let root_upstream = build_tunnel_upstream_url(&root_target.url, "/").unwrap(); + assert_eq!(root_upstream.as_str(), "http://127.0.0.1:5173/"); + } + + #[test] + fn build_tunnel_upstream_ws_url_switches_scheme() { + let target = validate_tunnel_target_url("http://localhost:3000/app").unwrap(); + let upstream = build_tunnel_upstream_ws_url(&target.url, "/socket?token=1").unwrap(); + assert_eq!(upstream.as_str(), "ws://localhost:3000/app/socket?token=1"); + } +} diff --git a/crates/agent-gui/src-tauri/src/services/tunnel/store.rs b/crates/agent-gui/src-tauri/src/services/tunnel/store.rs new file mode 100644 index 00000000..6e7dac47 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/tunnel/store.rs @@ -0,0 +1,708 @@ +//! Desired tunnel state: the agent-side source of truth, persisted in the +//! settings DB (`tunnel_settings`) and mirrored to the gateway as +//! `TunnelDesiredState`. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use uuid::Uuid; + +use crate::commands::settings::open_db; +use crate::runtime::project_path::project_path_key as normalize_project_path_key; +use crate::services::gateway::{now_unix_seconds, proto}; + +use super::{ + tunnel_health_payload_from_proto, validate_tunnel_target_url, GatewayTunnelCreateInput, + GatewayTunnelUpdateInput, TunnelHealthPayload, TunnelMutationError, TunnelStatePayload, + TunnelStatusPayload, GATEWAY_TUNNEL_STATE_EVENT, +}; + +const TUNNEL_SETTINGS_TABLE: &str = "tunnel_settings"; +const MAX_TUNNELS_PER_AGENT: usize = 5; +// The only Rust source of truth for allowed tunnel TTLs. +const TUNNEL_TTL_WHITELIST: [u32; 4] = [0, 900, 3600, 14400]; +const TUNNEL_DEFAULT_TTL_SECONDS: u32 = 3600; +const TUNNEL_PROBE_THROTTLE: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredTunnelSpec { + pub id: String, + #[serde(default)] + pub slug_hint: String, + #[serde(default)] + pub name: String, + pub target_url: String, + #[serde(default)] + pub expires_at: i64, + #[serde(default)] + pub project_path_key: String, + #[serde(default)] + pub created_at: i64, +} + +#[derive(Default)] +struct TunnelStoreState { + specs: HashMap, + revision: u64, + local_health: HashMap, + probe_checked_at: HashMap, + last_snapshot: Option, + gateway_unsupported: bool, + publish_epoch: u64, + snapshot_epoch: u64, +} + +pub struct TunnelStore { + app_handle: tauri::AppHandle, + state: Mutex, +} + +impl TunnelStore { + pub fn new(app_handle: tauri::AppHandle) -> Self { + Self { + app_handle, + state: Mutex::new(TunnelStoreState::default()), + } + } + + fn lock_state(&self) -> Result, String> { + self.state + .lock() + .map_err(|_| "gateway tunnel store lock poisoned".to_string()) + } + + /// Loads persisted specs (dropping already-expired ones) into memory. + pub async fn initialize(&self) -> Result<(), String> { + let specs = load_tunnel_specs().await?; + let expired = { + let mut state = self.lock_state()?; + for spec in specs { + state.specs.entry(spec.id.clone()).or_insert(spec); + } + sweep_expired_specs(&mut state, now_unix_seconds()) + }; + if !expired.is_empty() { + delete_tunnel_specs(expired).await?; + } + Ok(()) + } + + pub(super) fn prepare_create( + &self, + input: GatewayTunnelCreateInput, + ) -> Result { + let state = self.lock_state().map_err(TunnelMutationError::internal)?; + prepare_create_spec(&state, input, now_unix_seconds()) + } + + pub(super) fn prepare_update( + &self, + input: GatewayTunnelUpdateInput, + ) -> Result { + let state = self.lock_state().map_err(TunnelMutationError::internal)?; + prepare_update_spec(&state, input) + } + + pub(super) fn commit_spec(&self, spec: StoredTunnelSpec) -> Result<(), String> { + let mut state = self.lock_state()?; + state.specs.insert(spec.id.clone(), spec); + state.revision += 1; + Ok(()) + } + + pub(super) fn remove_spec(&self, tunnel_id: &str) -> Result, String> { + let mut state = self.lock_state()?; + let removed = state.specs.remove(tunnel_id.trim()); + if removed.is_some() { + state.local_health.remove(tunnel_id.trim()); + state.probe_checked_at.remove(tunnel_id.trim()); + state.revision += 1; + } + Ok(removed) + } + + pub(super) fn spec_exists(&self, tunnel_id: &str) -> Result { + Ok(self.lock_state()?.specs.contains_key(tunnel_id.trim())) + } + + /// Removes expired specs from memory and returns their ids so the caller + /// can persist the deletions. + pub(super) fn take_expired_specs(&self) -> Result, String> { + let mut state = self.lock_state()?; + Ok(sweep_expired_specs(&mut state, now_unix_seconds())) + } + + pub(super) fn desired_state(&self) -> Result<(Vec, u64), String> { + let state = self.lock_state()?; + let mut specs = state + .specs + .values() + .filter(|spec| !spec_expired(spec, now_unix_seconds())) + .cloned() + .collect::>(); + specs.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let tunnels = specs + .into_iter() + .map(|spec| proto::TunnelSpec { + id: spec.id, + slug_hint: spec.slug_hint, + name: spec.name, + target_url: spec.target_url, + expires_at: spec.expires_at, + project_path_key: spec.project_path_key, + }) + .collect(); + Ok((tunnels, state.revision)) + } + + pub(super) fn begin_publish_watch(&self) -> Result { + let mut state = self.lock_state()?; + state.publish_epoch += 1; + Ok(state.publish_epoch) + } + + /// Marks the gateway as tunnel-unsupported when no snapshot has arrived + /// since the given publish epoch. Returns true when the flag flipped on. + pub(super) fn mark_gateway_unsupported_if_stale(&self, epoch: u64) -> Result { + let mut state = self.lock_state()?; + if state.snapshot_epoch >= epoch { + return Ok(false); + } + state.gateway_unsupported = true; + Ok(true) + } + + pub(super) fn is_gateway_unsupported(&self) -> bool { + self.lock_state() + .map(|state| state.gateway_unsupported) + .unwrap_or(false) + } + + /// Applies a gateway snapshot: persist-worthy slug allocations are + /// returned, the payload is cached, and the Tauri event is emitted. + pub(super) fn record_snapshot( + &self, + snapshot: &proto::TunnelStateSnapshot, + ) -> Result, String> { + let payload = tunnel_state_payload_from_proto(snapshot); + let changed = { + let mut state = self.lock_state()?; + let mut changed = Vec::new(); + for status in &snapshot.tunnels { + let slug = status.slug.trim(); + if slug.is_empty() { + continue; + } + if let Some(spec) = state.specs.get_mut(status.id.trim()) { + if spec.slug_hint != slug { + spec.slug_hint = slug.to_string(); + changed.push(spec.clone()); + } + } + } + state.gateway_unsupported = false; + state.snapshot_epoch = state.publish_epoch; + state.last_snapshot = Some(payload.clone()); + changed + }; + self.emit_state(&payload); + Ok(changed) + } + + pub(super) fn cached_state(&self) -> Option { + self.lock_state().ok()?.last_snapshot.clone() + } + + pub(super) fn cache_and_emit(&self, payload: TunnelStatePayload) { + if let Ok(mut state) = self.lock_state() { + state.last_snapshot = Some(payload.clone()); + } + self.emit_state(&payload); + } + + fn emit_state(&self, payload: &TunnelStatePayload) { + if let Err(error) = self + .app_handle + .emit(GATEWAY_TUNNEL_STATE_EVENT, payload.clone()) + { + eprintln!("emit gateway tunnel state failed: {error}"); + } + } + + /// Builds a snapshot from local desired specs for offline/unsupported + /// rendering: slug/publicPath stay empty until the gateway allocates them. + pub(super) fn build_local_state( + &self, + agent_online: bool, + ) -> Result { + let state = self.lock_state()?; + let now = now_unix_seconds(); + let mut specs = state + .specs + .values() + .filter(|spec| !spec_expired(spec, now)) + .cloned() + .collect::>(); + specs.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let tunnels = specs + .into_iter() + .map(|spec| TunnelStatusPayload { + local: state.local_health.get(&spec.id).cloned(), + id: spec.id, + slug: String::new(), + name: spec.name, + target_url: spec.target_url, + public_path: String::new(), + created_at: spec.created_at, + expires_at: spec.expires_at, + active_connections: 0, + project_path_key: spec.project_path_key, + }) + .collect(); + Ok(TunnelStatePayload { + revision: state.revision, + agent_online, + relay: None, + tunnels, + gateway_unsupported: state.gateway_unsupported.then_some(true), + }) + } + + /// Returns `(tunnel_id, target_url)` pairs due for a local probe and + /// stamps their throttle window. Explicit checks bypass the throttle. + pub(super) fn claim_probe_targets( + &self, + tunnel_ids: Option>, + bypass_throttle: bool, + ) -> Result, String> { + let mut state = self.lock_state()?; + let now_unix = now_unix_seconds(); + let now = Instant::now(); + let candidate_ids = match tunnel_ids { + Some(ids) => ids + .into_iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect::>(), + None => state.specs.keys().cloned().collect(), + }; + let mut targets = Vec::new(); + for tunnel_id in candidate_ids { + let Some(spec) = state.specs.get(&tunnel_id) else { + continue; + }; + if spec_expired(spec, now_unix) { + continue; + } + if !bypass_throttle { + let throttled = state + .probe_checked_at + .get(&tunnel_id) + .map(|checked_at| now.duration_since(*checked_at) < TUNNEL_PROBE_THROTTLE) + .unwrap_or(false); + if throttled { + continue; + } + } + let target_url = spec.target_url.clone(); + state.probe_checked_at.insert(tunnel_id.clone(), now); + targets.push((tunnel_id, target_url)); + } + Ok(targets) + } + + pub(super) fn record_local_health( + &self, + results: &[(String, TunnelHealthPayload)], + ) -> Result<(), String> { + let mut state = self.lock_state()?; + for (tunnel_id, health) in results { + state.local_health.insert(tunnel_id.clone(), health.clone()); + } + Ok(()) + } +} + +fn spec_expired(spec: &StoredTunnelSpec, now: i64) -> bool { + spec.expires_at > 0 && spec.expires_at <= now +} + +fn sweep_expired_specs(state: &mut TunnelStoreState, now: i64) -> Vec { + let expired = state + .specs + .values() + .filter(|spec| spec_expired(spec, now)) + .map(|spec| spec.id.clone()) + .collect::>(); + for tunnel_id in &expired { + state.specs.remove(tunnel_id); + state.local_health.remove(tunnel_id); + state.probe_checked_at.remove(tunnel_id); + } + if !expired.is_empty() { + state.revision += 1; + } + expired +} + +fn prepare_create_spec( + state: &TunnelStoreState, + input: GatewayTunnelCreateInput, + now: i64, +) -> Result { + let target = validate_tunnel_target_url(&input.target_url) + .map_err(|error| TunnelMutationError::new("invalid_target", error))?; + let ttl_seconds = normalize_tunnel_ttl(input.ttl_seconds) + .map_err(|error| TunnelMutationError::new("invalid_ttl", error))?; + let active = state + .specs + .values() + .filter(|spec| !spec_expired(spec, now)) + .count(); + if active >= MAX_TUNNELS_PER_AGENT { + return Err(TunnelMutationError::new( + "limit_exceeded", + format!("at most {MAX_TUNNELS_PER_AGENT} tunnels are allowed"), + )); + } + Ok(StoredTunnelSpec { + id: generate_tunnel_id(), + slug_hint: String::new(), + name: input.name.unwrap_or_default().trim().to_string(), + target_url: target.url.to_string(), + expires_at: tunnel_expires_at(ttl_seconds), + project_path_key: normalize_project_path_key(&input.project_path_key.unwrap_or_default()), + created_at: now, + }) +} + +fn prepare_update_spec( + state: &TunnelStoreState, + input: GatewayTunnelUpdateInput, +) -> Result { + let tunnel_id = input.id.trim().to_string(); + if tunnel_id.is_empty() { + return Err(TunnelMutationError::not_found()); + } + let existing = state + .specs + .get(&tunnel_id) + .ok_or_else(TunnelMutationError::not_found)?; + let target = validate_tunnel_target_url(&input.target_url) + .map_err(|error| TunnelMutationError::new("invalid_target", error))?; + // ttlSeconds absent keeps the current expiry; present recomputes from now. + let expires_at = match input.ttl_seconds { + None => existing.expires_at, + Some(ttl_seconds) => { + let ttl_seconds = normalize_tunnel_ttl(Some(ttl_seconds)) + .map_err(|error| TunnelMutationError::new("invalid_ttl", error))?; + tunnel_expires_at(ttl_seconds) + } + }; + Ok(StoredTunnelSpec { + id: existing.id.clone(), + slug_hint: existing.slug_hint.clone(), + name: input.name.unwrap_or_default().trim().to_string(), + target_url: target.url.to_string(), + expires_at, + project_path_key: normalize_project_path_key(&input.project_path_key.unwrap_or_default()), + created_at: existing.created_at, + }) +} + +fn normalize_tunnel_ttl(input: Option) -> Result { + let ttl_seconds = input.unwrap_or(TUNNEL_DEFAULT_TTL_SECONDS); + if TUNNEL_TTL_WHITELIST.contains(&ttl_seconds) { + Ok(ttl_seconds) + } else { + Err("ttlSeconds must be one of 0, 900, 3600, or 14400".to_string()) + } +} + +fn tunnel_expires_at(ttl_seconds: u32) -> i64 { + if ttl_seconds == 0 { + return 0; + } + now_unix_seconds() + i64::from(ttl_seconds) +} + +fn generate_tunnel_id() -> String { + let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S%f"); + format!("tun_{timestamp}_{}", random_url_token(8)) +} + +fn random_url_token(byte_count: usize) -> String { + let bytes = Uuid::new_v4(); + let byte_count = byte_count.min(bytes.as_bytes().len()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes.as_bytes()[..byte_count]) +} + +fn tunnel_state_payload_from_proto(snapshot: &proto::TunnelStateSnapshot) -> TunnelStatePayload { + TunnelStatePayload { + revision: snapshot.revision, + agent_online: snapshot.agent_online, + relay: snapshot + .relay + .as_ref() + .map(tunnel_health_payload_from_proto), + tunnels: snapshot + .tunnels + .iter() + .map(|status| TunnelStatusPayload { + id: status.id.clone(), + slug: status.slug.clone(), + name: status.name.clone(), + target_url: status.target_url.clone(), + public_path: status.public_path.clone(), + created_at: status.created_at, + expires_at: status.expires_at, + active_connections: status.active_connections, + project_path_key: status.project_path_key.clone(), + local: status.local.as_ref().map(tunnel_health_payload_from_proto), + }) + .collect(), + gateway_unsupported: None, + } +} + +fn now_ms() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + duration.as_millis() as i64 +} + +async fn load_tunnel_specs() -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + load_tunnel_specs_sync(&conn) + }) + .await + .map_err(|e| format!("load tunnel specs join failed: {e}"))? +} + +pub(super) async fn persist_tunnel_spec(spec: StoredTunnelSpec) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + persist_tunnel_spec_sync(&conn, &spec) + }) + .await + .map_err(|e| format!("persist tunnel spec join failed: {e}"))? +} + +pub(super) async fn delete_tunnel_specs(tunnel_ids: Vec) -> Result<(), String> { + if tunnel_ids.is_empty() { + return Ok(()); + } + tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + for tunnel_id in &tunnel_ids { + delete_tunnel_spec_sync(&conn, tunnel_id)?; + } + Ok(()) + }) + .await + .map_err(|e| format!("delete tunnel specs join failed: {e}"))? +} + +fn load_tunnel_specs_sync(conn: &Connection) -> Result, String> { + let mut statement = conn + .prepare(&format!( + "SELECT tunnel_id, payload_json FROM {TUNNEL_SETTINGS_TABLE}" + )) + .map_err(|e| format!("read {TUNNEL_SETTINGS_TABLE} failed: {e}"))?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|e| format!("read {TUNNEL_SETTINGS_TABLE} failed: {e}"))?; + let mut specs = Vec::new(); + for row in rows { + let (tunnel_id, payload_json) = + row.map_err(|e| format!("read {TUNNEL_SETTINGS_TABLE} failed: {e}"))?; + match serde_json::from_str::(&payload_json) { + Ok(mut spec) => { + if spec.id.trim().is_empty() { + spec.id = tunnel_id; + } + specs.push(spec); + } + Err(error) => { + eprintln!("parse tunnel spec {tunnel_id} failed: {error}"); + } + } + } + Ok(specs) +} + +fn persist_tunnel_spec_sync(conn: &Connection, spec: &StoredTunnelSpec) -> Result<(), String> { + let payload_json = serde_json::to_string(spec) + .map_err(|e| format!("serialize tunnel spec {} failed: {e}", spec.id))?; + conn.execute( + &format!( + "INSERT OR REPLACE INTO {TUNNEL_SETTINGS_TABLE} (tunnel_id, payload_json, updated_at) VALUES (?1, ?2, ?3)" + ), + params![spec.id, payload_json, now_ms()], + ) + .map_err(|e| format!("write {TUNNEL_SETTINGS_TABLE} failed: {e}"))?; + Ok(()) +} + +fn delete_tunnel_spec_sync(conn: &Connection, tunnel_id: &str) -> Result<(), String> { + conn.execute( + &format!("DELETE FROM {TUNNEL_SETTINGS_TABLE} WHERE tunnel_id = ?1"), + params![tunnel_id], + ) + .map_err(|e| format!("delete from {TUNNEL_SETTINGS_TABLE} failed: {e}"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + generate_tunnel_id, normalize_tunnel_ttl, prepare_create_spec, prepare_update_spec, + tunnel_expires_at, TunnelStoreState, TUNNEL_DEFAULT_TTL_SECONDS, + }; + use crate::services::tunnel::{GatewayTunnelCreateInput, GatewayTunnelUpdateInput}; + + fn create_input(target_url: &str) -> GatewayTunnelCreateInput { + GatewayTunnelCreateInput { + target_url: target_url.to_string(), + name: Some(" dev server ".to_string()), + ttl_seconds: None, + project_path_key: Some("/workspace/project".to_string()), + } + } + + #[test] + fn tunnel_ttl_allows_infinite_expiry() { + assert_eq!(normalize_tunnel_ttl(Some(0)).unwrap(), 0); + assert_eq!(tunnel_expires_at(0), 0); + assert!(normalize_tunnel_ttl(Some(1)).is_err()); + } + + #[test] + fn tunnel_ttl_defaults_when_absent() { + assert_eq!( + normalize_tunnel_ttl(None).unwrap(), + TUNNEL_DEFAULT_TTL_SECONDS + ); + } + + #[test] + fn generated_tunnel_ids_are_prefixed_and_unique() { + let first = generate_tunnel_id(); + let second = generate_tunnel_id(); + assert!(first.starts_with("tun_"), "{first}"); + assert_ne!(first, second); + } + + #[test] + fn create_spec_applies_defaults_and_validation() { + let state = TunnelStoreState::default(); + let now = 1_000; + + let spec = prepare_create_spec(&state, create_input("http://localhost:3000"), now) + .expect("create spec"); + assert_eq!(spec.name, "dev server"); + assert_eq!(spec.target_url, "http://localhost:3000/"); + assert_eq!(spec.created_at, now); + assert!(spec.expires_at > 0, "default ttl should set an expiry"); + assert!(spec.slug_hint.is_empty()); + + let invalid_target = + prepare_create_spec(&state, create_input("http://example.com"), now).unwrap_err(); + assert_eq!(invalid_target.code, "invalid_target"); + + let mut invalid_ttl_input = create_input("http://localhost:3000"); + invalid_ttl_input.ttl_seconds = Some(123); + let invalid_ttl = prepare_create_spec(&state, invalid_ttl_input, now).unwrap_err(); + assert_eq!(invalid_ttl.code, "invalid_ttl"); + } + + #[test] + fn create_spec_enforces_tunnel_limit() { + let mut state = TunnelStoreState::default(); + let now = 1_000; + for index in 0..5 { + let spec = + prepare_create_spec(&state, create_input("http://localhost:3000"), now).unwrap(); + state.specs.insert(format!("tun_{index}"), spec); + } + + let over_limit = + prepare_create_spec(&state, create_input("http://localhost:3000"), now).unwrap_err(); + assert_eq!(over_limit.code, "limit_exceeded"); + + // Expired tunnels no longer count against the limit. + state.specs.values_mut().next().unwrap().expires_at = now - 1; + assert!(prepare_create_spec(&state, create_input("http://localhost:3000"), now).is_ok()); + } + + #[test] + fn update_spec_keeps_expiry_when_ttl_absent() { + let mut state = TunnelStoreState::default(); + let now = 1_000; + let mut spec = + prepare_create_spec(&state, create_input("http://localhost:3000"), now).unwrap(); + spec.expires_at = 4_242; + let tunnel_id = spec.id.clone(); + state.specs.insert(tunnel_id.clone(), spec); + + let kept = prepare_update_spec( + &state, + GatewayTunnelUpdateInput { + id: tunnel_id.clone(), + target_url: "http://127.0.0.1:8080".to_string(), + name: Some("renamed".to_string()), + ttl_seconds: None, + project_path_key: None, + }, + ) + .expect("update spec"); + assert_eq!(kept.expires_at, 4_242); + assert_eq!(kept.name, "renamed"); + assert_eq!(kept.target_url, "http://127.0.0.1:8080/"); + + let recomputed = prepare_update_spec( + &state, + GatewayTunnelUpdateInput { + id: tunnel_id, + target_url: "http://127.0.0.1:8080".to_string(), + name: None, + ttl_seconds: Some(900), + project_path_key: None, + }, + ) + .expect("update spec with ttl"); + assert_ne!(recomputed.expires_at, 4_242); + assert!(recomputed.expires_at > 0); + + let missing = prepare_update_spec( + &state, + GatewayTunnelUpdateInput { + id: "tun_missing".to_string(), + target_url: "http://127.0.0.1:8080".to_string(), + name: None, + ttl_seconds: None, + project_path_key: None, + }, + ) + .unwrap_err(); + assert_eq!(missing.code, "not_found"); + } +} diff --git a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx index 780c0ddc..0be302c3 100644 --- a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx @@ -1,8 +1,19 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocale } from "../../i18n"; import { workspaceProjectPathKey } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; +import { + composePublicUrl, + type LocalTunnelClient, + TUNNEL_TTL_OPTIONS, + type TunnelCreateInput, + type TunnelHealth, + type TunnelStateSnapshot, + type TunnelStatus, + type TunnelTtlSeconds, + type TunnelUpdateInput, + validateLocalHttpTarget, +} from "../../lib/tunnels/constants"; import { AlertTriangle, Check, @@ -24,64 +35,26 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Label } from "../ui/label"; -export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; - -export type TunnelCreateInput = { - targetUrl: string; - name?: string; - ttlSeconds: TunnelTtlSeconds; - projectPathKey?: string; -}; - -export type TunnelUpdateInput = { - id: string; - targetUrl: string; - name?: string; - ttlSeconds: TunnelTtlSeconds; - projectPathKey?: string; -}; - -export type TunnelSummary = { - id: string; - slug: string; - name: string; - targetUrl: string; - publicUrl: string; - createdAt: number; - expiresAt: number; - status: "active" | "expired" | "offline"; - projectPathKey?: string; - diagnostics?: TunnelDiagnostic[]; -}; - -export type TunnelDiagnostic = { - protocol: "http" | "websocket" | "sse"; - status: "ok" | "failed" | "unknown"; - statusCode: number; - errorCode: string; - message: string; - checkedAt: number; -}; - -export type LocalTunnelClient = { - listTunnels(): Promise; - createTunnel(input: TunnelCreateInput): Promise; - updateTunnel(input: TunnelUpdateInput): Promise; - probeTunnel(id: string): Promise; - closeTunnel(id: string): Promise; -}; +export type { LocalTunnelClient } from "../../lib/tunnels/constants"; type LocalTunnelPanelProps = { - client: LocalTunnelClient; - enabled?: boolean; + client: LocalTunnelClient | null; + enabled: boolean; disabledMessage?: string; projectPathKey?: string; - refreshToken?: number; + publicBaseUrl: string; + onOpenExternal?: (url: string) => void; }; type TunnelScope = "project" | "global"; -const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; +type TunnelRowAction = "save" | "close" | "check"; + +type HealthDisplayStatus = TunnelHealth["status"]; + +// "keep" leaves ttl_seconds out of tunnel.update so the current expiry is +// preserved instead of silently re-bucketing it. +type EditTtlValue = TunnelTtlSeconds | "keep"; const TUNNEL_SCOPE_OPTIONS: Array<{ scope: TunnelScope; @@ -100,49 +73,43 @@ const TUNNEL_SCOPE_OPTIONS: Array<{ }, ]; -const TTL_OPTIONS: Array<{ value: TunnelTtlSeconds; labelKey: string }> = [ - { value: 900, labelKey: "projectTools.tunnelTtl15m" }, - { value: 3600, labelKey: "projectTools.tunnelTtl1h" }, - { value: 14400, labelKey: "projectTools.tunnelTtl4h" }, - { value: 0, labelKey: "projectTools.tunnelTtlInfinite" }, -]; - -const TUNNEL_DIAGNOSTIC_PROTOCOLS: TunnelDiagnostic["protocol"][] = [ - "http", - "websocket", - "sse", -]; - const TUNNEL_INPUT_CLASS = "h-8 min-w-0 rounded-lg border-border/60 bg-background/80 text-[11px] placeholder:text-[11px] transition-[border-color,box-shadow,background-color] focus-visible:border-muted-foreground/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-muted-foreground/15 focus-visible:ring-offset-0"; +function ttlLabelKey(value: TunnelTtlSeconds) { + if (value === 900) return "projectTools.tunnelTtl15m"; + if (value === 3600) return "projectTools.tunnelTtl1h"; + if (value === 14400) return "projectTools.tunnelTtl4h"; + return "projectTools.tunnelTtlInfinite"; +} + function TtlSegmented({ value, onChange, disabled, }: { - value: TunnelTtlSeconds; + value: TunnelTtlSeconds | null; onChange: (value: TunnelTtlSeconds) => void; disabled?: boolean; }) { const { t } = useLocale(); return (
- {TTL_OPTIONS.map((option) => { - const active = value === option.value; + {TUNNEL_TTL_OPTIONS.map((option) => { + const active = value === option; return ( ); })} @@ -150,44 +117,46 @@ function TtlSegmented({ ); } -function normalizeTunnelHostname(hostname: string) { - return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); -} - -function isIpv4Address(hostname: string) { - const parts = hostname.split("."); - if (parts.length !== 4) return false; - return parts.every((part) => { - if (!/^\d+$/.test(part)) return false; - const value = Number(part); - return value >= 0 && value <= 255 && String(value) === part; - }); +function healthStatusLabelKey(status: HealthDisplayStatus) { + if (status === "ok") return "projectTools.tunnelHealthOk"; + if (status === "failed") return "projectTools.tunnelHealthFailed"; + return "projectTools.tunnelHealthUnknown"; } -function isIpAddress(hostname: string) { - if (isIpv4Address(hostname)) return true; - return hostname.includes(":"); -} - -function validateLocalHttpTarget(input: string) { - const value = input.trim(); - if (!value) return "projectTools.tunnelTargetRequired"; - try { - const url = new URL(value); - if (url.protocol !== "http:") { - return "projectTools.tunnelInvalidUrl"; - } - const hostname = normalizeTunnelHostname(url.hostname); - if (hostname !== "localhost" && !isIpAddress(hostname)) { - return "projectTools.tunnelLocalhostOnly"; - } - if (url.username || url.password || url.hash) { - return "projectTools.tunnelInvalidUrl"; - } - } catch { - return "projectTools.tunnelInvalidUrl"; - } - return null; +function HealthBadge({ + label, + status, + title, +}: { + label: string; + status: HealthDisplayStatus; + title?: string; +}) { + return ( + + + {label} + + ); } function asErrorMessage(error: unknown) { @@ -234,43 +203,21 @@ function fallbackWriteTextToClipboard(text: string) { } } -function displayTunnelName(tunnel: TunnelSummary) { +function displayTunnelName(tunnel: TunnelStatus) { return tunnel.name.trim() || tunnel.targetUrl; } -function tunnelStatusKey(status: TunnelSummary["status"]) { - if (status === "expired") return "projectTools.tunnelStatusExpired"; - if (status === "offline") return "projectTools.tunnelStatusOffline"; - return "projectTools.tunnelStatusActive"; -} - function normalizeProjectPathKey(value: string | undefined) { return workspaceProjectPathKey(value ?? ""); } -function ttlFromTunnel(tunnel: TunnelSummary, nowSeconds: number): TunnelTtlSeconds { - if (!tunnel.expiresAt) return 0; - const remaining = Math.max(0, tunnel.expiresAt - nowSeconds); - if (remaining <= 900) return 900; - if (remaining <= 3600) return 3600; - return 14400; -} - -function diagnosticFor(tunnel: TunnelSummary, protocol: TunnelDiagnostic["protocol"]) { - return tunnel.diagnostics?.find((item) => item.protocol === protocol); -} - -function diagnosticLabel(protocol: TunnelDiagnostic["protocol"]) { - if (protocol === "websocket") return "WS"; - return protocol.toUpperCase(); -} - export function LocalTunnelPanel({ client, - enabled = true, + enabled, disabledMessage, projectPathKey, - refreshToken, + publicBaseUrl, + onOpenExternal, }: LocalTunnelPanelProps) { const { t } = useLocale(); const normalizedProjectPathKey = useMemo( @@ -284,78 +231,52 @@ export function LocalTunnelPanel({ const [name, setName] = useState(""); const [ttlSeconds, setTtlSeconds] = useState(3600); const [createOpen, setCreateOpen] = useState(true); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); const [editingId, setEditingId] = useState(""); const [editTargetUrl, setEditTargetUrl] = useState(""); const [editName, setEditName] = useState(""); - const [editTtlSeconds, setEditTtlSeconds] = useState(3600); - const [tunnels, setTunnels] = useState([]); - const [loading, setLoading] = useState(false); - const [creating, setCreating] = useState(false); - const [savingId, setSavingId] = useState(""); - const [closingId, setClosingId] = useState(""); - const [probingId, setProbingId] = useState(""); + const [editTtlSeconds, setEditTtlSeconds] = useState("keep"); + const [snapshot, setSnapshot] = useState(null); + const [pendingActions, setPendingActions] = useState>({}); + const [rowErrors, setRowErrors] = useState>({}); + const [checkingAll, setCheckingAll] = useState(false); const [copiedId, setCopiedId] = useState(""); - const [error, setError] = useState(null); const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000)); - const refreshTokenRef = useRef(refreshToken); const targetValidationKey = useMemo(() => validateLocalHttpTarget(targetUrl), [targetUrl]); const editTargetValidationKey = useMemo( () => (editingId ? validateLocalHttpTarget(editTargetUrl) : null), [editTargetUrl, editingId], ); - const refresh = useCallback( - (options?: { showLoading?: boolean }) => { - const showLoading = options?.showLoading ?? true; - if (showLoading) { - setLoading(true); - } - setError(null); - return client - .listTunnels() - .then((items) => setTunnels(items)) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => { - if (showLoading) { - setLoading(false); - } - }); - }, - [client], - ); - useEffect(() => { - void refresh(); - }, [refresh]); - - useEffect(() => { - if (refreshTokenRef.current === refreshToken) return; - refreshTokenRef.current = refreshToken; - void refresh({ showLoading: false }); - }, [refresh, refreshToken]); - - useEffect(() => { - const handleTunnelManagerChanged = () => { - void refresh({ showLoading: false }); - }; - window.addEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); - return () => - window.removeEventListener(TUNNEL_MANAGER_CHANGED_EVENT, handleTunnelManagerChanged); - }, [refresh]); + if (!client) return; + return client.subscribeTunnelState((next) => { + setSnapshot((current) => (current && next.revision <= current.revision ? current : next)); + }); + }, [client]); useEffect(() => { if (!normalizedProjectPathKey && scope === "project") { setScope("global"); - setError(null); + setCreateError(null); } }, [normalizedProjectPathKey, scope]); + const tunnels = useMemo(() => snapshot?.tunnels ?? [], [snapshot]); + const hasFiniteExpiry = useMemo( + () => tunnels.some((tunnel) => tunnel.expiresAt > 0), + [tunnels], + ); + useEffect(() => { + if (!hasFiniteExpiry) return; + setNowSeconds(Math.floor(Date.now() / 1000)); const timer = window.setInterval(() => { setNowSeconds(Math.floor(Date.now() / 1000)); }, 1000); return () => window.clearInterval(timer); - }, []); + }, [hasFiniteExpiry]); useEffect(() => { if (!copiedId) return; @@ -363,13 +284,37 @@ export function LocalTunnelPanel({ return () => window.clearTimeout(timer); }, [copiedId]); + const gatewayUnsupported = snapshot?.gatewayUnsupported === true; + const mutationsEnabled = enabled && !gatewayUnsupported && Boolean(client); + + const beginRowAction = useCallback((id: string, action: TunnelRowAction) => { + setPendingActions((current) => ({ ...current, [id]: action })); + setRowErrors((current) => { + if (!(id in current)) return current; + const { [id]: _removed, ...rest } = current; + return rest; + }); + }, []); + + const endRowAction = useCallback((id: string) => { + setPendingActions((current) => { + if (!(id in current)) return current; + const { [id]: _removed, ...rest } = current; + return rest; + }); + }, []); + + const setRowError = useCallback((id: string, message: string) => { + setRowErrors((current) => ({ ...current, [id]: message })); + }, []); + const createTunnel = useCallback(() => { const validationKey = validateLocalHttpTarget(targetUrl); if (validationKey) { - setError(t(validationKey)); + setCreateError(t(validationKey)); return; } - if (!enabled || creating) return; + if (!client || !mutationsEnabled || creating) return; const input: TunnelCreateInput = { targetUrl: targetUrl.trim(), name: name.trim() || undefined, @@ -379,135 +324,168 @@ export function LocalTunnelPanel({ input.projectPathKey = normalizedProjectPathKey; } setCreating(true); - setError(null); + setCreateError(null); void client .createTunnel(input) - .then((created) => { - setTunnels((current) => [ - created, - ...current.filter((item) => item.id !== created.id && item.slug !== created.slug), - ]); - setName(""); - void refresh({ showLoading: false }); - }) - .catch((err) => setError(asErrorMessage(err))) + .then(() => setName("")) + .catch((err) => setCreateError(asErrorMessage(err))) .finally(() => setCreating(false)); }, [ client, creating, - enabled, + mutationsEnabled, name, normalizedProjectPathKey, - refresh, scope, t, targetUrl, ttlSeconds, ]); - const beginEdit = useCallback( - (tunnel: TunnelSummary) => { - setEditingId(tunnel.id); - setEditTargetUrl(tunnel.targetUrl); - setEditName(tunnel.name); - setEditTtlSeconds(ttlFromTunnel(tunnel, nowSeconds)); - setError(null); - }, - [nowSeconds], - ); + const beginEdit = useCallback((tunnel: TunnelStatus) => { + setEditingId(tunnel.id); + setEditTargetUrl(tunnel.targetUrl); + setEditName(tunnel.name); + setEditTtlSeconds("keep"); + setRowErrors((current) => { + if (!(tunnel.id in current)) return current; + const { [tunnel.id]: _removed, ...rest } = current; + return rest; + }); + }, []); const cancelEdit = useCallback(() => { setEditingId(""); setEditTargetUrl(""); setEditName(""); - setEditTtlSeconds(3600); - setError(null); + setEditTtlSeconds("keep"); }, []); const updateTunnel = useCallback( - (tunnel: TunnelSummary) => { + (tunnel: TunnelStatus) => { const validationKey = validateLocalHttpTarget(editTargetUrl); if (validationKey) { - setError(t(validationKey)); + setRowError(tunnel.id, t(validationKey)); return; } - if (!enabled || savingId) return; + if (!client || !mutationsEnabled || pendingActions[tunnel.id]) return; const input: TunnelUpdateInput = { id: tunnel.id, targetUrl: editTargetUrl.trim(), name: editName.trim() || undefined, - ttlSeconds: editTtlSeconds, }; + // Only re-bucket the expiry when the user explicitly picked a TTL. + if (editTtlSeconds !== "keep") { + input.ttlSeconds = editTtlSeconds; + } const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); if (tunnelProjectPathKey) { input.projectPathKey = tunnelProjectPathKey; } - setSavingId(tunnel.id); - setError(null); + beginRowAction(tunnel.id, "save"); void client .updateTunnel(input) - .then((updated) => { - setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))); - cancelEdit(); - }) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setSavingId((current) => (current === tunnel.id ? "" : current))); + .then(() => cancelEdit()) + .catch((err) => setRowError(tunnel.id, asErrorMessage(err))) + .finally(() => endRowAction(tunnel.id)); }, - [cancelEdit, client, editName, editTargetUrl, editTtlSeconds, enabled, savingId, t], + [ + beginRowAction, + cancelEdit, + client, + editName, + editTargetUrl, + editTtlSeconds, + endRowAction, + mutationsEnabled, + pendingActions, + setRowError, + t, + ], ); const closeTunnel = useCallback( (id: string) => { - if (!enabled || closingId) return; - setClosingId(id); - setError(null); + if (!client || !mutationsEnabled || pendingActions[id]) return; + beginRowAction(id, "close"); void client .closeTunnel(id) - .then((closed) => { - setTunnels((current) => - current - .filter((item) => item.id !== id) - .concat(closed.status === "active" ? [closed] : []), - ); - }) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setClosingId((current) => (current === id ? "" : current))); + .catch((err) => setRowError(id, asErrorMessage(err))) + .finally(() => endRowAction(id)); }, - [client, closingId, enabled], + [beginRowAction, client, endRowAction, mutationsEnabled, pendingActions, setRowError], ); - const probeTunnel = useCallback( + const checkTunnel = useCallback( (id: string) => { - if (!enabled || probingId) return; - setProbingId(id); - setError(null); + if (!client || !mutationsEnabled || pendingActions[id]) return; + beginRowAction(id, "check"); void client - .probeTunnel(id) - .then((updated) => - setTunnels((current) => current.map((item) => (item.id === updated.id ? updated : item))), - ) - .catch((err) => setError(asErrorMessage(err))) - .finally(() => setProbingId((current) => (current === id ? "" : current))); + .checkTunnel(id) + .catch((err) => setRowError(id, asErrorMessage(err))) + .finally(() => endRowAction(id)); }, - [client, enabled, probingId], + [beginRowAction, client, endRowAction, mutationsEnabled, pendingActions, setRowError], ); - const copyLink = useCallback((tunnel: TunnelSummary) => { - if (!tunnel.publicUrl) return; - void writeTextToClipboard(tunnel.publicUrl) - .then((copied) => { - if (copied) { - setCopiedId(tunnel.id); - } - }) - .catch(() => {}); - }, []); + const checkAllTunnels = useCallback(() => { + if (!client || !mutationsEnabled || checkingAll) return; + setCheckingAll(true); + setCreateError(null); + void client + .checkTunnel() + .catch((err) => setCreateError(asErrorMessage(err))) + .finally(() => setCheckingAll(false)); + }, [checkingAll, client, mutationsEnabled]); - const openLink = useCallback((tunnel: TunnelSummary) => { - if (!tunnel.publicUrl) return; - setError(null); - void openUrl(tunnel.publicUrl).catch((err) => setError(asErrorMessage(err))); - }, []); + const publicUrlFor = useCallback( + (tunnel: TunnelStatus) => composePublicUrl(publicBaseUrl, tunnel.publicPath), + [publicBaseUrl], + ); + + const copyLink = useCallback( + (tunnel: TunnelStatus) => { + const url = publicUrlFor(tunnel); + if (!url) return; + void writeTextToClipboard(url) + .then((copied) => { + if (copied) { + setCopiedId(tunnel.id); + } + }) + .catch(() => {}); + }, + [publicUrlFor], + ); + + const openLink = useCallback( + (tunnel: TunnelStatus) => { + const url = publicUrlFor(tunnel); + if (!url) return; + if (onOpenExternal) { + onOpenExternal(url); + return; + } + window.open(url, "_blank", "noopener,noreferrer"); + }, + [onOpenExternal, publicUrlFor], + ); + + const healthTitle = useCallback( + (health: TunnelHealth | null) => { + if (!health) return t("projectTools.tunnelHealthUnknown"); + return [ + t(healthStatusLabelKey(health.status)), + health.httpStatus > 0 ? `HTTP ${health.httpStatus}` : "", + health.rttMs > 0 ? `${health.rttMs}ms` : "", + health.error, + health.checkedAt > 0 ? formatDateTime(health.checkedAt) : "", + ] + .filter(Boolean) + .join(" · "); + }, + [t], + ); const scopedTunnels = useMemo( () => @@ -526,13 +504,17 @@ export function LocalTunnelPanel({ () => [...scopedTunnels].sort((a, b) => b.createdAt - a.createdAt), [scopedTunnels], ); + const loading = Boolean(client) && snapshot === null; + const agentOnline = snapshot?.agentOnline === true; + const linkStatus: HealthDisplayStatus = snapshot ? (agentOnline ? "ok" : "failed") : "unknown"; + const relayStatus: HealthDisplayStatus = snapshot?.relay?.status ?? "unknown"; const canCreate = - enabled && + mutationsEnabled && !creating && !targetValidationKey && (scope !== "project" || Boolean(normalizedProjectPathKey)); const showCreateForm = scope === "project" && Boolean(normalizedProjectPathKey); - const createFieldsDisabled = !showCreateForm || !createOpen || !enabled || creating; + const createFieldsDisabled = !showCreateForm || !createOpen || !mutationsEnabled || creating; return (
@@ -550,9 +532,39 @@ export function LocalTunnelPanel({
-
+ + + + +
+
{ setScope(option.scope); - setError(null); + setCreateError(null); }} className={cn( "relative z-10 flex h-7 min-w-0 transform-gpu items-center justify-center gap-1.5 rounded-[7px] px-2 text-xs text-muted-foreground transition-[color,transform] duration-200 ease-out hover:text-foreground active:scale-[0.98] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100", @@ -586,7 +598,7 @@ export function LocalTunnelPanel({ ); })} - +
@@ -597,6 +609,13 @@ export function LocalTunnelPanel({
) : null} + {gatewayUnsupported ? ( +
+ + {t("projectTools.tunnelGatewayUnsupported")} +
+ ) : null} + {normalizedProjectPathKey ? (
@@ -657,10 +674,7 @@ export function LocalTunnelPanel({ }} >
-
) : null} - {error ? ( + {createError ? (
- {error} + {createError}
) : null} @@ -778,9 +787,15 @@ export function LocalTunnelPanel({ {sortedTunnels.map((tunnel) => { const hasExpiry = tunnel.expiresAt > 0; const remaining = hasExpiry ? tunnel.expiresAt - nowSeconds : 0; - const expired = tunnel.status === "expired" || (hasExpiry && remaining <= 0); + const expired = hasExpiry && remaining <= 0; + const offline = snapshot !== null && !agentOnline; const isEditing = editingId === tunnel.id; - const updating = savingId === tunnel.id; + const pendingAction = pendingActions[tunnel.id]; + const updating = pendingAction === "save"; + const rowError = rowErrors[tunnel.id]; + const publicUrl = publicUrlFor(tunnel); + const localHealth = tunnel.local; + const localStatus: HealthDisplayStatus = localHealth?.status ?? "unknown"; const tunnelProjectPathKey = normalizeProjectPathKey(tunnel.projectPathKey); const handleEditKeyDown = (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing) return; @@ -804,7 +819,7 @@ export function LocalTunnelPanel({ - {t(tunnelStatusKey(expired ? "expired" : tunnel.status))} + {t( + offline + ? "projectTools.tunnelStatusOffline" + : expired + ? "projectTools.tunnelStatusExpired" + : "projectTools.tunnelStatusActive", + )}
@@ -840,7 +861,7 @@ export function LocalTunnelPanel({ value={editTargetUrl} onChange={(event) => setEditTargetUrl(event.target.value)} onKeyDown={handleEditKeyDown} - disabled={!enabled || updating} + disabled={!mutationsEnabled || updating} inputMode="url" autoComplete="off" spellCheck={false} @@ -866,7 +887,7 @@ export function LocalTunnelPanel({ onChange={(event) => setEditName(event.target.value)} onKeyDown={handleEditKeyDown} placeholder={t("projectTools.tunnelNamePlaceholder")} - disabled={!enabled || updating} + disabled={!mutationsEnabled || updating} autoComplete="off" className={TUNNEL_INPUT_CLASS} /> @@ -875,10 +896,23 @@ export function LocalTunnelPanel({ +
@@ -899,7 +933,7 @@ export function LocalTunnelPanel({ type="button" size="sm" className="h-7 gap-1 rounded-lg px-2.5 text-xs" - disabled={!enabled || updating || Boolean(editTargetValidationKey)} + disabled={!mutationsEnabled || updating || Boolean(editTargetValidationKey)} onClick={() => updateTunnel(tunnel)} title={ updating @@ -921,7 +955,7 @@ export function LocalTunnelPanel({
-
- {TUNNEL_DIAGNOSTIC_PROTOCOLS.map((protocol) => { - const diagnostic = diagnosticFor(tunnel, protocol); - const status = diagnostic?.status ?? "unknown"; - const title = diagnostic - ? [ - diagnosticLabel(protocol), - t( - status === "ok" - ? "projectTools.tunnelDiagnosticOk" - : status === "failed" - ? "projectTools.tunnelDiagnosticFailed" - : "projectTools.tunnelDiagnosticUnknown", - ), - diagnostic.statusCode ? String(diagnostic.statusCode) : "", - diagnostic.errorCode, - diagnostic.message, - ] - .filter(Boolean) - .join(" · ") - : t("projectTools.tunnelDiagnosticUnknown"); - return ( -
- - {diagnosticLabel(protocol)} -
- ); - })} +
+
+ + {t("projectTools.tunnelServiceLabel")} + {localStatus === "ok" && localHealth && localHealth.httpStatus > 0 ? ( + HTTP {localHealth.httpStatus} + ) : ( + {t(healthStatusLabelKey(localStatus))} + )} +
+ {rowError ? ( +
+ {rowError} +
+ ) : null}
probeTunnel(tunnel.id)} - title={!enabled ? disabledMessage : t("projectTools.tunnelProbe")} - aria-label={t("projectTools.tunnelProbe")} + disabled={!mutationsEnabled || expired || Boolean(pendingAction)} + onClick={() => checkTunnel(tunnel.id)} + title={!enabled ? disabledMessage : t("projectTools.tunnelCheckAction")} + aria-label={t("projectTools.tunnelCheckAction")} > - {probingId === tunnel.id ? ( + {pendingAction === "check" ? ( ) : ( @@ -1051,7 +1071,7 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground" - disabled={!enabled || expired} + disabled={!mutationsEnabled || expired} onClick={() => beginEdit(tunnel)} title={!enabled ? disabledMessage : t("projectTools.tunnelEdit")} aria-label={t("projectTools.tunnelEdit")} @@ -1063,7 +1083,7 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground" - disabled={!tunnel.publicUrl || expired} + disabled={!publicUrl || expired} onClick={() => openLink(tunnel)} title={t("projectTools.tunnelOpenLink")} aria-label={t("projectTools.tunnelOpenLink")} @@ -1075,12 +1095,12 @@ export function LocalTunnelPanel({ variant="ghost" size="icon" className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive" - disabled={!enabled || closingId === tunnel.id} + disabled={!mutationsEnabled || Boolean(pendingAction)} onClick={() => closeTunnel(tunnel.id)} title={!enabled ? disabledMessage : t("projectTools.tunnelClose")} aria-label={t("projectTools.tunnelClose")} > - {closingId === tunnel.id ? ( + {pendingAction === "close" ? ( ) : ( diff --git a/crates/agent-gui/src/components/project-tools/RightDockContent.tsx b/crates/agent-gui/src/components/project-tools/RightDockContent.tsx index 4834a565..3c9b3247 100644 --- a/crates/agent-gui/src/components/project-tools/RightDockContent.tsx +++ b/crates/agent-gui/src/components/project-tools/RightDockContent.tsx @@ -1,3 +1,4 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; import type { RefObject } from "react"; import { useLocale } from "../../i18n"; import type { GitClient } from "../../lib/git/types"; @@ -36,7 +37,7 @@ type RightDockContentProps = { tunnelClient?: LocalTunnelClient | null; tunnelEnabled: boolean; tunnelDisabledMessage?: string; - tunnelRefreshToken?: number; + tunnelPublicBaseUrl: string; sshHosts: SshHostConfig[]; associatedSshHostIds: string[]; client: TerminalClient; @@ -83,7 +84,7 @@ export function RightDockContent(props: RightDockContentProps) { tunnelClient, tunnelEnabled, tunnelDisabledMessage, - tunnelRefreshToken, + tunnelPublicBaseUrl, sshHosts, associatedSshHostIds, client, @@ -163,7 +164,10 @@ export function RightDockContent(props: RightDockContentProps) { enabled={tunnelEnabled} disabledMessage={tunnelDisabledMessage} projectPathKey={projectPathKey} - refreshToken={tunnelRefreshToken} + publicBaseUrl={tunnelPublicBaseUrl} + onOpenExternal={(url) => { + void openUrl(url); + }} />
) : null} diff --git a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx index fa02f327..61e555ba 100644 --- a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx @@ -56,7 +56,7 @@ type RightDockPanelProps = { tunnelClient?: LocalTunnelClient | null; tunnelEnabled?: boolean; tunnelDisabledMessage?: string; - tunnelRefreshToken?: number; + tunnelPublicBaseUrl: string; onWidthChange: (width: number) => void; onProjectStateChange: ( updater: (current: RightDockProjectState) => RightDockProjectState, @@ -326,7 +326,7 @@ export function RightDockPanel(props: RightDockPanelProps) { tunnelClient, tunnelEnabled = true, tunnelDisabledMessage, - tunnelRefreshToken, + tunnelPublicBaseUrl, onWidthChange, onProjectStateChange, onFileTreeStateChange, @@ -642,7 +642,7 @@ export function RightDockPanel(props: RightDockPanelProps) { tunnelClient={tunnelClient} tunnelEnabled={tunnelEnabled} tunnelDisabledMessage={tunnelDisabledMessage} - tunnelRefreshToken={tunnelRefreshToken} + tunnelPublicBaseUrl={tunnelPublicBaseUrl} sshHosts={sshHosts} associatedSshHostIds={associatedSshHostIds} client={client} diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index c1a62676..3be8f89b 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -370,7 +370,6 @@ export const translations: Record> = { "projectTools.tunnelTargetRequired": "请输入 HTTP 服务地址。", "projectTools.tunnelInvalidUrl": "请输入有效的 http 地址,不能包含账号、密码或片段。", "projectTools.tunnelLocalhostOnly": "仅支持 localhost 或 IPv4/IPv6 地址。", - "projectTools.tunnelRemoteOffline": "Remote Gateway 未连接,连接后才能创建或关闭 tunnel。", "projectTools.tunnelWebDisabled": "桌面端 Remote 设置未允许 WebUI 创建或关闭内网穿透。", "projectTools.tunnelStatusActive": "运行中", "projectTools.tunnelStatusExpired": "已过期", @@ -383,10 +382,15 @@ export const translations: Record> = { "projectTools.tunnelCopyLink": "复制链接", "projectTools.tunnelCopied": "已复制", "projectTools.tunnelOpenLink": "打开链接", - "projectTools.tunnelProbe": "重新检测", - "projectTools.tunnelDiagnosticOk": "可用", - "projectTools.tunnelDiagnosticFailed": "失败", - "projectTools.tunnelDiagnosticUnknown": "未检测", + "projectTools.tunnelLinkLabel": "链路", + "projectTools.tunnelRelayLabel": "中继", + "projectTools.tunnelServiceLabel": "本地服务", + "projectTools.tunnelHealthOk": "正常", + "projectTools.tunnelHealthFailed": "异常", + "projectTools.tunnelHealthUnknown": "未知", + "projectTools.tunnelGatewayUnsupported": "网关版本过旧,请升级后使用隧道", + "projectTools.tunnelKeepCurrentTtl": "保持当前有效期", + "projectTools.tunnelCheckAction": "检查", "projectTools.tunnelClose": "删除链接", "projectTools.gitReview.viewChanges": "查看更改", "projectTools.gitReview.discardChanges": "放弃更改", @@ -1896,8 +1900,6 @@ export const translations: Record> = { "projectTools.tunnelTargetRequired": "Enter an HTTP service URL.", "projectTools.tunnelInvalidUrl": "Enter a valid http URL without credentials or fragments.", "projectTools.tunnelLocalhostOnly": "Only localhost or IPv4/IPv6 addresses are supported.", - "projectTools.tunnelRemoteOffline": - "Remote Gateway is not connected. Connect it before creating or closing tunnels.", "projectTools.tunnelWebDisabled": "Desktop Remote settings do not allow WebUI tunnel create or close.", "projectTools.tunnelStatusActive": "Active", @@ -1911,10 +1913,15 @@ export const translations: Record> = { "projectTools.tunnelCopyLink": "Copy link", "projectTools.tunnelCopied": "Copied", "projectTools.tunnelOpenLink": "Open link", - "projectTools.tunnelProbe": "Recheck", - "projectTools.tunnelDiagnosticOk": "Available", - "projectTools.tunnelDiagnosticFailed": "Failed", - "projectTools.tunnelDiagnosticUnknown": "Not checked", + "projectTools.tunnelLinkLabel": "Link", + "projectTools.tunnelRelayLabel": "Relay", + "projectTools.tunnelServiceLabel": "Service", + "projectTools.tunnelHealthOk": "OK", + "projectTools.tunnelHealthFailed": "Failed", + "projectTools.tunnelHealthUnknown": "Unknown", + "projectTools.tunnelGatewayUnsupported": "The gateway is outdated; upgrade it to use tunnels", + "projectTools.tunnelKeepCurrentTtl": "Keep current expiry", + "projectTools.tunnelCheckAction": "Check", "projectTools.tunnelClose": "Delete link", "projectTools.gitReview.viewChanges": "View Changes", "projectTools.gitReview.discardChanges": "Discard Changes", diff --git a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts index f8a2558d..6c7f4697 100644 --- a/crates/agent-gui/src/lib/chat/messages/uiMessages.ts +++ b/crates/agent-gui/src/lib/chat/messages/uiMessages.ts @@ -280,7 +280,6 @@ export function summarizeToolCall( typeof args.targetUrl === "string" ? `target=${summarizeToolArg(args.targetUrl)}` : null, - typeof args.slug === "string" ? `slug=${summarizeToolArg(args.slug)}` : null, typeof args.id === "string" ? `id=${summarizeToolArg(args.id)}` : null, ] : name === "SSHManager" || name === "SshManager" diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 262fc5b1..bb2cc8b2 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -38,7 +38,7 @@ import { createSkillTools } from "./skillTools"; import { createSSHManagerTools, type SshManagerSessionChange } from "./sshManagerTools"; import type { SystemToolId, SystemToolRuntimeScope } from "./systemToolOptions"; import { createTerminalTools } from "./terminalTools"; -import { createTunnelManagerTools } from "./tunnelManagerTools"; +import { createTunnelManagerTools, type TunnelManagerChange } from "./tunnelManagerTools"; export type BuiltinToolRegistry = { tools: BuiltinToolBundle["tools"]; @@ -223,34 +223,13 @@ type BuildBuiltinBaseToolRegistryParams = { mcpLoadFailureMode?: "continue" | "throw"; memoryToolMode?: "rw" | "ro"; remoteWebTunnelsEnabled?: boolean; - remoteGatewayOnline?: boolean; tunnelProjectPathKey?: string; + tunnelPublicBaseUrl?: string; sshHosts?: SshHostConfig[]; associatedSshHostIds?: string[]; sshManagerRemoteAllowed?: boolean; onSshSessionsChanged?: (change: SshManagerSessionChange) => void | Promise; - onTunnelsChanged?: (change: { - action: "create" | "probe" | "close"; - tunnel: { - id: string; - slug: string; - name: string; - targetUrl: string; - publicUrl: string; - createdAt: number; - expiresAt: number; - status: "active" | "expired" | "offline"; - projectPathKey?: string; - diagnostics?: Array<{ - protocol: "http" | "websocket" | "sse"; - status: "ok" | "failed" | "unknown"; - statusCode: number; - errorCode: string; - message: string; - checkedAt: number; - }>; - }; - }) => void | Promise; + onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; }; async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryParams) { @@ -305,12 +284,10 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP mode: params.memoryToolMode ?? "rw", }), createTunnelManagerTools({ - enabled: - params.remoteWebTunnelsEnabled === true && - params.remoteGatewayOnline === true && - params.runtimeScope === "chat", + enabled: params.remoteWebTunnelsEnabled === true && params.runtimeScope === "chat", runtimeScope: params.runtimeScope, projectPathKey: params.tunnelProjectPathKey, + publicBaseUrl: params.tunnelPublicBaseUrl, onTunnelsChanged: params.onTunnelsChanged, }), createSSHManagerTools({ diff --git a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts index 28ee6c81..62553099 100644 --- a/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/tunnelManagerTools.ts @@ -2,67 +2,42 @@ import type { Tool, ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; +import { + composePublicUrl, + type TunnelHealth, + type TunnelStateSnapshot, + type TunnelStatus, + type TunnelTtlSeconds, +} from "../tunnels/constants"; import { type BuiltinToolBundle, createBuiltinMetadataMap } from "./builtinTypes"; -type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; - -export const TUNNEL_MANAGER_CHANGED_EVENT = "liveagent:tunnel-manager-changed"; - -type TunnelSummary = { - id: string; - slug: string; - name: string; - targetUrl: string; - publicUrl: string; - createdAt: number; - expiresAt: number; - activeConnections: number; - status: "active" | "expired" | "offline"; - projectPathKey?: string; - diagnostics?: TunnelDiagnostic[]; -}; - -type TunnelDiagnostic = { - protocol: "http" | "websocket" | "sse"; - status: "ok" | "failed" | "unknown"; - statusCode: number; - errorCode: string; - message: string; - checkedAt: number; -}; - -type TunnelManagerTunnelSummary = Omit; - -export type TunnelChangeAction = "create" | "probe" | "close"; +export type TunnelChangeAction = "create" | "close" | "check"; export type TunnelManagerChange = { action: TunnelChangeAction; - tunnel: TunnelManagerTunnelSummary; -}; - -type TunnelCreateInput = { - targetUrl: string; - name?: string; - ttlSeconds: TunnelTtlSeconds; projectPathKey?: string; }; -type TunnelManagerAction = "list" | "create" | "probe" | "close"; +type TunnelManagerAction = "list" | "create" | "close" | "check"; type TunnelManagerDetails = { kind: "tunnel_manager"; action: TunnelManagerAction; - tunnels?: TunnelManagerTunnelSummary[]; - tunnel?: TunnelManagerTunnelSummary; + tunnels?: TunnelStatus[]; + tunnel?: TunnelStatus; }; +// Health checks run asynchronously on the agent; after triggering one we wait +// briefly before sampling the state snapshot so fresh results can land. +const CHECK_SETTLE_DELAY_MS = 2500; + const TUNNEL_MANAGER_TOOL: Tool = { name: "TunnelManager", description: - "Manage temporary Remote HTTP/WebSocket/SSE tunnels through the Gateway. Use list to inspect active tunnels and diagnostics, create to expose a localhost or IPv4/IPv6 http service, probe to recheck HTTP/WebSocket/SSE diagnostics, and close to revoke a tunnel.", + "Manage temporary Remote HTTP/WebSocket/SSE tunnels through the Gateway. Use list to inspect active tunnels and their health, create to expose a localhost or IPv4/IPv6 http service, check to re-run health probes, and close to revoke a tunnel. Mutations also work while the gateway link is offline; they take effect once the link is restored.", parameters: Type.Object({ action: Type.Union( - [Type.Literal("list"), Type.Literal("create"), Type.Literal("probe"), Type.Literal("close")], + [Type.Literal("list"), Type.Literal("create"), Type.Literal("close"), Type.Literal("check")], { description: "Tunnel action to perform.", }, @@ -85,12 +60,8 @@ const TUNNEL_MANAGER_TOOL: Tool = { ), id: Type.Optional( Type.String({ - description: "Tunnel id for action=probe or action=close. Preferred over slug when available.", - }), - ), - slug: Type.Optional( - Type.String({ - description: "Tunnel slug for action=probe or action=close when id is not known.", + description: + "Tunnel id. Required for action=close. Optional for action=check (omit to check all tunnels).", }), ), }), @@ -107,10 +78,10 @@ function asArgs(value: unknown): Record { } function normalizeAction(value: unknown): TunnelManagerAction { - if (value === "list" || value === "create" || value === "probe" || value === "close") { + if (value === "list" || value === "create" || value === "close" || value === "check") { return value; } - throw new Error('TunnelManager.action must be "list", "create", "probe", or "close".'); + throw new Error('TunnelManager.action must be "list", "create", "close", or "check".'); } function normalizeTtlSeconds(value: unknown): TunnelTtlSeconds { @@ -137,36 +108,37 @@ function formatRemaining(expiresAt: number) { return minutes > 0 && minutes < 60 ? `${hours}h ${minutes}m` : `${hours}h`; } -function stripConnectionCount(tunnel: TunnelSummary): TunnelManagerTunnelSummary { - const { activeConnections: _activeConnections, ...summary } = tunnel; - return summary; +function formatHealth(health: TunnelHealth | null | undefined) { + if (!health || health.status === "unknown") return "unknown"; + const parts: string[] = [health.status]; + if (health.httpStatus > 0) parts.push(`HTTP ${health.httpStatus}`); + if (health.status === "ok" && health.rttMs > 0) parts.push(`${health.rttMs}ms`); + if (health.status === "failed" && health.error) parts.push(health.error); + return parts.join(" "); } -function formatTunnelLine(tunnel: TunnelManagerTunnelSummary) { - const name = tunnel.name.trim() || tunnel.targetUrl; +function formatSnapshotHealthLines(snapshot: TunnelStateSnapshot) { const lines = [ - `- ${name}`, - ` id: ${tunnel.id}`, - ` slug: ${tunnel.slug}`, - ` target: ${tunnel.targetUrl}`, - ` public: ${tunnel.publicUrl}`, - ` status: ${tunnel.status}`, - ` ttl: ${formatRemaining(tunnel.expiresAt)}`, + `link: ${snapshot.agentOnline ? "online" : "offline"}`, + `relay: ${formatHealth(snapshot.relay)}`, ]; - if (tunnel.diagnostics?.length) { - lines.push(" diagnostics:"); - for (const diagnostic of tunnel.diagnostics) { - const status = - diagnostic.status === "ok" - ? "ok" - : diagnostic.status === "failed" - ? `failed:${diagnostic.errorCode || "unknown"}` - : "unknown"; - const statusCode = diagnostic.statusCode ? ` status=${diagnostic.statusCode}` : ""; - const message = diagnostic.message ? ` ${diagnostic.message}` : ""; - lines.push(` ${diagnostic.protocol}: ${status}${statusCode}${message}`); - } + if (snapshot.gatewayUnsupported === true) { + lines.push("gateway: connected gateway does not support tunnels (no public URLs)"); + } + return lines; +} + +function formatTunnelLine(tunnel: TunnelStatus, publicBaseUrl: string) { + const name = tunnel.name.trim() || tunnel.targetUrl; + const publicUrl = composePublicUrl(publicBaseUrl, tunnel.publicPath); + const lines = [`- ${name}`, ` id: ${tunnel.id}`, ` target: ${tunnel.targetUrl}`]; + if (publicUrl) { + lines.push(` public: ${publicUrl}`); + } else if (tunnel.publicPath) { + lines.push(` publicPath: ${tunnel.publicPath}`); } + lines.push(` service: ${formatHealth(tunnel.local)}`); + lines.push(` ttl: ${formatRemaining(tunnel.expiresAt)}`); return lines.join("\n"); } @@ -174,8 +146,8 @@ function okResult(params: { toolCall: ToolCall; action: TunnelManagerAction; text: string; - tunnels?: TunnelManagerTunnelSummary[]; - tunnel?: TunnelManagerTunnelSummary; + tunnels?: TunnelStatus[]; + tunnel?: TunnelStatus; }): ToolResultMessage { const details: TunnelManagerDetails = { kind: "tunnel_manager", @@ -214,43 +186,33 @@ function errorResult( }; } -async function listTunnels() { - return invoke("gateway_tunnel_list"); -} - -async function createTunnel(input: TunnelCreateInput) { - return invoke("gateway_tunnel_create", { input }); -} - -async function closeTunnel(id: string) { - return invoke("gateway_tunnel_close", { tunnel_id: id }); -} - -async function probeTunnel(id: string) { - return invoke("gateway_tunnel_probe", { tunnel_id: id }); +async function fetchTunnelState() { + return invoke("gateway_tunnel_state"); } -async function resolveTunnelId(args: Record, action: "probe" | "close") { - const id = normalizeOptionalText(args.id); - const slug = normalizeOptionalText(args.slug); - if (!id && !slug) { - throw new Error(`TunnelManager.id or TunnelManager.slug is required for action=${action}.`); - } - if (id) { - return id; - } - const tunnels = await listTunnels(); - const tunnelId = tunnels.find((tunnel) => tunnel.slug === slug)?.id ?? ""; - if (!tunnelId) { - throw new Error(`No tunnel found for slug "${slug}".`); - } - return tunnelId; +function delay(ms: number, signal?: AbortSignal) { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); + }); } async function executeTunnelManager( toolCall: ToolCall, params: { projectPathKey?: string; + publicBaseUrl?: string; onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; }, signal?: AbortSignal, @@ -267,17 +229,29 @@ async function executeTunnelManager( }; } + const publicBaseUrl = params.publicBaseUrl?.trim() ?? ""; + const projectPathKey = params.projectPathKey?.trim() || undefined; + const notifyChange = (action: TunnelChangeAction) => + params.onTunnelsChanged?.({ action, projectPathKey }); + try { const args = asArgs(toolCall.arguments); const action = normalizeAction(args.action); if (action === "list") { - const tunnels = (await listTunnels()).map(stripConnectionCount); + const snapshot = await fetchTunnelState(); const text = - tunnels.length === 0 - ? "No Remote tunnels are currently registered." - : ["Remote tunnels:", ...tunnels.map(formatTunnelLine)].join("\n"); - return okResult({ toolCall, action, text, tunnels }); + snapshot.tunnels.length === 0 + ? [ + "No Remote tunnels are currently registered.", + ...formatSnapshotHealthLines(snapshot), + ].join("\n") + : [ + "Remote tunnels:", + ...formatSnapshotHealthLines(snapshot), + ...snapshot.tunnels.map((tunnel) => formatTunnelLine(tunnel, publicBaseUrl)), + ].join("\n"); + return okResult({ toolCall, action, text, tunnels: snapshot.tunnels }); } if (action === "create") { @@ -285,49 +259,76 @@ async function executeTunnelManager( if (!targetUrl) { throw new Error("TunnelManager.targetUrl is required for action=create."); } - const tunnel = await createTunnel({ + const input = { targetUrl, name: normalizeOptionalText(args.name) || undefined, ttlSeconds: normalizeTtlSeconds(args.ttlSeconds), - ...(params.projectPathKey?.trim() ? { projectPathKey: params.projectPathKey.trim() } : {}), - }); - const visibleTunnel = stripConnectionCount(tunnel); - await params.onTunnelsChanged?.({ action: "create", tunnel: visibleTunnel }); - return okResult({ - toolCall, - action, - text: ["Created Remote tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), - tunnel: visibleTunnel, - }); + ...(projectPathKey ? { projectPathKey } : {}), + }; + const knownIds = new Set(); + try { + for (const tunnel of (await fetchTunnelState()).tunnels) { + knownIds.add(tunnel.id); + } + } catch { + // Best-effort: the created tunnel is diffed against the pre-create ids. + } + await invoke("gateway_tunnel_create", { input }); + const snapshot = await fetchTunnelState(); + const created = snapshot.tunnels + .filter((tunnel) => !knownIds.has(tunnel.id)) + .sort((a, b) => b.createdAt - a.createdAt); + const tunnel = + created.find((candidate) => candidate.targetUrl === targetUrl) ?? created[0] ?? null; + await notifyChange("create"); + const text = [ + "Created Remote tunnel:", + ...(tunnel ? [formatTunnelLine(tunnel, publicBaseUrl)] : []), + ...formatSnapshotHealthLines(snapshot), + ].join("\n"); + return okResult({ toolCall, action, text, ...(tunnel ? { tunnel } : {}) }); } - if (action === "probe") { - const tunnel = await probeTunnel(await resolveTunnelId(args, "probe")); - const visibleTunnel = stripConnectionCount(tunnel); - await params.onTunnelsChanged?.({ action: "probe", tunnel: visibleTunnel }); - return okResult({ - toolCall, - action, - text: ["Rechecked Remote tunnel diagnostics:", formatTunnelLine(visibleTunnel)].join("\n"), - tunnel: visibleTunnel, - }); + if (action === "close") { + const id = normalizeOptionalText(args.id); + if (!id) { + throw new Error("TunnelManager.id is required for action=close."); + } + await invoke("gateway_tunnel_close", { tunnel_id: id }); + await notifyChange("close"); + return okResult({ toolCall, action, text: `Closed Remote tunnel ${id}.` }); } - const tunnel = await closeTunnel(await resolveTunnelId(args, "close")); - const visibleTunnel = stripConnectionCount(tunnel); - await params.onTunnelsChanged?.({ action: "close", tunnel: visibleTunnel }); + // action === "check" + const id = normalizeOptionalText(args.id) || undefined; + await invoke("gateway_tunnel_check", { tunnel_id: id }); + // Probes run asynchronously; wait briefly before sampling the state. + await delay(CHECK_SETTLE_DELAY_MS, signal); + const snapshot = await fetchTunnelState(); + const checkedTunnels = id + ? snapshot.tunnels.filter((tunnel) => tunnel.id === id) + : snapshot.tunnels; + if (id && checkedTunnels.length === 0) { + throw new Error(`No tunnel found for id "${id}".`); + } + await notifyChange("check"); + const text = [ + "Tunnel health (sampled ~2.5s after triggering checks; probes are async and may still be settling):", + ...formatSnapshotHealthLines(snapshot), + ...checkedTunnels.map((tunnel) => formatTunnelLine(tunnel, publicBaseUrl)), + ].join("\n"); return okResult({ toolCall, action, - text: ["Closed Remote tunnel:", formatTunnelLine(visibleTunnel)].join("\n"), - tunnel: visibleTunnel, + text, + ...(id ? { tunnel: checkedTunnels[0] } : { tunnels: checkedTunnels }), }); } catch (err) { const args = asArgs(toolCall.arguments); const action = args.action === "create" || - args.action === "probe" || args.action === "close" || + args.action === "check" || args.action === "list" ? args.action : undefined; @@ -339,6 +340,7 @@ export function createTunnelManagerTools(params: { enabled: boolean; runtimeScope: "chat" | "cron_auto_prompt"; projectPathKey?: string; + publicBaseUrl?: string; onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; }): BuiltinToolBundle { const tools = params.enabled && params.runtimeScope === "chat" ? [TUNNEL_MANAGER_TOOL] : []; @@ -350,6 +352,7 @@ export function createTunnelManagerTools(params: { toolCall, { projectPathKey: params.projectPathKey, + publicBaseUrl: params.publicBaseUrl, onTunnelsChanged: params.onTunnelsChanged, }, signal, diff --git a/crates/agent-gui/src/lib/tunnels/constants.ts b/crates/agent-gui/src/lib/tunnels/constants.ts new file mode 100644 index 00000000..0299a10f --- /dev/null +++ b/crates/agent-gui/src/lib/tunnels/constants.ts @@ -0,0 +1,109 @@ +// Shared tunnel types and helpers for the local-tunnel panel. This file is +// byte-copied between the webui and the desktop GUI codebases — keep it free +// of any codebase-specific imports. + +export type TunnelTtlSeconds = 0 | 900 | 3600 | 14400; + +export const TUNNEL_TTL_OPTIONS: TunnelTtlSeconds[] = [900, 3600, 14400, 0]; + +export type TunnelHealth = { + status: "ok" | "failed" | "unknown"; + httpStatus: number; + error: string; + checkedAt: number; + rttMs: number; +}; + +export type TunnelStatus = { + id: string; + slug: string; + name: string; + targetUrl: string; + publicPath: string; + createdAt: number; + expiresAt: number; + activeConnections: number; + projectPathKey: string; + local: TunnelHealth | null; +}; + +export type TunnelStateSnapshot = { + revision: number; + agentOnline: boolean; + relay: TunnelHealth | null; + tunnels: TunnelStatus[]; + gatewayUnsupported?: boolean; +}; + +export type TunnelCreateInput = { + targetUrl: string; + name?: string; + ttlSeconds: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export type TunnelUpdateInput = { + id: string; + targetUrl: string; + name?: string; + // Omitted = keep the current expiry; present = re-bucket from now. + ttlSeconds?: TunnelTtlSeconds; + projectPathKey?: string; +}; + +export interface LocalTunnelClient { + // Fires immediately with the last known snapshot when available. + subscribeTunnelState(listener: (snapshot: TunnelStateSnapshot) => void): () => void; + createTunnel(input: TunnelCreateInput): Promise; + updateTunnel(input: TunnelUpdateInput): Promise; + closeTunnel(id: string): Promise; + checkTunnel(id?: string): Promise; +} + +function normalizeTunnelHostname(hostname: string) { + return hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); +} + +function isIpv4Address(hostname: string) { + const parts = hostname.split("."); + if (parts.length !== 4) return false; + return parts.every((part) => { + if (!/^\d+$/.test(part)) return false; + const value = Number(part); + return value >= 0 && value <= 255 && String(value) === part; + }); +} + +function isIpAddress(hostname: string) { + if (isIpv4Address(hostname)) return true; + return hostname.includes(":"); +} + +// Returns an i18n key describing the validation failure, or null when valid. +export function validateLocalHttpTarget(input: string): string | null { + const value = input.trim(); + if (!value) return "projectTools.tunnelTargetRequired"; + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return "projectTools.tunnelInvalidUrl"; + } + const hostname = normalizeTunnelHostname(url.hostname); + if (hostname !== "localhost" && !isIpAddress(hostname)) { + return "projectTools.tunnelLocalhostOnly"; + } + if (url.username || url.password || url.hash) { + return "projectTools.tunnelInvalidUrl"; + } + } catch { + return "projectTools.tunnelInvalidUrl"; + } + return null; +} + +export function composePublicUrl(baseUrl: string, publicPath: string): string { + const base = baseUrl.trim().replace(/\/+$/, ""); + const path = publicPath.trim(); + if (!base || !path) return ""; + return `${base}${path.startsWith("/") ? path : `/${path}`}`; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index c32a754c..070f954a 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -26,12 +26,6 @@ import { type NotifyItem, NotifyToast } from "../components/chat/NotifyToast"; import { SharedHistoryManagerModal } from "../components/chat/SharedHistoryManagerModal"; import { Ban, PanelRightClose, PanelRightOpen, Terminal, Upload } from "../components/icons"; import { MacOsTitleBarSpacer, MacOsTitleBarToggle } from "../components/MacOsTitleBarSpacer"; -import type { - LocalTunnelClient, - TunnelCreateInput, - TunnelSummary, - TunnelUpdateInput, -} from "../components/project-tools/LocalTunnelPanel"; import { RightDockPanel } from "../components/project-tools/RightDockPanel"; import { Button } from "../components/ui/button"; import { useConfirmDialog } from "../components/ui/confirm-dialog"; @@ -168,6 +162,12 @@ import { import { tauriTerminalClient } from "../lib/terminal/tauriTerminalClient"; import type { TerminalSession } from "../lib/terminal/types"; import type { SkillAccessPolicy } from "../lib/tools/skillAccessPolicy"; +import type { + LocalTunnelClient, + TunnelCreateInput, + TunnelStateSnapshot, + TunnelUpdateInput, +} from "../lib/tunnels/constants"; import { applyWorkspaceProjectConversationActivityMap, buildWorkspaceProjectActivityUpdatedAts, @@ -709,7 +709,6 @@ export function ChatPage(props: ChatPageProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [activeView, setActiveView] = useState<"chat" | "skills-hub" | "mcp-hub">("chat"); const [rightDockOpen, setRightDockOpen] = useState(false); - const [tunnelRefreshToken, setTunnelRefreshToken] = useState(0); const previousRightDockFileTreeOpenRef = useRef(false); const [workspaceEditorMounted, setWorkspaceEditorMounted] = useState(false); const [workspaceEditorOpen, setWorkspaceEditorOpen] = useState(false); @@ -732,19 +731,52 @@ export function ChatPage(props: ChatPageProps) { const [remoteRuntimeStatus, setRemoteRuntimeStatus] = useState(() => buildFallbackGatewayStatus(settings.remote), ); - const tauriTunnelClient = useMemo( - () => ({ - listTunnels: () => invoke("gateway_tunnel_list"), - createTunnel: (input: TunnelCreateInput) => - invoke("gateway_tunnel_create", { input }), - updateTunnel: (input: TunnelUpdateInput) => - invoke("gateway_tunnel_update", { input }), - probeTunnel: (id: string) => - invoke("gateway_tunnel_probe", { tunnel_id: id }), - closeTunnel: (id: string) => invoke("gateway_tunnel_close", { tunnel_id: id }), - }), - [], - ); + const tauriTunnelClient = useMemo(() => { + const listeners = new Set<(snapshot: TunnelStateSnapshot) => void>(); + let unlistenPromise: Promise<() => void> | null = null; + const normalizeSnapshot = (payload: unknown): TunnelStateSnapshot => { + const raw = (payload ?? {}) as Partial; + return { + revision: raw.revision ?? 0, + agentOnline: raw.agentOnline === true, + relay: raw.relay ?? null, + tunnels: raw.tunnels ?? [], + gatewayUnsupported: raw.gatewayUnsupported === true, + }; + }; + return { + subscribeTunnelState: (listener) => { + listeners.add(listener); + if (!unlistenPromise) { + unlistenPromise = listen("gateway:tunnel-state", (event) => { + const snapshot = normalizeSnapshot(event.payload); + for (const subscriber of [...listeners]) { + subscriber(snapshot); + } + }); + } + void invoke("gateway_tunnel_state") + .then((payload) => { + if (listeners.has(listener)) { + listener(normalizeSnapshot(payload)); + } + }) + .catch(() => {}); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && unlistenPromise) { + const pending = unlistenPromise; + unlistenPromise = null; + void pending.then((unlisten) => unlisten()).catch(() => {}); + } + }; + }, + createTunnel: (input: TunnelCreateInput) => invoke("gateway_tunnel_create", { input }), + updateTunnel: (input: TunnelUpdateInput) => invoke("gateway_tunnel_update", { input }), + closeTunnel: (id: string) => invoke("gateway_tunnel_close", { tunnel_id: id }), + checkTunnel: (id?: string) => invoke("gateway_tunnel_check", { tunnel_id: id }), + }; + }, []); const { historyItems, @@ -1538,12 +1570,10 @@ export function ChatPage(props: ChatPageProps) { : !terminalProjectPath ? "Select a project to use project tools." : undefined; - const tunnelEnabled = settings.remote.enableWebTunnels === true && remoteRuntimeStatus.online; + const tunnelEnabled = settings.remote.enableWebTunnels === true; const tunnelDisabledMessage = !settings.remote.enableWebTunnels ? t("projectTools.tunnelWebDisabled") - : !remoteRuntimeStatus.online - ? t("projectTools.tunnelRemoteOffline") - : undefined; + : undefined; const hideWorkspaceSshTerminalOverlay = useCallback(() => { setWorkspaceSshTerminalOpen(false); }, []); @@ -4717,7 +4747,7 @@ export function ChatPage(props: ChatPageProps) { enabledMcpServerIds, selectableMcpServers, remoteWebTunnelsEnabled: settings.remote.enableWebTunnels, - remoteGatewayOnline: canShareHistory, + tunnelPublicBaseUrl: settings.remote.gatewayUrl.trim(), sshHosts: settings.ssh.hosts, associatedSshHostIds: effectiveAssociatedSshHostIds, sshManagerRemoteAllowed: @@ -4728,9 +4758,8 @@ export function ChatPage(props: ChatPageProps) { } }, onTunnelsChanged: (change) => { - setTunnelRefreshToken((current) => current + 1); if (change.action === "create") { - ensureTunnelToolTab(change.tunnel.projectPathKey); + ensureTunnelToolTab(change.projectPathKey); } }, sessionId, @@ -5827,7 +5856,7 @@ export function ChatPage(props: ChatPageProps) { tunnelClient={isAgentMode ? tauriTunnelClient : null} tunnelEnabled={tunnelEnabled} tunnelDisabledMessage={tunnelDisabledMessage} - tunnelRefreshToken={tunnelRefreshToken} + tunnelPublicBaseUrl={settings.remote.gatewayUrl.trim()} onWidthChange={(nextWidth) => setSettings((prev) => updateRightDockWidth(prev, nextWidth))} onProjectStateChange={(updater) => setSettings((prev) => updateRightDockProjectState(prev, terminalProjectPathKey, updater)) diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 40c6162e..461a551f 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -64,10 +64,7 @@ import type { BuiltinToolExecutionContext } from "../../../lib/tools/builtinType import { createFileToolState } from "../../../lib/tools/fileToolState"; import type { SkillAccessPolicy } from "../../../lib/tools/skillAccessPolicy"; import type { SshManagerSessionChange } from "../../../lib/tools/sshManagerTools"; -import { - TUNNEL_MANAGER_CHANGED_EVENT, - type TunnelManagerChange, -} from "../../../lib/tools/tunnelManagerTools"; +import type { TunnelManagerChange } from "../../../lib/tools/tunnelManagerTools"; import { recordSilentMemoryTurnBoundary, type runSilentMemoryExtraction, @@ -341,7 +338,7 @@ export type RunAgentConversationTurnParams = { enabledMcpServerIds: string[]; selectableMcpServers: AppSettings["mcp"]["servers"]; remoteWebTunnelsEnabled?: boolean; - remoteGatewayOnline?: boolean; + tunnelPublicBaseUrl?: string; onTunnelsChanged?: (change: TunnelManagerChange) => void; sshHosts?: SshHostConfig[]; associatedSshHostIds?: string[]; @@ -429,7 +426,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP enabledMcpServerIds, selectableMcpServers, remoteWebTunnelsEnabled, - remoteGatewayOnline, + tunnelPublicBaseUrl, onTunnelsChanged, sshHosts, associatedSshHostIds, @@ -555,18 +552,13 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP enabledMcpServerIds, selectableMcpServers, remoteWebTunnelsEnabled, - remoteGatewayOnline, tunnelProjectPathKey: workspaceProjectPathKey(effectiveWorkdir), + tunnelPublicBaseUrl, sshHosts, associatedSshHostIds, sshManagerRemoteAllowed, onSshSessionsChanged, - onTunnelsChanged: (change) => { - onTunnelsChanged?.(change); - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent(TUNNEL_MANAGER_CHANGED_EVENT)); - } - }, + onTunnelsChanged, onMcpLoadError: (message) => { const warning = `MCP 工具加载失败,已跳过并继续对话:${message || "未知错误"}`; console.warn(warning); diff --git a/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs index a4d47de0..733d5479 100644 --- a/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs +++ b/crates/agent-gui/test/tools/tunnel-manager-tools.test.mjs @@ -8,11 +8,22 @@ function createTunnel(overrides = {}) { slug: "abc123", name: "Local app", targetUrl: "http://localhost:3000", - publicUrl: "https://gateway.example.test/t/abc123", + publicPath: "/t/abc123/", createdAt: 1_700_000_000, expiresAt: Math.floor(Date.now() / 1000) + 3600, activeConnections: 0, - status: "active", + projectPathKey: "project:/workspace", + local: { status: "ok", httpStatus: 200, error: "", checkedAt: 1_700_000_100, rttMs: 12 }, + ...overrides, + }; +} + +function createSnapshot(tunnels, overrides = {}) { + return { + revision: 1, + agentOnline: true, + relay: { status: "ok", httpStatus: 0, error: "", checkedAt: 1_700_000_100, rttMs: 23 }, + tunnels, ...overrides, }; } @@ -45,22 +56,16 @@ async function buildRegistry(params = {}) { }); } -test("TunnelManager is injected only when Remote Web Tunnels are enabled and gateway is online", async () => { +test("TunnelManager is injected only when Remote Web Tunnels are enabled", async () => { const disabledRegistry = await buildRegistry({ remoteWebTunnelsEnabled: false, - remoteGatewayOnline: true, }); assert.equal(disabledRegistry.hasTool("TunnelManager"), false); - const offlineRegistry = await buildRegistry({ - remoteWebTunnelsEnabled: true, - remoteGatewayOnline: false, - }); - assert.equal(offlineRegistry.hasTool("TunnelManager"), false); - + // Gateway link being offline no longer gates the tool: offline mutations queue + // on the agent and are reconciled when the link is restored. const enabledRegistry = await buildRegistry({ remoteWebTunnelsEnabled: true, - remoteGatewayOnline: true, }); assert.equal(enabledRegistry.hasTool("TunnelManager"), true); assert.equal( @@ -71,58 +76,40 @@ test("TunnelManager is injected only when Remote Web Tunnels are enabled and gat const cronRegistry = await buildRegistry({ runtimeScope: "cron_auto_prompt", remoteWebTunnelsEnabled: true, - remoteGatewayOnline: true, }); assert.equal(cronRegistry.hasTool("TunnelManager"), false); }); -test("TunnelManager list/create/probe/close call gateway tunnel commands", async () => { +test("TunnelManager list/create/close/check call gateway tunnel commands", async () => { const invocations = []; + const tunnels = [createTunnel()]; const loader = createTsModuleLoader({ mocks: { "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); - if (command === "gateway_tunnel_list") { - return [createTunnel()]; + if (command === "gateway_tunnel_state") { + return createSnapshot([...tunnels]); } if (command === "gateway_tunnel_create") { - return createTunnel({ - id: "tun-created", - slug: "created", - targetUrl: args.input.targetUrl, - name: args.input.name ?? "", - ...(args.input.ttlSeconds === 0 ? { expiresAt: 0 } : {}), - }); + tunnels.push( + createTunnel({ + id: "tun-created", + slug: "created", + publicPath: "/t/created/", + targetUrl: args.input.targetUrl, + name: args.input.name ?? "", + createdAt: 1_700_000_500, + ...(args.input.ttlSeconds === 0 ? { expiresAt: 0 } : {}), + }), + ); + return undefined; } if (command === "gateway_tunnel_close") { - return createTunnel({ - id: args.tunnel_id, - status: "expired", - }); + return undefined; } - if (command === "gateway_tunnel_probe") { - return createTunnel({ - id: args.tunnel_id, - diagnostics: [ - { - protocol: "websocket", - status: "ok", - statusCode: 101, - errorCode: "", - message: "WebSocket probe succeeded", - checkedAt: 1_700_000_100, - }, - { - protocol: "sse", - status: "unknown", - statusCode: 0, - errorCode: "", - message: "", - checkedAt: 1_700_000_100, - }, - ], - }); + if (command === "gateway_tunnel_check") { + return undefined; } throw new Error(`unexpected invoke ${command}`); }, @@ -135,6 +122,7 @@ test("TunnelManager list/create/probe/close call gateway tunnel commands", async enabled: true, runtimeScope: "chat", projectPathKey: "project:/workspace", + publicBaseUrl: "https://gateway.example.test/", onTunnelsChanged: (change) => changes.push(change), }); @@ -143,9 +131,15 @@ test("TunnelManager list/create/probe/close call gateway tunnel commands", async const listResult = await bundle.executeToolCall(createToolCall({ action: "list" })); assert.equal(listResult.isError, false); assert.equal(listResult.details.kind, "tunnel_manager"); + assert.equal(listResult.details.action, "list"); assert.equal(listResult.details.tunnels.length, 1); - assert.equal(listResult.details.tunnels[0].activeConnections, undefined); - assert.doesNotMatch(listResult.content[0].text, /activeConnections|connections/i); + assert.match(listResult.content[0].text, /link: online/); + assert.match(listResult.content[0].text, /relay: ok/); + assert.match(listResult.content[0].text, /service: ok HTTP 200/); + assert.match( + listResult.content[0].text, + /public: https:\/\/gateway\.example\.test\/t\/abc123\//, + ); const createResult = await bundle.executeToolCall( createToolCall({ @@ -156,30 +150,34 @@ test("TunnelManager list/create/probe/close call gateway tunnel commands", async }), ); assert.equal(createResult.isError, false); + assert.equal(createResult.details.action, "create"); assert.equal(createResult.details.tunnel.id, "tun-created"); - assert.equal(createResult.details.tunnel.activeConnections, undefined); - assert.doesNotMatch(createResult.content[0].text, /activeConnections|connections/i); assert.match(createResult.content[0].text, /unlimited/); + assert.match( + createResult.content[0].text, + /public: https:\/\/gateway\.example\.test\/t\/created\//, + ); - const probeResult = await bundle.executeToolCall( - createToolCall({ action: "probe", id: "tun-1" }), + const closeResult = await bundle.executeToolCall( + createToolCall({ action: "close", id: "tun-1" }), ); - assert.equal(probeResult.isError, false); - assert.equal(probeResult.details.tunnel.activeConnections, undefined); - assert.match(probeResult.content[0].text, /diagnostics:/); - assert.match(probeResult.content[0].text, /websocket: ok status=101/); - assert.match(probeResult.content[0].text, /sse: unknown/); - - const closeBySlugResult = await bundle.executeToolCall( - createToolCall({ action: "close", slug: "abc123" }), + assert.equal(closeResult.isError, false); + assert.equal(closeResult.details.action, "close"); + assert.match(closeResult.content[0].text, /tun-1/); + + const checkResult = await bundle.executeToolCall( + createToolCall({ action: "check", id: "tun-created" }), ); - assert.equal(closeBySlugResult.isError, false); - assert.equal(closeBySlugResult.details.tunnel.activeConnections, undefined); + assert.equal(checkResult.isError, false); + assert.equal(checkResult.details.action, "check"); + assert.equal(checkResult.details.tunnel.id, "tun-created"); + assert.match(checkResult.content[0].text, /link: online/); assert.deepEqual( invocations.map((call) => [call.command, call.args]), [ - ["gateway_tunnel_list", undefined], + ["gateway_tunnel_state", undefined], + ["gateway_tunnel_state", undefined], [ "gateway_tunnel_create", { @@ -191,17 +189,18 @@ test("TunnelManager list/create/probe/close call gateway tunnel commands", async }, }, ], - ["gateway_tunnel_probe", { tunnel_id: "tun-1" }], - ["gateway_tunnel_list", undefined], + ["gateway_tunnel_state", undefined], ["gateway_tunnel_close", { tunnel_id: "tun-1" }], + ["gateway_tunnel_check", { tunnel_id: "tun-created" }], + ["gateway_tunnel_state", undefined], ], ); assert.deepEqual( - changes.map((change) => [change.action, change.tunnel.id, change.tunnel.activeConnections]), + changes.map((change) => [change.action, change.projectPathKey]), [ - ["create", "tun-created", undefined], - ["probe", "tun-1", undefined], - ["close", "tun-1", undefined], + ["create", "project:/workspace"], + ["close", "project:/workspace"], + ["check", "project:/workspace"], ], ); }); @@ -221,7 +220,7 @@ test("TunnelManager rejects invalid arguments before invoking gateway commands", const { createTunnelManagerTools } = loader.loadModule("src/lib/tools/tunnelManagerTools.ts"); const bundle = createTunnelManagerTools({ enabled: true, runtimeScope: "chat" }); - const invalidAction = await bundle.executeToolCall(createToolCall({ action: "delete" })); + const invalidAction = await bundle.executeToolCall(createToolCall({ action: "probe" })); assert.equal(invalidAction.isError, true); assert.match(invalidAction.content[0].text, /action/); @@ -237,11 +236,7 @@ test("TunnelManager rejects invalid arguments before invoking gateway commands", const missingCloseTarget = await bundle.executeToolCall(createToolCall({ action: "close" })); assert.equal(missingCloseTarget.isError, true); - assert.match(missingCloseTarget.content[0].text, /id or TunnelManager.slug/); - - const missingProbeTarget = await bundle.executeToolCall(createToolCall({ action: "probe" })); - assert.equal(missingProbeTarget.isError, true); - assert.match(missingProbeTarget.content[0].text, /id or TunnelManager.slug/); + assert.match(missingCloseTarget.content[0].text, /TunnelManager\.id is required/); assert.deepEqual(invocations, []); }); From 1a1b3b77aec319c3b2af5775f20699bfb29cdd96 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 13:24:29 +0800 Subject: [PATCH 27/64] fix(project-tools): show project names on tunnel scope badges --- .../project-tools/LocalTunnelPanel.tsx | 25 +++++++++++++------ .../project-tools/LocalTunnelPanel.tsx | 25 +++++++++++++------ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx index 0be302c3..26001176 100644 --- a/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/LocalTunnelPanel.tsx @@ -211,6 +211,11 @@ function normalizeProjectPathKey(value: string | undefined) { return workspaceProjectPathKey(value ?? ""); } +function projectNameFromPathKey(pathKey: string) { + const segments = pathKey.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + export function LocalTunnelPanel({ client, enabled, @@ -1040,13 +1045,19 @@ export function LocalTunnelPanel({ {scope === "global" ? ( - - {t( - tunnelProjectPathKey - ? "projectTools.tunnelScopeProjectBadge" - : "projectTools.tunnelScopeGlobalBadge", - )} - + tunnelProjectPathKey ? ( + + {projectNameFromPathKey(tunnelProjectPathKey) || + t("projectTools.tunnelScopeProjectBadge")} + + ) : ( + + {t("projectTools.tunnelScopeGlobalBadge")} + + ) ) : null}
diff --git a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx index 0be302c3..26001176 100644 --- a/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/LocalTunnelPanel.tsx @@ -211,6 +211,11 @@ function normalizeProjectPathKey(value: string | undefined) { return workspaceProjectPathKey(value ?? ""); } +function projectNameFromPathKey(pathKey: string) { + const segments = pathKey.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? ""; +} + export function LocalTunnelPanel({ client, enabled, @@ -1040,13 +1045,19 @@ export function LocalTunnelPanel({ {scope === "global" ? ( - - {t( - tunnelProjectPathKey - ? "projectTools.tunnelScopeProjectBadge" - : "projectTools.tunnelScopeGlobalBadge", - )} - + tunnelProjectPathKey ? ( + + {projectNameFromPathKey(tunnelProjectPathKey) || + t("projectTools.tunnelScopeProjectBadge")} + + ) : ( + + {t("projectTools.tunnelScopeGlobalBadge")} + + ) ) : null}
From dedd020c02d2d2b837228c7092bd2ecd3b7b1074 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 15:21:26 +0800 Subject: [PATCH 28/64] fix(tools): stabilize filesystem path recovery --- .../src/components/chat/MentionComposer.tsx | 4 +- .../project-tools/ProjectFileTreePanel.tsx | 14 +- .../WorkspaceCodeEditorOverlay.tsx | 9 +- .../WorkspaceFilePreviewOverlay.tsx | 4 +- .../web/src/lib/chat/uiMessages.ts | 64 +- crates/agent-gateway/web/src/lib/ssh/scan.ts | 8 +- .../web/src/lib/tools/builtinTypes.ts | 85 +- .../web/src/lib/tools/fsBackend.ts | 84 + .../web/src/pages/chat/AssistantBubble.tsx | 47 +- .../web/src/pages/chat/ToolSurfaces.tsx | 4 - .../src-tauri/src/commands/workspace/fs.rs | 1557 ++++++++++++----- .../src-tauri/src/runtime/shell_runner.rs | 91 + .../src-tauri/src/services/gateway_bridge.rs | 7 + .../src/components/chat/MentionComposer.tsx | 4 +- .../project-tools/ProjectFileTreePanel.tsx | 18 +- .../WorkspaceCodeEditorOverlay.tsx | 9 +- .../WorkspaceFilePreviewOverlay.tsx | 6 +- .../WorkspaceImagePreviewOverlay.tsx | 4 +- .../chat/context/requestContextSanitizer.ts | 2 - .../src/lib/chat/runner/agentRunner.ts | 28 +- crates/agent-gui/src/lib/skills/index.ts | 4 +- crates/agent-gui/src/lib/ssh/scan.ts | 8 +- .../src/lib/tools/builtinRegistry.ts | 8 + .../agent-gui/src/lib/tools/builtinTypes.ts | 31 +- .../lib/tools/delegate/createDelegateTools.ts | 10 +- .../agent-gui/src/lib/tools/delegate/input.ts | 22 - .../src/lib/tools/delegate/schema.ts | 2 +- .../src/lib/tools/delegate/worktree.ts | 32 +- .../agent-gui/src/lib/tools/fileToolState.ts | 18 +- crates/agent-gui/src/lib/tools/fsBackend.ts | 84 + crates/agent-gui/src/lib/tools/fsTools.ts | 456 +++-- .../src/lib/tools/mcpManagerTools.ts | 13 +- crates/agent-gui/src/lib/tools/pathErrors.ts | 80 + crates/agent-gui/src/lib/tools/pathUtils.ts | 185 +- crates/agent-gui/src/lib/tools/shellTools.ts | 14 +- crates/agent-gui/src/lib/tools/skillTools.ts | 53 +- .../src/lib/tools/sshManagerTools.ts | 16 +- crates/agent-gui/src/pages/ChatPage.tsx | 3 +- .../assistant-bubble/ToolResultDisplay.tsx | 2 - .../test/chat/markdown-image-policy.test.mjs | 9 +- .../test/tools/delegate-tools.test.mjs | 14 - .../test/tools/path-and-system-tools.test.mjs | 525 +++++- .../agent-gui/test/tools/shell-tools.test.mjs | 4 +- 43 files changed, 2602 insertions(+), 1040 deletions(-) create mode 100644 crates/agent-gateway/web/src/lib/tools/fsBackend.ts create mode 100644 crates/agent-gui/src/lib/tools/fsBackend.ts create mode 100644 crates/agent-gui/src/lib/tools/pathErrors.ts diff --git a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx index 072df604..1d602e64 100644 --- a/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gateway/web/src/components/chat/MentionComposer.tsx @@ -16,7 +16,6 @@ import { type RefObject, } from "react"; import { createPortal } from "react-dom"; -import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useLocale } from "../../i18n"; import { @@ -29,6 +28,7 @@ import { } from "../../lib/chat/mentionReferences"; import { extractClipboardFiles } from "../../lib/clipboardFiles"; import { cn } from "../../lib/shared/utils"; +import { invokeFs } from "../../lib/tools/fsBackend"; /* ------------------------------------------------------------------ */ /* Types */ @@ -1356,7 +1356,7 @@ export const MentionComposer = memo(forwardRef("fs_mention_list", { + invokeFs("fs_mention_list", { workdir: normalizedWorkdir, max_results: MENTION_INDEX_MAX_RESULTS, query: ctx.query, diff --git a/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx b/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx index c46aad13..f9efc8f1 100644 --- a/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/ProjectFileTreePanel.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocale } from "@/i18n"; import type { @@ -6,6 +5,7 @@ import type { RightDockFileTreeStatePatch, } from "@/lib/settings"; import { cn } from "@/lib/shared/utils"; +import { invokeFs } from "@/lib/tools/fsBackend"; import { getFileTypeIcon } from "../chat/fileTypeIcons"; import { isWorkspaceImagePath, @@ -258,7 +258,7 @@ export function ProjectFileTreePanel(props: { if (!shouldLoad) return; try { - const response = await invoke("fs_list", { + const response = await invokeFs("fs_list", { workdir: cwd, path: path || undefined, depth: 1, @@ -426,7 +426,7 @@ export function ProjectFileTreePanel(props: { const timer = window.setTimeout(() => { setSearchLoading(true); setSearchError(null); - void invoke("fs_mention_list", { + void invokeFs("fs_mention_list", { workdir: cwd, query, max_results: SEARCH_MAX_RESULTS, @@ -560,7 +560,7 @@ export function ProjectFileTreePanel(props: { targetNode?.kind === "dir" ? targetNode.path : dirname(targetNode?.path ?? targetPath); if (pendingAction === "file") { const nextPath = joinPath(targetDir, name); - await invoke("fs_write_text", { + await invokeFs("fs_write_text", { workdir: cwd, path: nextPath, content: "", @@ -576,7 +576,7 @@ export function ProjectFileTreePanel(props: { }); } else if (pendingAction === "folder") { const nextPath = joinPath(targetDir, name); - await invoke("fs_create_dir", { + await invokeFs("fs_create_dir", { workdir: cwd, path: nextPath, }); @@ -597,7 +597,7 @@ export function ProjectFileTreePanel(props: { } else if (pendingAction === "rename" && targetPath) { const parent = dirname(targetPath); const nextPath = joinPath(parent, name); - await invoke("fs_rename", { + await invokeFs("fs_rename", { workdir: cwd, from_path: targetPath, to_path: nextPath, @@ -675,7 +675,7 @@ export function ProjectFileTreePanel(props: { setBusyAction(true); setActionError(null); try { - await invoke("fs_delete", { workdir: cwd, path: targetPath }); + await invokeFs("fs_delete", { workdir: cwd, path: targetPath }); const nextExpanded = state.expanded.filter( (item) => item !== targetPath && !item.startsWith(`${targetPath}/`), ); diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index 90631f31..affbb771 100644 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, @@ -16,6 +15,7 @@ import JsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; import TsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"; import { useLocale } from "@/i18n"; import { cn } from "@/lib/shared/utils"; +import { invokeFs, isFsBackendError } from "@/lib/tools/fsBackend"; import { AlertTriangle, ClipboardPaste, @@ -230,6 +230,7 @@ function languageForPath(path: string) { } function isVersionConflict(error: unknown) { + if (isFsBackendError(error) && error.code === "stale_file") return true; const message = error instanceof Error ? error.message : String(error ?? ""); return message.includes("File changed since the last full Read"); } @@ -390,7 +391,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp const contentToSave = tab.content; updateTab(tabKey, (current) => ({ ...current, status: "saving", error: null })); try { - const response = await invoke("fs_write_text", { + const response = await invokeFs("fs_write_text", { workdir: tab.workdir, path: tab.path, content: contentToSave, @@ -443,7 +444,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp ]); setGlobalError(null); try { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: request.workdir, path: request.path, }); @@ -483,7 +484,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp setOpeningPaths((current) => [...current.filter((item) => item !== tab.path), tab.path]); setGlobalError(null); try { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: tab.workdir, path: tab.path, }); diff --git a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index f05806d9..377886b2 100644 --- a/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-gateway/web/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -1,9 +1,9 @@ -import { invoke } from "@tauri-apps/api/core"; import { renderAsync } from "docx-preview"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { read, utils } from "xlsx"; import { useLocale } from "@/i18n"; import { cn } from "@/lib/shared/utils"; +import { invokeFs } from "@/lib/tools/fsBackend"; import { getFileTypeIcon, type FileTypeIconComponent } from "../chat/fileTypeIcons"; import { AlertTriangle, @@ -350,7 +350,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr replacePreview(null); } try { - const response = await invoke("fs_read_workspace_image", { + const response = await invokeFs("fs_read_workspace_image", { workdir: request.workdir, path: request.path, }); diff --git a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts index be20ad68..30e7605c 100644 --- a/crates/agent-gateway/web/src/lib/chat/uiMessages.ts +++ b/crates/agent-gateway/web/src/lib/chat/uiMessages.ts @@ -178,9 +178,8 @@ export function summarizeToolCall( const args = toolCall.arguments || {}; const name = toolCall.name; const path = summarizeToolArg(args.path); - const root = typeof args.root === "string" && args.root.trim() - ? `root=${summarizeToolArg(args.root)}` - : null; + const displayScope = displayFileToolScope(args); + const scope = displayScope ? `scope=${summarizeToolArg(displayScope)}` : null; const imagePaths = Array.isArray(args.paths) ? args.paths .map((value) => summarizeToolArg(value)) @@ -199,12 +198,13 @@ export function summarizeToolCall( const imageBase64s = Array.isArray(args.base64s) ? args.base64s.filter((value) => typeof value === "string" && value.trim()).length : 0; - const rootPath = "path="; + const defaultPath = "path=."; + const defaultCwd = "cwd=."; const parts = name === "Image" ? [ - root, + scope, imageSources.length > 0 ? `sources=${imageSources.length}${imageSources[0] ? ` first=${imageSources[0]}` : ""}` : imagePaths.length > 0 @@ -225,7 +225,7 @@ export function summarizeToolCall( ] : name === "Read" ? [ - root, + scope, path ? `path=${path}` : null, typeof args.start_line === "number" ? `start=${args.start_line}` : null, typeof args.limit === "number" ? `limit=${args.limit}` : null, @@ -328,10 +328,10 @@ export function summarizeToolCall( typeof args.message === "string" ? `messageChars=${args.message.length}` : null, ] : name === "Write" - ? [root, path ? `path=${path}` : null, "mode=rewrite"] + ? [scope, path ? `path=${path}` : null, "mode=rewrite"] : name === "Edit" ? [ - root, + scope, path ? `path=${path}` : null, typeof args.expected_replacements === "number" ? `expected=${args.expected_replacements}` @@ -340,29 +340,29 @@ export function summarizeToolCall( ] : name === "List" ? [ - root, - path ? `path=${path}` : rootPath, + scope, + path ? `path=${path}` : defaultPath, typeof args.depth === "number" ? `depth=${args.depth}` : null, typeof args.offset === "number" ? `offset=${args.offset}` : null, typeof args.max_results === "number" ? `max=${args.max_results}` : null, ] : name === "Glob" ? [ - root, + scope, typeof args.pattern === "string" ? `pattern=${summarizeToolArg(args.pattern)}` : null, - path ? `path=${path}` : rootPath, + path ? `path=${path}` : defaultPath, typeof args.offset === "number" ? `offset=${args.offset}` : null, typeof args.max_results === "number" ? `max=${args.max_results}` : null, ] : name === "Grep" ? [ - root, + scope, typeof args.pattern === "string" ? `pattern=${summarizeToolArg(args.pattern)}` : null, - path ? `path=${path}` : rootPath, + path ? `path=${path}` : defaultPath, typeof args.file_pattern === "string" ? `filePattern=${summarizeToolArg(args.file_pattern)}` : null, @@ -378,13 +378,13 @@ export function summarizeToolCall( typeof args.offset === "number" ? `offset=${args.offset}` : null, ] : name === "Delete" - ? [root, path ? `path=${path}` : null] + ? [scope, path ? `path=${path}` : null] : name === "Bash" ? [ - root, + scope, typeof args.cwd === "string" ? `cwd=${summarizeToolArg(args.cwd)}` - : rootPath, + : defaultCwd, summarizeBashTimeout(args.timeout_ms), typeof args.command === "string" ? `command=${summarizeToolArg(args.command)}` @@ -427,15 +427,27 @@ function summarizeImageArgValue(key: string, value: unknown) { return value; } -function displayFileToolRoot(root: unknown) { - return typeof root === "string" && root.trim() && root.trim() !== "workspace" - ? root.trim() - : undefined; +// Render-layer tolerance for historical messages: current payloads carry +// `scope` ("workspace" | "skill" | "external"), while old sessions may still +// carry the legacy `root` ("workspace" | "skills") or unknown scope values +// such as "temp"/"artifact". Degrade at the read site — unknown values are +// displayed verbatim; "workspace" (the default) is hidden. +function displayFileToolScope(source: unknown) { + const record = + source && typeof source === "object" && !Array.isArray(source) + ? (source as Record) + : {}; + const scope = + typeof record.scope === "string" && record.scope.trim() ? record.scope.trim() : undefined; + const legacyRoot = + typeof record.root === "string" && record.root.trim() ? record.root.trim() : undefined; + const resolved = scope ?? (legacyRoot === "skills" ? "skill" : legacyRoot); + return resolved && resolved !== "workspace" ? resolved : undefined; } -function displayFileToolRootEntry(root: unknown) { - const displayRoot = displayFileToolRoot(root); - return displayRoot ? { root: displayRoot } : {}; +function displayFileToolScopeEntry(source: unknown) { + const displayScope = displayFileToolScope(source); + return displayScope ? { scope: displayScope } : {}; } export function toolCallArgsForDisplay(toolCall: ToolCall) { @@ -445,14 +457,14 @@ export function toolCallArgsForDisplay(toolCall: ToolCall) { switch (name) { case "Write": return { - ...displayFileToolRootEntry(args.root), + ...displayFileToolScopeEntry(args), path: args.path, mode: "rewrite", contentChars: fileToolFieldChars(args, "content"), }; case "Edit": return { - ...displayFileToolRootEntry(args.root), + ...displayFileToolScopeEntry(args), path: args.path, expected_replacements: args.expected_replacements, replace_all: args.replace_all, diff --git a/crates/agent-gateway/web/src/lib/ssh/scan.ts b/crates/agent-gateway/web/src/lib/ssh/scan.ts index 34f455b6..4504f2ec 100644 --- a/crates/agent-gateway/web/src/lib/ssh/scan.ts +++ b/crates/agent-gateway/web/src/lib/ssh/scan.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import type { SshHostConfig } from "../settings"; +import { invokeFs } from "../tools/fsBackend"; type FsRoot = { id: string; @@ -217,7 +217,7 @@ function toHomeRelativePath(homePath: string, path: string) { } async function readHomeFile(homePath: string, relativePath: string) { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: homePath, path: relativePath, }); @@ -233,7 +233,7 @@ async function readOptionalHomeFile(homePath: string, relativePath: string) { } async function findHomePath() { - const response = await invoke("fs_roots"); + const response = await invokeFs("fs_roots", {}); const home = response.roots.find((root) => root.kind === "home") ?? response.roots.find((root) => root.id === "home"); @@ -242,7 +242,7 @@ async function findHomePath() { async function listSshDirectory(homePath: string) { try { - const response = await invoke("fs_list", { + const response = await invokeFs("fs_list", { workdir: homePath, path: SSH_DIR_PATH, depth: 1, diff --git a/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts b/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts index 6b9cb956..66898b9b 100644 --- a/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gateway/web/src/lib/tools/builtinTypes.ts @@ -38,12 +38,24 @@ export function createBuiltinMetadataMap( } export type FsEntryKind = "file" | "dir"; -export type FileToolRoot = "workspace" | "skills"; +export type PathScope = "workspace" | "skill" | "external"; + +export type ResolvedPathResultDetails = { + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; +}; export type ReadTextResultDetails = { kind: "read_text"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; startLine: number; numLines: number; totalLines: number; @@ -56,8 +68,12 @@ export type ReadTextResultDetails = { export type ReadImageResultDetails = { kind: "read_image"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; mimeType: string; sizeBytes: number; mtimeMs: number; @@ -67,6 +83,11 @@ export type ReadImageResultDetails = { export type DisplayImageItemDetails = { path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; sourceType?: "path" | "url" | "base64" | "auto"; renderMode?: "inline" | "proxy"; sourceUrl?: string; @@ -89,8 +110,12 @@ export type DisplayImageResultDetails = { export type ReadPdfResultDetails = { kind: "read_pdf"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; pageStart: number; numPages: number; totalPages: number; @@ -102,8 +127,12 @@ export type ReadPdfResultDetails = { export type ReadNotebookResultDetails = { kind: "read_notebook"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; cellStart: number; numCells: number; totalCells: number; @@ -115,8 +144,12 @@ export type ReadNotebookResultDetails = { export type ReadDocumentResultDetails = { kind: "read_word" | "read_spreadsheet" | "read_archive"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; truncated: boolean; mimeType?: string; sizeBytes?: number; @@ -142,6 +175,7 @@ export type SkillsManagerActionResultDetails = { invalidCount?: number; installedCount?: number; createdName?: string; + deletedName?: string; validationOk?: boolean; packageArchive?: string; seededCount?: number; @@ -258,8 +292,12 @@ export type SubagentMessageResultDetails = { export type WriteResultDetails = { kind: "write"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; mode: "rewrite"; existedBefore: boolean; bytesWritten: number; @@ -271,8 +309,12 @@ export type WriteResultDetails = { export type EditResultDetails = { kind: "edit"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; replacements: number; replaceAll: boolean; expectedReplacements?: number; @@ -285,8 +327,12 @@ export type EditResultDetails = { export type DeleteResultDetails = { kind: "delete"; - root?: FileToolRoot; path: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; targetKind: string; }; @@ -297,8 +343,13 @@ export type ListResultEntry = { export type ListResultDetails = { kind: "list"; - root?: FileToolRoot; path?: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; + targetKind?: FsEntryKind; depth: number; offset: number; maxResults: number; @@ -309,8 +360,13 @@ export type ListResultDetails = { export type GlobResultDetails = { kind: "glob"; - root?: FileToolRoot; path?: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; + targetKind?: FsEntryKind; pattern: string; sortBy: "path"; offset: number; @@ -336,8 +392,13 @@ export type GrepResultFileSummary = { export type GrepResultDetails = { kind: "grep"; - root?: FileToolRoot; path?: string; + scope?: PathScope; + absolutePath?: string; + relativePath?: string; + displayPath?: string; + fileId?: string; + targetKind?: FsEntryKind; pattern: string; filePattern?: string; ignoreCase: boolean; diff --git a/crates/agent-gateway/web/src/lib/tools/fsBackend.ts b/crates/agent-gateway/web/src/lib/tools/fsBackend.ts new file mode 100644 index 00000000..a932fc3a --- /dev/null +++ b/crates/agent-gateway/web/src/lib/tools/fsBackend.ts @@ -0,0 +1,84 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type FsErrorCode = + | "invalid_workdir" + | "invalid_path" + | "out_of_bounds" + | "not_found" + | "not_a_directory" + | "not_a_file" + | "unsupported_target" + | "requires_full_read" + | "stale_file" + | "edit_no_match" + | "edit_ambiguous" + | "edit_count_mismatch" + | "too_large" + | "not_utf8" + | "regex_invalid" + | "glob_invalid" + | "io" + | "other"; + +export class FsBackendError extends Error { + readonly code: FsErrorCode; + readonly path?: string; + readonly workdir?: string; + readonly entryKind?: string; + readonly didYouMean: string[]; + + constructor(params: { + code: FsErrorCode; + message: string; + path?: string; + workdir?: string; + entryKind?: string; + didYouMean?: string[]; + }) { + super(params.message); + this.name = "FsBackendError"; + this.code = params.code; + this.path = params.path; + this.workdir = params.workdir; + this.entryKind = params.entryKind; + this.didYouMean = params.didYouMean ?? []; + } +} + +export function isFsBackendError(error: unknown): error is FsBackendError { + return error instanceof FsBackendError; +} + +function toFsBackendError(error: unknown): FsBackendError { + if (error instanceof FsBackendError) return error; + if (error && typeof error === "object" && !Array.isArray(error)) { + const record = error as Record; + if (typeof record.code === "string" && typeof record.message === "string") { + return new FsBackendError({ + code: record.code as FsErrorCode, + message: record.message, + path: typeof record.path === "string" ? record.path : undefined, + workdir: typeof record.workdir === "string" ? record.workdir : undefined, + entryKind: typeof record.entryKind === "string" ? record.entryKind : undefined, + didYouMean: Array.isArray(record.didYouMean) + ? record.didYouMean.filter((value): value is string => typeof value === "string") + : undefined, + }); + } + } + const message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : String(error ?? "Unknown filesystem error"); + return new FsBackendError({ code: "other", message }); +} + +export async function invokeFs(command: string, args: Record): Promise { + try { + return await invoke(command, args); + } catch (error) { + throw toFsBackendError(error); + } +} diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index c5d32b96..43de7f1c 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -43,7 +43,6 @@ import { FileChangeBadge } from "../../components/chat/FileChangeBadge"; import { EditDiffView } from "./EditDiffView"; import { FileToolArgsDisplay } from "./FileToolArgs"; import { - fileRootTags, MetaTags, PathDisplay, ToolFactGrid, @@ -1806,6 +1805,27 @@ function CodePreview(props: { text: string; maxChars?: number }) { ); } +// Render-layer tolerance for historical messages: current details carry +// `scope` ("workspace" | "skill" | "external"), while old sessions may still +// carry the legacy `root` ("workspace" | "skills") or unknown scope values +// such as "temp"/"artifact". Degrade at the read site — unknown values are +// displayed verbatim; "workspace" (the default) is hidden. +function resolveFileToolScope(details: unknown): string | undefined { + if (!details || typeof details !== "object") return undefined; + const record = details as Record; + const scope = + typeof record.scope === "string" && record.scope.trim() ? record.scope.trim() : undefined; + const legacyRoot = + typeof record.root === "string" && record.root.trim() ? record.root.trim() : undefined; + const resolved = scope ?? (legacyRoot === "skills" ? "skill" : legacyRoot); + return resolved && resolved !== "workspace" ? resolved : undefined; +} + +function fileScopeTags(details: unknown): MetaTag[] { + const scope = resolveFileToolScope(details); + return scope ? [{ label: "scope", value: scope }] : []; +} + function ToolResultDisplay({ item, result, @@ -1850,7 +1870,7 @@ function ToolResultDisplay({ 0 ? `${details.startLine}-${details.startLine + details.numLines - 1}/${details.totalLines}` : `empty/${details.totalLines}` }, { label: "view", value: details.isPartialView ? "partial" : "full" }, ...(details.truncated ? [{ label: "truncated", value: "true" }] : []), @@ -1946,7 +1966,7 @@ function ToolResultDisplay({ ); } @@ -2077,7 +2100,7 @@ function ToolResultDisplay({ const details = result.details as DeleteResultDetails; return ( - + ); } @@ -2094,7 +2117,7 @@ function ToolResultDisplay({ total: details.total, offset: details.offset, hasMore: details.hasMore, - }).concat(fileRootTags(details.root))} + }).concat(fileScopeTags(details))} /> @@ -2126,7 +2149,7 @@ function ToolResultDisplay({ total: details.total, offset: details.offset, hasMore: details.hasMore, - }).concat(fileRootTags(details.root))} + }).concat(fileScopeTags(details))} /> @@ -2151,7 +2174,7 @@ function ToolResultDisplay({ }, + + #[error("{path} is not a directory")] + NotADirectory { path: String, entry_kind: String }, + + #[error("{path} is not a regular file")] + NotAFile { path: String, entry_kind: String }, + + #[error("{message}")] + UnsupportedTarget { path: String, message: String }, + + #[error("{path} requires a full-file Read first")] + RequiresFullRead { path: String }, + + #[error("{message}")] + StaleFile { path: String, message: String }, + + #[error("old_string was not found in {path}")] + EditNoMatch { path: String }, + + #[error("old_string matched {matches} locations in {path}")] + EditAmbiguous { path: String, matches: usize }, + + #[error("expected {expected} replacements but found {actual} in {path}")] + EditCountMismatch { + path: String, + expected: usize, + actual: usize, + }, + + #[error("{message}")] + TooLarge { path: String, message: String }, + + #[error("{path} is not valid UTF-8 text")] + NotUtf8 { path: String }, + #[error("I/O error: {0}")] Io(#[from] io::Error), @@ -73,6 +110,230 @@ enum FsError { Other(String), } +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FsErrorCode { + InvalidWorkdir, + InvalidPath, + OutOfBounds, + NotFound, + NotADirectory, + NotAFile, + UnsupportedTarget, + RequiresFullRead, + StaleFile, + EditNoMatch, + EditAmbiguous, + EditCountMismatch, + TooLarge, + NotUtf8, + RegexInvalid, + GlobInvalid, + Io, + Other, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FsCommandError { + pub code: FsErrorCode, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workdir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub entry_kind: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub did_you_mean: Vec, +} + +impl FsCommandError { + fn other(message: impl Into) -> Self { + Self { + code: FsErrorCode::Other, + message: message.into(), + path: None, + workdir: None, + entry_kind: None, + did_you_mean: Vec::new(), + } + } + + fn with_workdir(mut self, workdir: &Path) -> Self { + self.workdir = Some(display_path(workdir)); + self + } +} + +impl From for FsCommandError { + fn from(err: FsError) -> Self { + let message = err.to_string(); + let (code, path, entry_kind, did_you_mean) = match err { + FsError::InvalidWorkdir(_) => (FsErrorCode::InvalidWorkdir, None, None, Vec::new()), + FsError::InvalidRelPath(path) => (FsErrorCode::InvalidPath, Some(path), None, Vec::new()), + FsError::OutOfBounds(path) => (FsErrorCode::OutOfBounds, Some(path), None, Vec::new()), + FsError::NotFound { path, did_you_mean } => { + (FsErrorCode::NotFound, Some(path), None, did_you_mean) + } + FsError::NotADirectory { path, entry_kind } => { + (FsErrorCode::NotADirectory, Some(path), Some(entry_kind), Vec::new()) + } + FsError::NotAFile { path, entry_kind } => { + (FsErrorCode::NotAFile, Some(path), Some(entry_kind), Vec::new()) + } + FsError::UnsupportedTarget { path, .. } => { + (FsErrorCode::UnsupportedTarget, Some(path), None, Vec::new()) + } + FsError::RequiresFullRead { path } => { + (FsErrorCode::RequiresFullRead, Some(path), None, Vec::new()) + } + FsError::StaleFile { path, .. } => (FsErrorCode::StaleFile, Some(path), None, Vec::new()), + FsError::EditNoMatch { path } => (FsErrorCode::EditNoMatch, Some(path), None, Vec::new()), + FsError::EditAmbiguous { path, .. } => { + (FsErrorCode::EditAmbiguous, Some(path), None, Vec::new()) + } + FsError::EditCountMismatch { path, .. } => { + (FsErrorCode::EditCountMismatch, Some(path), None, Vec::new()) + } + FsError::TooLarge { path, .. } => (FsErrorCode::TooLarge, Some(path), None, Vec::new()), + FsError::NotUtf8 { path } => (FsErrorCode::NotUtf8, Some(path), None, Vec::new()), + FsError::Io(err) if err.kind() == io::ErrorKind::NotFound => { + (FsErrorCode::NotFound, None, None, Vec::new()) + } + FsError::Io(_) => (FsErrorCode::Io, None, None, Vec::new()), + FsError::Regex(_) => (FsErrorCode::RegexInvalid, None, None, Vec::new()), + FsError::Glob(_) => (FsErrorCode::GlobInvalid, None, None, Vec::new()), + FsError::Other(_) => (FsErrorCode::Other, None, None, Vec::new()), + }; + Self { + code, + message, + path, + workdir: None, + entry_kind, + did_you_mean, + } + } +} + +async fn run_blocking_fs( + label: &'static str, + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tauri::async_runtime::spawn_blocking(f) + .await + .map_err(|e| FsCommandError::other(format!("{label} join failed: {e}")))? +} + +#[cfg(unix)] +fn file_identity(meta: &fs::Metadata, _canon: &Path) -> String { + use std::os::unix::fs::MetadataExt; + format!("{}:{}", meta.dev(), meta.ino()) +} + +#[cfg(windows)] +fn file_identity(_meta: &fs::Metadata, canon: &Path) -> String { + format!("path:{}", display_path(canon).to_lowercase()) +} + +fn levenshtein_at_most(a: &str, b: &str, max: usize) -> bool { + let a: Vec = a.chars().take(64).collect(); + let b: Vec = b.chars().take(64).collect(); + if a.len().abs_diff(b.len()) > max { + return false; + } + let mut prev: Vec = (0..=b.len()).collect(); + for (i, ca) in a.iter().enumerate() { + let mut cur = Vec::with_capacity(b.len() + 1); + cur.push(i + 1); + let mut row_min = i + 1; + for (j, cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + let value = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1); + row_min = row_min.min(value); + cur.push(value); + } + if row_min > max { + return false; + } + prev = cur; + } + prev[b.len()] <= max +} + +fn nearest_entries(parent: &Path, missing_name: &str, limit: usize) -> Vec { + let needle = missing_name.to_lowercase(); + if needle.is_empty() { + return Vec::new(); + } + let Ok(entries) = fs::read_dir(parent) else { + return Vec::new(); + }; + let mut ranked: Vec<(u8, String)> = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let lower = name.to_lowercase(); + let rank = if lower == needle { + 0 + } else if lower.starts_with(&needle) || needle.starts_with(&lower) || lower.contains(&needle) + { + 1 + } else if levenshtein_at_most(&lower, &needle, 2) { + 2 + } else { + continue; + }; + ranked.push((rank, name)); + } + ranked.sort(); + ranked.into_iter().take(limit).map(|(_, name)| name).collect() +} + +fn not_found_error(workdir: &Path, rel: &Path) -> FsError { + let mut existing = workdir.to_path_buf(); + let mut existing_rel = PathBuf::new(); + let mut missing: Option = None; + for component in rel.components() { + let name = component.as_os_str().to_string_lossy().to_string(); + let candidate = existing.join(&name); + if candidate.exists() { + existing = candidate; + existing_rel.push(&name); + } else { + missing = Some(name); + break; + } + } + let did_you_mean = missing + .as_deref() + .map(|name| { + nearest_entries(&existing, name, 5) + .into_iter() + .map(|candidate| logical_rel_path(&existing_rel.join(candidate))) + .collect() + }) + .unwrap_or_default(); + FsError::NotFound { + path: logical_rel_path(rel), + did_you_mean, + } +} + +fn resolve_target(workdir: &Path, rel: &Path) -> Result { + let target = workdir.join(rel); + match fs::canonicalize(&target) { + Ok(canon) => { + if !canon.starts_with(workdir) { + return Err(FsError::OutOfBounds(canon.display().to_string())); + } + Ok(canon) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Err(not_found_error(workdir, rel)), + Err(err) => Err(FsError::Io(err)), + } +} + fn canonicalize_workdir(workdir: &str) -> Result { let raw = workdir.trim(); if raw.is_empty() { @@ -244,25 +505,25 @@ fn display_path(path: &Path) -> String { normalized } -fn resolve_existing_file_target( - workdir: &Path, - target: &Path, - label: &str, -) -> Result { - let resolved = ensure_within_workdir_existing(workdir, target).map_err(|e| e.to_string())?; - let md = fs::metadata(&resolved).map_err(|e| e.to_string())?; +fn resolve_existing_file_target(workdir: &Path, rel: &Path) -> Result { + let resolved = resolve_target(workdir, rel)?; + let md = fs::metadata(&resolved)?; if !md.is_file() { - return Err(FsError::Other(format!("{label} must be a regular file")).to_string()); + let entry_kind = if md.is_dir() { "dir" } else { "other" }; + return Err(FsError::NotAFile { + path: logical_rel_path(rel), + entry_kind: entry_kind.to_string(), + }); } Ok(resolved) } -fn ensure_parent_dir(workdir: &Path, target: &Path) -> Result<(), String> { +fn ensure_parent_dir(workdir: &Path, target: &Path) -> Result<(), FsError> { let parent = target .parent() - .ok_or_else(|| FsError::Other("Invalid target path".to_string()).to_string())?; - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - ensure_within_workdir_existing(workdir, parent).map_err(|e| e.to_string())?; + .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; + fs::create_dir_all(parent)?; + ensure_within_workdir_existing(workdir, parent)?; Ok(()) } @@ -402,11 +663,12 @@ fn infer_image_mime_from_bytes(bytes: &[u8]) -> Option<&'static str> { None } -fn validate_image_size(label: &str, len: usize) -> Result<(), String> { +fn validate_image_size(label: &str, len: usize) -> Result<(), FsError> { if len > READ_MAX_IMAGE_BYTES { - return Err( - FsError::Other(format!("Image is too large to read via tool ({label})")).to_string(), - ); + return Err(FsError::TooLarge { + path: label.to_string(), + message: format!("Image is too large to read via tool ({label})"), + }); } Ok(()) } @@ -416,7 +678,7 @@ fn resolve_supported_image_mime( provided_mime: Option<&str>, path_hint: Option<&Path>, bytes: &[u8], -) -> Result { +) -> Result { if let Some(mime) = provided_mime.and_then(normalize_supported_image_mime) { return Ok(mime); } @@ -426,7 +688,10 @@ fn resolve_supported_image_mime( if let Some(mime) = infer_image_mime_from_bytes(bytes) { return Ok(mime.to_string()); } - Err(FsError::Other(format!("{label} is not a supported image file")).to_string()) + Err(FsError::UnsupportedTarget { + path: label.to_string(), + message: format!("{label} is not a supported image file"), + }) } fn build_image_read_response( @@ -434,6 +699,7 @@ fn build_image_read_response( bytes: Vec, mime_type: String, mtime_ms: u64, + file_id: Option, ) -> ReadResponse { let size_bytes = bytes.len(); let content_hash = hash_bytes(&bytes); @@ -457,6 +723,7 @@ fn build_image_read_response( mime_type: Some(mime_type), data: Some(BASE64_STANDARD.encode(bytes)), size_bytes: Some(size_bytes), + file_id, } } @@ -763,6 +1030,7 @@ fn build_workspace_preview_response( mtime_ms: u64, content_hash: String, size_bytes: usize, + file_id: Option, ) -> ReadResponse { let kind = if mime_type.starts_with("image/") { "image" @@ -790,31 +1058,37 @@ fn build_workspace_preview_response( mime_type: Some(mime_type.to_string()), data: Some(BASE64_STANDARD.encode(bytes)), size_bytes: Some(size_bytes), + file_id, } } -fn read_local_preview_file(target: PathBuf, logical_path: String) -> Result { - let md = fs::metadata(&target).map_err(|e| e.to_string())?; +fn read_local_preview_file(target: PathBuf, logical_path: String) -> Result { + let md = fs::metadata(&target)?; let size_bytes = usize::try_from(md.len()).unwrap_or(usize::MAX); if size_bytes > READ_MAX_PREVIEW_BYTES { - return Err(FsError::Other(format!( - "File is too large to preview ({size_bytes} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)" - )) - .to_string()); + return Err(FsError::TooLarge { + path: logical_path, + message: format!( + "File is too large to preview ({size_bytes} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)" + ), + }); } - let bytes = fs::read(&target).map_err(|e| e.to_string())?; + let bytes = fs::read(&target)?; if bytes.len() > READ_MAX_PREVIEW_BYTES { - return Err(FsError::Other(format!( - "File is too large to preview ({} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)", - bytes.len() - )) - .to_string()); + return Err(FsError::TooLarge { + path: logical_path, + message: format!( + "File is too large to preview ({} bytes, max {READ_MAX_PREVIEW_BYTES} bytes)", + bytes.len() + ), + }); } let mtime_ms = metadata_mtime_ms(&md); let content_hash = hash_bytes(&bytes); let original_size_bytes = bytes.len(); + let file_id = Some(file_identity(&md, &target)); if is_convertible_document_preview_file(&target) { let html_bytes = convert_document_to_html_preview_cached( @@ -822,7 +1096,8 @@ fn read_local_preview_file(target: PathBuf, logical_path: String) -> Result Result Result, size_bytes: usize, + file_id: Option, ) -> ReadResponse { ReadResponse { kind: kind.to_string(), @@ -1583,6 +1863,7 @@ fn build_document_read_response( mime_type, data: None, size_bytes: Some(size_bytes), + file_id, } } @@ -1669,7 +1950,7 @@ struct ExpectedVersion { fn parse_expected_version( expected_mtime_ms: Option, expected_content_hash: Option, -) -> Result, String> { +) -> Result, FsError> { match (expected_mtime_ms, expected_content_hash) { (None, None) => Ok(None), (Some(mtime_ms), Some(content_hash)) if !content_hash.trim().is_empty() => { @@ -1678,25 +1959,28 @@ fn parse_expected_version( content_hash, })) } - _ => { - Err("expected_mtime_ms and expected_content_hash must be provided together".to_string()) - } + _ => Err(FsError::Other( + "expected_mtime_ms and expected_content_hash must be provided together".to_string(), + )), } } fn ensure_expected_version_matches( target: &Path, + logical_path: &str, expected: &ExpectedVersion, -) -> Result<(), String> { - let md = fs::metadata(target).map_err(|e| e.to_string())?; - let bytes = fs::read(target).map_err(|e| e.to_string())?; +) -> Result<(), FsError> { + let md = fs::metadata(target)?; + let bytes = fs::read(target)?; let actual_mtime_ms = metadata_mtime_ms(&md); let actual_content_hash = hash_bytes(&bytes); if actual_mtime_ms != expected.mtime_ms || actual_content_hash != expected.content_hash { - return Err( - "File changed since the last full Read. Read the file again before modifying it." - .to_string(), - ); + return Err(FsError::StaleFile { + path: logical_path.to_string(), + message: + "File changed since the last full Read. Read the file again before modifying it." + .to_string(), + }); } Ok(()) } @@ -1723,17 +2007,18 @@ pub struct ReadResponse { pub mime_type: Option, pub data: Option, pub size_bytes: Option, + pub file_id: Option, } fn compact_base64(input: &str) -> String { input.chars().filter(|c| !c.is_ascii_whitespace()).collect() } -fn decode_base64_image_bytes(label: &str, input: &str) -> Result, String> { +fn decode_base64_image_bytes(label: &str, input: &str) -> Result, FsError> { let compact = compact_base64(input); let bytes = BASE64_STANDARD .decode(compact.as_bytes()) - .map_err(|e| FsError::Other(format!("{label} is not valid base64: {e}")).to_string())?; + .map_err(|e| FsError::Other(format!("{label} is not valid base64: {e}")))?; validate_image_size(label, bytes.len())?; Ok(bytes) } @@ -1747,7 +2032,7 @@ fn hex_digit_value(byte: u8) -> Option { } } -fn percent_decode_data_url_payload(label: &str, payload: &str) -> Result, String> { +fn percent_decode_data_url_payload(label: &str, payload: &str) -> Result, FsError> { let bytes = payload.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut index = 0usize; @@ -1756,20 +2041,17 @@ fn percent_decode_data_url_payload(label: &str, payload: &str) -> Result if index + 2 >= bytes.len() { return Err(FsError::Other(format!( "{label} data URL contains an incomplete percent escape" - )) - .to_string()); + ))); } let hi = hex_digit_value(bytes[index + 1]).ok_or_else(|| { FsError::Other(format!( "{label} data URL contains an invalid percent escape" )) - .to_string() })?; let lo = hex_digit_value(bytes[index + 2]).ok_or_else(|| { FsError::Other(format!( "{label} data URL contains an invalid percent escape" )) - .to_string() })?; out.push((hi << 4) | lo); index += 3; @@ -1786,7 +2068,7 @@ fn parse_data_image_url( label: &str, source: &str, fallback_mime_type: Option<&str>, -) -> Result, String)>, String> { +) -> Result, String)>, FsError> { if !source .get(..5) .map(|prefix| prefix.eq_ignore_ascii_case("data:")) @@ -1795,9 +2077,9 @@ fn parse_data_image_url( return Ok(None); } - let (header, payload) = source.split_once(',').ok_or_else(|| { - FsError::Other(format!("{label} data URL is missing a comma")).to_string() - })?; + let (header, payload) = source + .split_once(',') + .ok_or_else(|| FsError::Other(format!("{label} data URL is missing a comma")))?; let is_base64 = header .split(';') .any(|part| part.trim().eq_ignore_ascii_case("base64")); @@ -1816,13 +2098,11 @@ fn parse_data_image_url( FsError::Other(format!( "{label} non-base64 data URL must declare image/svg+xml" )) - .to_string() })?; if normalized_mime != "image/svg+xml" || !looks_like_svg(&bytes) { return Err(FsError::Other(format!( "{label} non-base64 data URL only supports SVG image data" - )) - .to_string()); + ))); } } let mime_type = @@ -1833,7 +2113,7 @@ fn parse_data_image_url( fn read_base64_image_source( source: &str, mime_type: Option, -) -> Result { +) -> Result { let label = "base64 image"; let (bytes, mime_type) = match parse_data_image_url(label, source, mime_type.as_deref())? { Some(parsed) => parsed, @@ -1850,15 +2130,16 @@ fn read_base64_image_source( bytes, mime_type, 0, + None, )) } -fn read_inline_svg_image_source(source: &str) -> Result { +fn read_inline_svg_image_source(source: &str) -> Result { let label = "inline SVG"; let bytes = source.as_bytes().to_vec(); validate_image_size(label, bytes.len())?; if !looks_like_svg(&bytes) { - return Err(FsError::Other(format!("{label} is not valid SVG image data")).to_string()); + return Err(FsError::Other(format!("{label} is not valid SVG image data"))); } let mime_type = resolve_supported_image_mime(label, Some("image/svg+xml"), None, &bytes)?; let display_label = format!("inline-svg:{mime_type}:{} bytes", bytes.len()); @@ -1867,88 +2148,93 @@ fn read_inline_svg_image_source(source: &str) -> Result { bytes, mime_type, 0, + None, )) } -fn read_local_image_file(target: PathBuf, label: String) -> Result { - let md = fs::metadata(&target).map_err(|e| e.to_string())?; +fn read_local_image_file(target: PathBuf, label: String) -> Result { + let md = fs::metadata(&target)?; if !md.is_file() { - return Err(FsError::Other(format!("{label} must be a regular file")).to_string()); + let entry_kind = if md.is_dir() { "dir" } else { "other" }; + return Err(FsError::NotAFile { + path: label, + entry_kind: entry_kind.to_string(), + }); } - let bytes = fs::read(&target).map_err(|e| e.to_string())?; + let bytes = fs::read(&target)?; validate_image_size(&label, bytes.len())?; let mime_type = resolve_supported_image_mime(&label, None, Some(&target), &bytes)?; let mtime_ms = metadata_mtime_ms(&md); - Ok(build_image_read_response(label, bytes, mime_type, mtime_ms)) + let file_id = Some(file_identity(&md, &target)); + Ok(build_image_read_response( + label, bytes, mime_type, mtime_ms, file_id, + )) } -fn read_path_image_source(workdir: &str, source: &str) -> Result { +fn read_path_image_source(workdir: &str, source: &str) -> Result { let raw = source.trim(); if raw.is_empty() { - return Err(FsError::InvalidRelPath(source.to_string()).to_string()); + return Err(FsError::InvalidRelPath(source.to_string())); } if let Some(file_path) = parse_file_url_path(raw)? { - let target = fs::canonicalize(&file_path).map_err(|e| e.to_string())?; + let target = fs::canonicalize(&file_path)?; let label = display_path(&target); return read_local_image_file(target, label); } let expanded = expand_tilde_path(raw); if expanded.is_absolute() { - let target = fs::canonicalize(&expanded).map_err(|e| e.to_string())?; + let target = fs::canonicalize(&expanded)?; let label = display_path(&target); return read_local_image_file(target, label); } - let wd = canonicalize_workdir(workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(raw).map_err(|e| e.to_string())?; + let wd = canonicalize_workdir(workdir)?; + let rel = sanitize_rel_path(raw)?; let logical_path = logical_rel_path(&rel); - let target = wd.join(&rel); - let target = resolve_existing_file_target(&wd, &target, "Image.path")?; + let target = resolve_existing_file_target(&wd, &rel)?; read_local_image_file(target, logical_path) } -fn parse_file_url_path(source: &str) -> Result, String> { +fn parse_file_url_path(source: &str) -> Result, FsError> { let Ok(url) = Url::parse(source) else { return Ok(None); }; if url.scheme() != "file" { return Ok(None); } - url.to_file_path().map(Some).map_err(|_| { - FsError::Other("Image.file URL must resolve to a local file path".to_string()).to_string() - }) + url.to_file_path() + .map(Some) + .map_err(|_| FsError::Other("Image.file URL must resolve to a local file path".to_string())) } -fn read_url_image_source(source: &str) -> Result { - let url = Url::parse(source).map_err(|e| { - FsError::Other(format!("Image.url must be an absolute URL: {e}")).to_string() - })?; +fn read_url_image_source(source: &str) -> Result { + let url = Url::parse(source) + .map_err(|e| FsError::Other(format!("Image.url must be an absolute URL: {e}")))?; match url.scheme() { "http" | "https" => {} scheme => { return Err(FsError::Other(format!( "Image.url only supports http and https, got {scheme}" - )) - .to_string()); + ))); } } let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(IMAGE_SOURCE_HTTP_TIMEOUT_SECS)) .build() - .map_err(|e| format!("Failed to create the HTTP client: {e}"))?; + .map_err(|e| FsError::Other(format!("Failed to create the HTTP client: {e}")))?; let response = client .get(url.clone()) .send() - .map_err(|e| format!("Image.url request failed: {e}"))?; + .map_err(|e| FsError::Other(format!("Image.url request failed: {e}")))?; let status = response.status(); if !status.is_success() { - return Err(format!( + return Err(FsError::Other(format!( "Image.url request failed with HTTP status {status}" - )); + ))); } if let Some(content_length) = response @@ -1970,7 +2256,7 @@ fn read_url_image_source(source: &str) -> Result { let mut bytes = Vec::new(); reader .read_to_end(&mut bytes) - .map_err(|e| format!("Failed to read Image.url response: {e}"))?; + .map_err(|e| FsError::Other(format!("Failed to read Image.url response: {e}")))?; validate_image_size(url.as_str(), bytes.len())?; let mime_type = resolve_supported_image_mime( url.as_str(), @@ -1983,6 +2269,7 @@ fn read_url_image_source(source: &str) -> Result { bytes, mime_type, 0, + None, )) } @@ -1997,10 +2284,20 @@ fn fs_read_image_source_sync( source: String, source_type: Option, mime_type: Option, -) -> Result { +) -> Result { + fs_read_image_source_impl(workdir, source, source_type, mime_type) + .map_err(FsCommandError::from) +} + +fn fs_read_image_source_impl( + workdir: String, + source: String, + source_type: Option, + mime_type: Option, +) -> Result { let source = source.trim().to_string(); if source.is_empty() { - return Err("Image source cannot be empty".to_string()); + return Err(FsError::Other("Image source cannot be empty".to_string())); } let normalized_type = source_type @@ -2028,9 +2325,9 @@ fn fs_read_image_source_sync( read_path_image_source(&workdir, &source) } } - other => Err(format!( + other => Err(FsError::Other(format!( "Image sourceType must be one of path, url, base64, or auto, got {other}" - )), + ))), } } @@ -2040,8 +2337,8 @@ pub async fn fs_read_image_source( source: String, source_type: Option, mime_type: Option, -) -> Result { - run_blocking("fs_read_image_source", move || { +) -> Result { + run_blocking_fs("fs_read_image_source", move || { fs_read_image_source_sync(workdir, source, source_type, mime_type) }) .await @@ -2050,12 +2347,16 @@ pub async fn fs_read_image_source( pub(crate) fn fs_read_workspace_image_sync( workdir: String, path: String, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_read_workspace_image_impl(&wd, &path) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_read_workspace_image_impl(wd: &Path, path: &str) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); - let target = wd.join(&rel); - let target = resolve_existing_file_target(&wd, &target, "Preview.path")?; + let target = resolve_existing_file_target(wd, &rel)?; read_local_preview_file(target, logical_path) } @@ -2063,8 +2364,8 @@ pub(crate) fn fs_read_workspace_image_sync( pub async fn fs_read_workspace_image( workdir: String, path: String, -) -> Result { - run_blocking("fs_read_workspace_image", move || { +) -> Result { + run_blocking_fs("fs_read_workspace_image", move || { fs_read_workspace_image_sync(workdir, path) }) .await @@ -2079,25 +2380,40 @@ fn fs_read_text_sync( page_limit: Option, cell_start: Option, cell_limit: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_read_text_impl( + &wd, &path, start_line, limit, page_start, page_limit, cell_start, cell_limit, + ) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_read_text_impl( + wd: &Path, + path: &str, + start_line: Option, + limit: Option, + page_start: Option, + page_limit: Option, + cell_start: Option, + cell_limit: Option, +) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); - let target = wd.join(&rel); - let target = resolve_existing_file_target(&wd, &target, "Read.path")?; + let target = resolve_existing_file_target(wd, &rel)?; - let md = fs::metadata(&target).map_err(|e| e.to_string())?; - let bytes = fs::read(&target).map_err(|e| e.to_string())?; + let md = fs::metadata(&target)?; + let bytes = fs::read(&target)?; let mtime_ms = metadata_mtime_ms(&md); let content_hash = hash_bytes(&bytes); + let file_id = Some(file_identity(&md, &target)); if let Some(mime_type) = infer_image_mime(&target) { if bytes.len() > READ_MAX_IMAGE_BYTES { - return Err(FsError::Other(format!( - "Image is too large to read via tool ({})", - target.display() - )) - .to_string()); + return Err(FsError::TooLarge { + path: logical_path, + message: format!("Image is too large to read via tool ({})", target.display()), + }); } return Ok(ReadResponse { @@ -2120,6 +2436,7 @@ fn fs_read_text_sync( mime_type: Some(mime_type.to_string()), data: Some(BASE64_STANDARD.encode(bytes)), size_bytes: Some(md.len() as usize), + file_id, }); } @@ -2128,7 +2445,8 @@ fn fs_read_text_sync( &bytes, page_start.unwrap_or(1), page_limit.unwrap_or(DEFAULT_READ_LIMIT_PDF_PAGES), - )?; + ) + .map_err(FsError::Other)?; return Ok(ReadResponse { kind: "pdf".to_string(), path: logical_path, @@ -2149,6 +2467,7 @@ fn fs_read_text_sync( mime_type: Some("application/pdf".to_string()), data: None, size_bytes: Some(md.len() as usize), + file_id, }); } @@ -2157,7 +2476,8 @@ fn fs_read_text_sync( &bytes, cell_start.unwrap_or(1), cell_limit.unwrap_or(DEFAULT_READ_LIMIT_NOTEBOOK_CELLS), - )?; + ) + .map_err(FsError::Other)?; return Ok(ReadResponse { kind: "notebook".to_string(), path: logical_path, @@ -2178,12 +2498,13 @@ fn fs_read_text_sync( mime_type: Some("application/x-ipynb+json".to_string()), data: None, size_bytes: Some(md.len() as usize), + file_id, }); } if is_word_file(&target) { let (content, truncated) = if is_word_extractable_file(&target) { - build_docx_window(&bytes)? + build_docx_window(&bytes).map_err(FsError::Other)? } else { ( "Legacy Word .doc binary file recognized and uploaded.\nRead extracts text from .docx directly; use Bash or an external converter when you need to inspect legacy .doc contents.".to_string(), @@ -2199,12 +2520,13 @@ fn fs_read_text_sync( content_hash, office_mime_type(&target).map(str::to_string), md.len() as usize, + file_id, )); } if is_spreadsheet_file(&target) { let (content, truncated) = if is_xlsx_extractable_file(&target) { - build_xlsx_window(&bytes)? + build_xlsx_window(&bytes).map_err(FsError::Other)? } else { ( "Legacy Excel .xls binary file recognized and uploaded.\nRead extracts workbook previews from .xlsx/.xlsm directly; use Bash or an external converter when you need to inspect legacy .xls contents.".to_string(), @@ -2220,11 +2542,12 @@ fn fs_read_text_sync( content_hash, office_mime_type(&target).map(str::to_string), md.len() as usize, + file_id, )); } if is_archive_file(&target) { - let (content, truncated) = build_archive_window(&target, &bytes)?; + let (content, truncated) = build_archive_window(&target, &bytes).map_err(FsError::Other)?; return Ok(build_document_read_response( "archive", logical_path, @@ -2234,6 +2557,7 @@ fn fs_read_text_sync( content_hash, office_mime_type(&target).map(str::to_string), md.len() as usize, + file_id, )); } @@ -2265,6 +2589,7 @@ fn fs_read_text_sync( mime_type: None, data: None, size_bytes: Some(md.len() as usize), + file_id, }) } @@ -2278,8 +2603,8 @@ pub async fn fs_read_text( page_limit: Option, cell_start: Option, cell_limit: Option, -) -> Result { - run_blocking("fs_read_text", move || { +) -> Result { + run_blocking_fs("fs_read_text", move || { fs_read_text_sync( workdir, path, start_line, limit, page_start, page_limit, cell_start, cell_limit, ) @@ -2296,43 +2621,56 @@ pub struct ReadEditableTextResponse { pub content_hash: String, pub size_bytes: usize, pub total_lines: usize, + pub file_id: Option, } pub(crate) fn fs_read_editable_text_sync( workdir: String, path: String, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_read_editable_text_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_read_editable_text_impl(wd: &Path, path: &str) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); - let target = wd.join(&rel); - let target = resolve_existing_file_target(&wd, &target, "Read.path")?; + let target = resolve_existing_file_target(wd, &rel)?; if let Some(reason) = editable_text_unsupported_reason(&target) { - return Err(FsError::Other(reason.to_string()).to_string()); + return Err(FsError::UnsupportedTarget { + path: logical_path, + message: reason.to_string(), + }); } - let md = fs::metadata(&target).map_err(|e| e.to_string())?; + let md = fs::metadata(&target)?; let size_bytes = usize::try_from(md.len()).unwrap_or(usize::MAX); if size_bytes > EDITABLE_TEXT_MAX_BYTES { - return Err(FsError::Other(format!( - "File is too large to edit ({size_bytes} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)" - )) - .to_string()); + return Err(FsError::TooLarge { + path: logical_path, + message: format!( + "File is too large to edit ({size_bytes} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)" + ), + }); } - let bytes = fs::read(&target).map_err(|e| e.to_string())?; + let bytes = fs::read(&target)?; if bytes.len() > EDITABLE_TEXT_MAX_BYTES { - return Err(FsError::Other(format!( - "File is too large to edit ({} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)", - bytes.len() - )) - .to_string()); + return Err(FsError::TooLarge { + path: logical_path, + message: format!( + "File is too large to edit ({} bytes, max {EDITABLE_TEXT_MAX_BYTES} bytes)", + bytes.len() + ), + }); } let mtime_ms = metadata_mtime_ms(&md); let content_hash = hash_bytes(&bytes); - let content = String::from_utf8(bytes) - .map_err(|_| FsError::Other("File is not valid UTF-8 text".to_string()).to_string())?; + let file_id = Some(file_identity(&md, &target)); + let content = String::from_utf8(bytes).map_err(|_| FsError::NotUtf8 { + path: logical_path.clone(), + })?; let total_lines = count_text_lines(&content); Ok(ReadEditableTextResponse { @@ -2342,6 +2680,7 @@ pub(crate) fn fs_read_editable_text_sync( content_hash, size_bytes, total_lines, + file_id, }) } @@ -2349,8 +2688,8 @@ pub(crate) fn fs_read_editable_text_sync( pub async fn fs_read_editable_text( workdir: String, path: String, -) -> Result { - run_blocking("fs_read_editable_text", move || { +) -> Result { + run_blocking_fs("fs_read_editable_text", move || { fs_read_editable_text_sync(workdir, path) }) .await @@ -2364,14 +2703,19 @@ pub struct PathStatusResponse { pub kind: Option, pub size_bytes: Option, pub mtime_ms: Option, + pub file_id: Option, } pub(crate) fn fs_path_status_sync( workdir: String, path: String, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_path_status_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_path_status_impl(wd: &Path, path: &str) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let target = wd.join(&rel); @@ -2387,12 +2731,16 @@ pub(crate) fn fs_path_status_sync( } else { "other" }; + let file_id = fs::canonicalize(&target) + .ok() + .map(|canon| file_identity(&meta, &canon)); Ok(PathStatusResponse { path: logical_path, exists: true, kind: Some(kind.to_string()), size_bytes: Some(meta.len()), mtime_ms: Some(metadata_mtime_ms(&meta)), + file_id, }) } Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(PathStatusResponse { @@ -2401,14 +2749,18 @@ pub(crate) fn fs_path_status_sync( kind: None, size_bytes: None, mtime_ms: None, + file_id: None, }), - Err(err) => Err(FsError::Io(err).to_string()), + Err(err) => Err(FsError::Io(err)), } } #[tauri::command(rename_all = "snake_case")] -pub async fn fs_path_status(workdir: String, path: String) -> Result { - run_blocking("fs_path_status", move || fs_path_status_sync(workdir, path)).await +pub async fn fs_path_status( + workdir: String, + path: String, +) -> Result { + run_blocking_fs("fs_path_status", move || fs_path_status_sync(workdir, path)).await } #[derive(Debug, Serialize)] @@ -2421,6 +2773,7 @@ pub struct WriteTextResponse { pub mtime_ms: u64, pub content_hash: String, pub total_lines: usize, + pub file_id: Option, } pub(crate) fn fs_write_text_sync( @@ -2430,45 +2783,63 @@ pub(crate) fn fs_write_text_sync( mode: String, expected_mtime_ms: Option, expected_content_hash: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_write_text_impl( + &wd, + &path, + content, + mode, + expected_mtime_ms, + expected_content_hash, + ) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_write_text_impl( + wd: &Path, + path: &str, + content: String, + mode: String, + expected_mtime_ms: Option, + expected_content_hash: Option, +) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let raw_target = wd.join(&rel); let expected = parse_expected_version(expected_mtime_ms, expected_content_hash)?; if mode != "rewrite" { - return Err("Write.mode only supports rewrite".to_string()); + return Err(FsError::Other("Write.mode only supports rewrite".to_string())); } let (target, existed_before) = match fs::symlink_metadata(&raw_target) { Ok(meta) => { if meta.is_dir() { - return Err( - FsError::Other("Cannot write to a directory path".to_string()).to_string(), - ); + return Err(FsError::NotAFile { + path: logical_path, + entry_kind: "dir".to_string(), + }); } - ( - resolve_existing_file_target(&wd, &raw_target, "Write.path")?, - true, - ) + (resolve_existing_file_target(wd, &rel)?, true) } Err(err) if err.kind() == io::ErrorKind::NotFound => { - ensure_parent_dir(&wd, &raw_target)?; + ensure_parent_dir(wd, &raw_target)?; (raw_target.clone(), false) } - Err(err) => return Err(FsError::Io(err).to_string()), + Err(err) => return Err(FsError::Io(err)), }; if existed_before { - let expected = expected.ok_or_else(|| { - "Write requires a full-file Read first for existing files".to_string() + let expected = expected.ok_or_else(|| FsError::RequiresFullRead { + path: logical_path.clone(), })?; - ensure_expected_version_matches(&target, &expected)?; + ensure_expected_version_matches(&target, &logical_path, &expected)?; } - fs::write(&target, content.as_bytes()).map_err(|e| e.to_string())?; - let md = fs::metadata(&target).map_err(|e| e.to_string())?; + fs::write(&target, content.as_bytes())?; + let canon = fs::canonicalize(&target)?; + let md = fs::metadata(&canon)?; Ok(WriteTextResponse { path: logical_path, @@ -2478,6 +2849,7 @@ pub(crate) fn fs_write_text_sync( mtime_ms: metadata_mtime_ms(&md), content_hash: hash_bytes(content.as_bytes()), total_lines: count_text_lines(&content), + file_id: Some(file_identity(&md, &canon)), }) } @@ -2489,8 +2861,8 @@ pub async fn fs_write_text( mode: String, expected_mtime_ms: Option, expected_content_hash: Option, -) -> Result { - run_blocking("fs_write_text", move || { +) -> Result { + run_blocking_fs("fs_write_text", move || { fs_write_text_sync( workdir, path, @@ -2512,6 +2884,7 @@ pub struct EditTextResponse { pub mtime_ms: u64, pub content_hash: String, pub total_lines: usize, + pub file_id: Option, } pub(crate) fn fs_edit_text_sync( @@ -2523,47 +2896,71 @@ pub(crate) fn fs_edit_text_sync( replace_all: Option, expected_mtime_ms: Option, expected_content_hash: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_edit_text_impl( + &wd, + &path, + old_string, + new_string, + expected_replacements, + replace_all, + expected_mtime_ms, + expected_content_hash, + ) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_edit_text_impl( + wd: &Path, + path: &str, + old_string: String, + new_string: String, + expected_replacements: Option, + replace_all: Option, + expected_mtime_ms: Option, + expected_content_hash: Option, +) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); - let target = wd.join(&rel); - let target = resolve_existing_file_target(&wd, &target, "Edit.path")?; + let target = resolve_existing_file_target(wd, &rel)?; let expected = parse_expected_version(expected_mtime_ms, expected_content_hash)? - .ok_or_else(|| "Edit requires a full-file Read first".to_string())?; + .ok_or_else(|| FsError::RequiresFullRead { + path: logical_path.clone(), + })?; if old_string.is_empty() { - return Err("Edit.old_string must be a non-empty string".to_string()); + return Err(FsError::Other( + "Edit.old_string must be a non-empty string".to_string(), + )); } - ensure_expected_version_matches(&target, &expected)?; + ensure_expected_version_matches(&target, &logical_path, &expected)?; - let bytes = fs::read(&target).map_err(|e| e.to_string())?; + let bytes = fs::read(&target)?; let text = String::from_utf8_lossy(&bytes); let match_count = text.matches(&old_string).count(); if match_count == 0 { - return Err( - FsError::Other("old_string was not found; no changes were made".to_string()) - .to_string(), - ); + return Err(FsError::EditNoMatch { path: logical_path }); } let replace_all = replace_all.unwrap_or(false); if !replace_all && match_count > 1 { - return Err(FsError::Other(format!( - "Found {match_count} matches. Set replace_all=true or narrow old_string before editing." - )) - .to_string()); + return Err(FsError::EditAmbiguous { + path: logical_path, + matches: match_count, + }); } let actual_replacements = if replace_all { match_count } else { 1 }; if let Some(expected_count) = expected_replacements { if actual_replacements != expected_count { - return Err(FsError::Other(format!( - "Replacement count mismatch: would replace {actual_replacements}, expected {expected_count}" - )) - .to_string()); + return Err(FsError::EditCountMismatch { + path: logical_path, + expected: expected_count, + actual: actual_replacements, + }); } } @@ -2573,8 +2970,8 @@ pub(crate) fn fs_edit_text_sync( text.replacen(&old_string, &new_string, 1) }; - fs::write(&target, next.as_bytes()).map_err(|e| e.to_string())?; - let md = fs::metadata(&target).map_err(|e| e.to_string())?; + fs::write(&target, next.as_bytes())?; + let md = fs::metadata(&target)?; Ok(EditTextResponse { path: logical_path, @@ -2583,6 +2980,7 @@ pub(crate) fn fs_edit_text_sync( mtime_ms: metadata_mtime_ms(&md), content_hash: hash_bytes(next.as_bytes()), total_lines: count_text_lines(&next), + file_id: Some(file_identity(&md, &target)), }) } @@ -2596,8 +2994,8 @@ pub async fn fs_edit_text( replace_all: Option, expected_mtime_ms: Option, expected_content_hash: Option, -) -> Result { - run_blocking("fs_edit_text", move || { +) -> Result { + run_blocking_fs("fs_edit_text", move || { fs_edit_text_sync( workdir, path, @@ -2629,32 +3027,38 @@ fn remove_symlink_path(target: &Path) -> Result<(), io::Error> { } } -pub(crate) fn fs_delete_sync(workdir: String, path: String) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +pub(crate) fn fs_delete_sync( + workdir: String, + path: String, +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_delete_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_delete_impl(wd: &Path, path: &str) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let file_name = rel .file_name() - .ok_or_else(|| FsError::Other("Invalid target path".to_string()).to_string())?; - let parent = rel.parent().map_or(wd.clone(), |p| wd.join(p)); - let parent = ensure_within_workdir_existing(&wd, &parent).map_err(|e| e.to_string())?; + .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; + let parent = rel.parent().map_or(wd.to_path_buf(), |p| wd.join(p)); + let parent = ensure_within_workdir_existing(wd, &parent)?; let target = parent.join(file_name); - let meta = fs::symlink_metadata(&target).map_err(|e| e.to_string())?; + let meta = fs::symlink_metadata(&target)?; let kind = if meta.file_type().is_symlink() { - remove_symlink_path(&target).map_err(|e| e.to_string())?; + remove_symlink_path(&target)?; "symlink" } else if meta.is_file() { - fs::remove_file(&target).map_err(|e| e.to_string())?; + fs::remove_file(&target)?; "file" } else if meta.is_dir() { - fs::remove_dir_all(&target).map_err(|e| e.to_string())?; + fs::remove_dir_all(&target)?; "dir" } else { return Err(FsError::Other( "Only regular files, directories, or symlinks can be deleted".to_string(), - ) - .to_string()); + )); }; Ok(DeleteResponse { @@ -2664,8 +3068,8 @@ pub(crate) fn fs_delete_sync(workdir: String, path: String) -> Result Result { - run_blocking("fs_delete", move || fs_delete_sync(workdir, path)).await +pub async fn fs_delete(workdir: String, path: String) -> Result { + run_blocking_fs("fs_delete", move || fs_delete_sync(workdir, path)).await } #[derive(Debug, Serialize)] @@ -2722,21 +3126,29 @@ pub(crate) fn fs_open_workspace_path_sync( workdir: String, path: String, mode: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_open_workspace_path_impl(&wd, &path, mode) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_open_workspace_path_impl( + wd: &Path, + path: &str, + mode: Option, +) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); - let target = ensure_within_workdir_existing(&wd, &wd.join(&rel)).map_err(|e| e.to_string())?; - let meta = fs::metadata(&target).map_err(|e| e.to_string())?; + let target = resolve_target(wd, &rel)?; + let meta = fs::metadata(&target)?; let kind = if meta.is_file() { "file" } else if meta.is_dir() { "dir" } else { - return Err( - FsError::Other("Only regular files and directories can be opened".to_string()) - .to_string(), - ); + return Err(FsError::Other( + "Only regular files and directories can be opened".to_string(), + )); }; let normalized_mode = mode .as_deref() @@ -2747,14 +3159,13 @@ pub(crate) fn fs_open_workspace_path_sync( "" | "open" => "open", "reveal" | "containing_dir" | "containing-directory" => "reveal", other => { - return Err( - FsError::Other(format!("Open mode must be open or reveal, got {other}")) - .to_string(), - ) + return Err(FsError::Other(format!( + "Open mode must be open or reveal, got {other}" + ))) } }; - spawn_workspace_open_command(&target, kind, normalized_mode)?; + spawn_workspace_open_command(&target, kind, normalized_mode).map_err(FsError::Other)?; Ok(OpenWorkspacePathResponse { path: logical_path, @@ -2769,8 +3180,8 @@ pub async fn fs_open_workspace_path( workdir: String, path: String, mode: Option, -) -> Result { - run_blocking("fs_open_workspace_path", move || { +) -> Result { + run_blocking_fs("fs_open_workspace_path", move || { fs_open_workspace_path_sync(workdir, path, mode) }) .await @@ -2786,26 +3197,30 @@ pub struct CreateDirResponse { pub(crate) fn fs_create_dir_sync( workdir: String, path: String, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel = sanitize_rel_path(&path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_create_dir_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_create_dir_impl(wd: &Path, path: &str) -> Result { + let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let file_name = rel .file_name() - .ok_or_else(|| FsError::Other("Invalid target path".to_string()).to_string())?; - let parent = rel.parent().map_or(wd.clone(), |p| wd.join(p)); - let parent = ensure_within_workdir_existing(&wd, &parent).map_err(|e| e.to_string())?; + .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; + let parent = rel.parent().map_or(wd.to_path_buf(), |p| wd.join(p)); + let parent = ensure_within_workdir_existing(wd, &parent)?; let target = parent.join(file_name); match fs::symlink_metadata(&target) { Ok(_) => { - return Err(FsError::Other("Target path already exists".to_string()).to_string()); + return Err(FsError::Other("Target path already exists".to_string())); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(FsError::Io(error).to_string()), + Err(error) => return Err(FsError::Io(error)), } - fs::create_dir(&target).map_err(|e| e.to_string())?; + fs::create_dir(&target)?; Ok(CreateDirResponse { path: logical_path, kind: "dir".to_string(), @@ -2813,8 +3228,11 @@ pub(crate) fn fs_create_dir_sync( } #[tauri::command(rename_all = "snake_case")] -pub async fn fs_create_dir(workdir: String, path: String) -> Result { - run_blocking("fs_create_dir", move || fs_create_dir_sync(workdir, path)).await +pub async fn fs_create_dir( + workdir: String, + path: String, +) -> Result { + run_blocking_fs("fs_create_dir", move || fs_create_dir_sync(workdir, path)).await } #[derive(Debug, Serialize)] @@ -2829,33 +3247,37 @@ pub(crate) fn fs_rename_sync( workdir: String, from_path: String, to_path: String, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let from_rel = sanitize_rel_path(&from_path).map_err(|e| e.to_string())?; - let to_rel = sanitize_rel_path(&to_path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_rename_impl(&wd, &from_path, &to_path) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_rename_impl(wd: &Path, from_path: &str, to_path: &str) -> Result { + let from_rel = sanitize_rel_path(from_path)?; + let to_rel = sanitize_rel_path(to_path)?; let from_logical_path = logical_rel_path(&from_rel); let to_logical_path = logical_rel_path(&to_rel); if from_rel.parent() != to_rel.parent() { return Err(FsError::Other( "Rename only supports targets in the same directory".to_string(), - ) - .to_string()); + )); } let from_name = from_rel .file_name() - .ok_or_else(|| FsError::Other("Invalid source path".to_string()).to_string())?; + .ok_or_else(|| FsError::Other("Invalid source path".to_string()))?; let to_name = to_rel .file_name() - .ok_or_else(|| FsError::Other("Invalid target path".to_string()).to_string())?; + .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; let parent_rel = from_rel.parent(); - let parent = parent_rel.map_or(wd.clone(), |p| wd.join(p)); - let parent = ensure_within_workdir_existing(&wd, &parent).map_err(|e| e.to_string())?; + let parent = parent_rel.map_or(wd.to_path_buf(), |p| wd.join(p)); + let parent = ensure_within_workdir_existing(wd, &parent)?; let source = parent.join(from_name); let target = parent.join(to_name); - let meta = fs::symlink_metadata(&source).map_err(|e| e.to_string())?; + let meta = fs::symlink_metadata(&source)?; let kind = if meta.file_type().is_symlink() { "symlink" } else if meta.is_file() { @@ -2865,19 +3287,18 @@ pub(crate) fn fs_rename_sync( } else { return Err(FsError::Other( "Only regular files, directories, or symlinks can be renamed".to_string(), - ) - .to_string()); + )); }; match fs::symlink_metadata(&target) { Ok(_) => { - return Err(FsError::Other("Target path already exists".to_string()).to_string()); + return Err(FsError::Other("Target path already exists".to_string())); } Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(FsError::Io(error).to_string()), + Err(error) => return Err(FsError::Io(error)), } - fs::rename(&source, &target).map_err(|e| e.to_string())?; + fs::rename(&source, &target)?; Ok(RenameResponse { from_path: from_logical_path, path: to_logical_path, @@ -2890,8 +3311,8 @@ pub async fn fs_rename( workdir: String, from_path: String, to_path: String, -) -> Result { - run_blocking("fs_rename", move || { +) -> Result { + run_blocking_fs("fs_rename", move || { fs_rename_sync(workdir, from_path, to_path) }) .await @@ -2914,6 +3335,7 @@ pub struct ListResponse { pub total: usize, pub has_more: bool, pub entries: Vec, + pub target_kind: Option, } #[derive(Debug, Serialize, Clone)] @@ -3100,46 +3522,70 @@ pub(crate) fn fs_list_sync( depth: Option, offset: Option, max_results: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel_opt = sanitize_optional_rel_path(path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_list_impl(&wd, path, depth, offset, max_results) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_list_impl( + wd: &Path, + path: Option, + depth: Option, + offset: Option, + max_results: Option, +) -> Result { + let rel_opt = sanitize_optional_rel_path(path)?; let path_display = rel_opt.as_ref().map(|rel| logical_rel_path(rel)); let base = match rel_opt.as_ref() { - None => wd.clone(), - Some(rel) => wd.join(rel), + None => wd.to_path_buf(), + Some(rel) => resolve_target(wd, rel)?, }; - let base = ensure_within_workdir_existing(&wd, &base).map_err(|e| e.to_string())?; - let md = fs::metadata(&base).map_err(|e| e.to_string())?; - if !md.is_dir() { - return Err(FsError::Other("List.path must be a directory".to_string()).to_string()); - } + let md = fs::metadata(&base)?; + let target_kind = if md.is_dir() { + "dir" + } else if md.is_file() { + "file" + } else { + return Err(FsError::NotADirectory { + path: path_display.unwrap_or_default(), + entry_kind: "other".to_string(), + }); + }; let depth = depth.unwrap_or(DEFAULT_LIST_DEPTH).max(1); let offset = offset.unwrap_or(0); let max_results = max_results.unwrap_or(DEFAULT_PAGE_LIMIT).max(1); let mut entries: Vec = Vec::new(); - for result in build_ignore_walker(&base, Some(depth)) { - let entry = match result { - Ok(v) => v, - Err(_) => continue, - }; + if target_kind == "file" { + entries.push(ListEntry { + path: rel_to_workdir_str(wd, &base), + kind: "file".to_string(), + }); + } else { + for result in build_ignore_walker(&base, Some(depth)) { + let entry = match result { + Ok(v) => v, + Err(_) => continue, + }; - if entry.path() == base.as_path() { - continue; - } + if entry.path() == base.as_path() { + continue; + } - let file_type = match entry.file_type() { - Some(file_type) => file_type, - None => continue, - }; + let file_type = match entry.file_type() { + Some(file_type) => file_type, + None => continue, + }; - let kind = if file_type.is_dir() { "dir" } else { "file" }; - entries.push(ListEntry { - path: rel_to_workdir_str(&wd, entry.path()), - kind: kind.to_string(), - }); + let kind = if file_type.is_dir() { "dir" } else { "file" }; + entries.push(ListEntry { + path: rel_to_workdir_str(wd, entry.path()), + kind: kind.to_string(), + }); + } } entries.sort_by(|a, b| a.path.cmp(&b.path)); @@ -3159,6 +3605,7 @@ pub(crate) fn fs_list_sync( total, has_more, entries, + target_kind: Some(target_kind.to_string()), }) } @@ -3169,8 +3616,8 @@ pub async fn fs_list( depth: Option, offset: Option, max_results: Option, -) -> Result { - run_blocking("fs_list", move || { +) -> Result { + run_blocking_fs("fs_list", move || { fs_list_sync(workdir, path, depth, offset, max_results) }) .await @@ -3187,6 +3634,7 @@ pub struct GlobResponse { pub total: usize, pub has_more: bool, pub paths: Vec, + pub target_kind: Option, } fn fs_glob_sync( @@ -3196,35 +3644,56 @@ fn fs_glob_sync( offset: Option, max_results: Option, sort_by: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel_opt = sanitize_optional_rel_path(path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_glob_impl(&wd, path, pattern, offset, max_results, sort_by) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_glob_impl( + wd: &Path, + path: Option, + pattern: String, + offset: Option, + max_results: Option, + sort_by: Option, +) -> Result { + let rel_opt = sanitize_optional_rel_path(path)?; let path_display = rel_opt.as_ref().map(|rel| logical_rel_path(rel)); - let base = match rel_opt.as_ref() { - None => wd.clone(), - Some(rel) => wd.join(rel), + let target = match rel_opt.as_ref() { + None => wd.to_path_buf(), + Some(rel) => resolve_target(wd, rel)?, + }; + let md = fs::metadata(&target)?; + let target_kind = if md.is_dir() { + "dir" + } else if md.is_file() { + "file" + } else { + return Err(FsError::NotADirectory { + path: path_display.unwrap_or_default(), + entry_kind: "other".to_string(), + }); + }; + let base = if target_kind == "file" { + target.parent().map(Path::to_path_buf).unwrap_or_else(|| wd.to_path_buf()) + } else { + target }; - let base = ensure_within_workdir_existing(&wd, &base).map_err(|e| e.to_string())?; - let md = fs::metadata(&base).map_err(|e| e.to_string())?; - if !md.is_dir() { - return Err(FsError::Other("Glob.path must be a directory".to_string()).to_string()); - } let pat = normalize_glob_pattern_input(&pattern); if pat.is_empty() { - return Err(FsError::Glob("pattern cannot be empty".to_string()).to_string()); + return Err(FsError::Glob("pattern cannot be empty".to_string())); } let sort_by = sort_by.unwrap_or_else(|| "path".to_string()); if sort_by != "path" { - return Err("Glob.sort_by only supports path".to_string()); + return Err(FsError::Other("Glob.sort_by only supports path".to_string())); } let mut builder = GlobSetBuilder::new(); - builder.add(Glob::new(&pat).map_err(|e| FsError::Glob(e.to_string()).to_string())?); - let globset = builder - .build() - .map_err(|e| FsError::Glob(e.to_string()).to_string())?; + builder.add(Glob::new(&pat).map_err(|e| FsError::Glob(e.to_string()))?); + let globset = builder.build().map_err(|e| FsError::Glob(e.to_string()))?; let offset = offset.unwrap_or(0); let max_results = max_results.unwrap_or(DEFAULT_PAGE_LIMIT).max(1); @@ -3248,7 +3717,7 @@ fn fs_glob_sync( Err(_) => continue, }; if globset.is_match(rel_to_base) { - paths.push(rel_to_workdir_str(&wd, entry.path())); + paths.push(rel_to_workdir_str(wd, entry.path())); } } @@ -3270,6 +3739,7 @@ fn fs_glob_sync( total, has_more, paths, + target_kind: Some(target_kind.to_string()), }) } @@ -3281,8 +3751,8 @@ pub async fn fs_glob( offset: Option, max_results: Option, sort_by: Option, -) -> Result { - run_blocking("fs_glob", move || { +) -> Result { + run_blocking_fs("fs_glob", move || { fs_glob_sync(workdir, path, pattern, offset, max_results, sort_by) }) .await @@ -3323,6 +3793,77 @@ pub struct GrepResponse { pub has_more: bool, pub matches: Vec, pub files: Vec, + pub target_kind: Option, +} + +fn scan_grep_file( + wd: &Path, + file: &Path, + re: ®ex::Regex, + multiline: bool, + context: usize, + matches: &mut Vec, + file_summaries: &mut BTreeMap, +) { + let bytes = match fs::read(file) { + Ok(value) => value, + Err(_) => return, + }; + let text = String::from_utf8_lossy(&bytes); + let lines = split_lines_for_grep(&text); + let file_path = rel_to_workdir_str(wd, file); + let mut file_match_count = 0usize; + let mut first_line: Option = None; + + if multiline { + let line_starts = build_line_starts(&text); + for mat in re.find_iter(&text) { + let line_no = byte_index_to_line(&line_starts, mat.start()); + let (before, after) = build_context_for_line(&lines, line_no, context); + let text = line_text_for_grep(&lines, line_no); + matches.push(GrepMatch { + path: file_path.clone(), + line: line_no, + text, + before, + after, + }); + file_match_count += 1; + if first_line.is_none() { + first_line = Some(line_no); + } + } + } else { + for (idx, line) in lines.iter().enumerate() { + if !re.is_match(line) { + continue; + } + let line_no = idx + 1; + let (before, after) = build_context_for_line(&lines, line_no, context); + matches.push(GrepMatch { + path: file_path.clone(), + line: line_no, + text: trim_line_for_preview(line), + before, + after, + }); + file_match_count += 1; + if first_line.is_none() { + first_line = Some(line_no); + } + } + } + + if file_match_count > 0 { + file_summaries.insert( + file_path.clone(), + GrepFileSummary { + path: file_path, + count: file_match_count, + first_line, + }, + ); + } } fn fs_grep_sync( @@ -3336,29 +3877,64 @@ fn fs_grep_sync( offset: Option, context: Option, multiline: Option, -) -> Result { - let wd = canonicalize_workdir(&workdir).map_err(|e| e.to_string())?; - let rel_opt = sanitize_optional_rel_path(path).map_err(|e| e.to_string())?; +) -> Result { + let wd = canonicalize_workdir(&workdir)?; + fs_grep_impl( + &wd, + path, + pattern, + file_pattern, + ignore_case, + output_mode, + head_limit, + offset, + context, + multiline, + ) + .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) +} + +fn fs_grep_impl( + wd: &Path, + path: Option, + pattern: String, + file_pattern: Option, + ignore_case: Option, + output_mode: Option, + head_limit: Option, + offset: Option, + context: Option, + multiline: Option, +) -> Result { + let rel_opt = sanitize_optional_rel_path(path)?; let path_display = rel_opt.as_ref().map(|rel| logical_rel_path(rel)); let base = match rel_opt.as_ref() { - None => wd.clone(), - Some(rel) => wd.join(rel), + None => wd.to_path_buf(), + Some(rel) => resolve_target(wd, rel)?, + }; + let md = fs::metadata(&base)?; + let target_kind = if md.is_dir() { + "dir" + } else if md.is_file() { + "file" + } else { + return Err(FsError::NotADirectory { + path: path_display.unwrap_or_default(), + entry_kind: "other".to_string(), + }); }; - let base = ensure_within_workdir_existing(&wd, &base).map_err(|e| e.to_string())?; - let md = fs::metadata(&base).map_err(|e| e.to_string())?; - if !md.is_dir() { - return Err(FsError::Other("Grep.path must be a directory".to_string()).to_string()); - } let pat = pattern.trim(); if pat.is_empty() { - return Err(FsError::Regex("pattern cannot be empty".to_string()).to_string()); + return Err(FsError::Regex("pattern cannot be empty".to_string())); } let ignore_case = ignore_case.unwrap_or(true); let output_mode = output_mode.unwrap_or_else(|| "content".to_string()); if output_mode != "content" && output_mode != "files" && output_mode != "count" { - return Err("Grep.output_mode must be content, files, or count".to_string()); + return Err(FsError::Other( + "Grep.output_mode must be content, files, or count".to_string(), + )); } let head_limit = head_limit.unwrap_or(DEFAULT_GREP_HEAD_LIMIT).max(1); let offset = offset.unwrap_or(0); @@ -3369,108 +3945,68 @@ fn fs_grep_sync( rb.case_insensitive(ignore_case); rb.multi_line(multiline); rb.dot_matches_new_line(multiline); - let re = rb - .build() - .map_err(|e| FsError::Regex(e.to_string()).to_string())?; + let re = rb.build().map_err(|e| FsError::Regex(e.to_string()))?; let file_globset = match file_pattern.as_ref() { None => None, Some(value) if value.trim().is_empty() => None, - Some(value) => Some(build_globset_from_pipe_patterns(value).map_err(|e| e.to_string())?), + Some(value) => Some(build_globset_from_pipe_patterns(value)?), }; let mut matches: Vec = Vec::new(); let mut file_summaries = BTreeMap::::new(); - for result in build_ignore_walker(&base, None) { - let entry = match result { - Ok(v) => v, - Err(_) => continue, - }; - - let file_type = match entry.file_type() { - Some(file_type) => file_type, - None => continue, - }; - if !file_type.is_file() { - continue; - } + if target_kind == "file" { + scan_grep_file( + wd, + &base, + &re, + multiline, + context, + &mut matches, + &mut file_summaries, + ); + } else { + for result in build_ignore_walker(&base, None) { + let entry = match result { + Ok(v) => v, + Err(_) => continue, + }; - let rel_to_base = match entry.path().strip_prefix(&base) { - Ok(value) => value, - Err(_) => continue, - }; - if let Some(globset) = file_globset.as_ref() { - if !globset.is_match(rel_to_base) { + let file_type = match entry.file_type() { + Some(file_type) => file_type, + None => continue, + }; + if !file_type.is_file() { continue; } - } - - let canonical = match fs::canonicalize(entry.path()) { - Ok(value) => value, - Err(_) => continue, - }; - if !canonical.starts_with(&wd) { - continue; - } - let bytes = match fs::read(entry.path()) { - Ok(value) => value, - Err(_) => continue, - }; - let text = String::from_utf8_lossy(&bytes); - let lines = split_lines_for_grep(&text); - let file_path = rel_to_workdir_str(&wd, entry.path()); - let mut file_match_count = 0usize; - let mut first_line: Option = None; - - if multiline { - let line_starts = build_line_starts(&text); - for mat in re.find_iter(&text) { - let line_no = byte_index_to_line(&line_starts, mat.start()); - let (before, after) = build_context_for_line(&lines, line_no, context); - let text = line_text_for_grep(&lines, line_no); - matches.push(GrepMatch { - path: file_path.clone(), - line: line_no, - text, - before, - after, - }); - file_match_count += 1; - if first_line.is_none() { - first_line = Some(line_no); - } - } - } else { - for (idx, line) in lines.iter().enumerate() { - if !re.is_match(line) { + let rel_to_base = match entry.path().strip_prefix(&base) { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(globset) = file_globset.as_ref() { + if !globset.is_match(rel_to_base) { continue; } - let line_no = idx + 1; - let (before, after) = build_context_for_line(&lines, line_no, context); - matches.push(GrepMatch { - path: file_path.clone(), - line: line_no, - text: trim_line_for_preview(line), - before, - after, - }); - file_match_count += 1; - if first_line.is_none() { - first_line = Some(line_no); - } } - } - if file_match_count > 0 { - file_summaries.insert( - file_path.clone(), - GrepFileSummary { - path: file_path, - count: file_match_count, - first_line: first_line, - }, + let canonical = match fs::canonicalize(entry.path()) { + Ok(value) => value, + Err(_) => continue, + }; + if !canonical.starts_with(wd) { + continue; + } + + scan_grep_file( + wd, + entry.path(), + &re, + multiline, + context, + &mut matches, + &mut file_summaries, ); } } @@ -3523,6 +4059,7 @@ fn fs_grep_sync( has_more, matches, files, + target_kind: Some(target_kind.to_string()), }) } @@ -3538,8 +4075,8 @@ pub async fn fs_grep( offset: Option, context: Option, multiline: Option, -) -> Result { - run_blocking("fs_grep", move || { +) -> Result { + run_blocking_fs("fs_grep", move || { fs_grep_sync( workdir, path, @@ -3979,8 +4516,9 @@ mod tests { ) .expect_err("non-base64 non-SVG data URL should be rejected"); assert!( - invalid_plain_data_url.contains("only supports SVG"), - "unexpected error: {invalid_plain_data_url}" + invalid_plain_data_url.message.contains("only supports SVG"), + "unexpected error: {}", + invalid_plain_data_url.message ); let inline_response = fs_read_image_source_sync( @@ -4030,7 +4568,12 @@ mod tests { let error = fs_read_workspace_image_sync(workdir.display().to_string(), path.to_string()) .expect_err("out-of-bounds workspace image path should fail"); - assert!(!error.trim().is_empty(), "expected error for {path:?}"); + assert!( + matches!(error.code, FsErrorCode::InvalidPath), + "expected invalid path error for {path:?}, got {:?}", + error.code + ); + assert!(!error.message.trim().is_empty(), "expected error for {path:?}"); } let _ = fs::remove_dir_all(workdir); @@ -4331,17 +4874,21 @@ mod tests { fs_read_editable_text_sync(workdir.display().to_string(), "src".to_string()) .expect_err("directory should fail"); assert!( - dir_error.contains("regular file"), - "unexpected error: {dir_error}" + matches!(dir_error.code, FsErrorCode::NotAFile), + "unexpected error: {:?}", + dir_error.code ); + assert_eq!(dir_error.entry_kind.as_deref(), Some("dir")); let utf8_error = fs_read_editable_text_sync(workdir.display().to_string(), "src/binary.bin".to_string()) .expect_err("invalid UTF-8 should fail"); assert!( - utf8_error.contains("UTF-8"), - "unexpected error: {utf8_error}" + matches!(utf8_error.code, FsErrorCode::NotUtf8), + "unexpected error: {:?}", + utf8_error.code ); + assert_eq!(utf8_error.path.as_deref(), Some("src/binary.bin")); for (path, expected) in [ ("src/notebook.ipynb", "Notebook"), @@ -4350,8 +4897,14 @@ mod tests { let error = fs_read_editable_text_sync(workdir.display().to_string(), path.to_string()) .expect_err("unsupported preview file should fail"); assert!( - error.contains(expected), - "unexpected error for {path}: {error}" + matches!(error.code, FsErrorCode::UnsupportedTarget), + "unexpected error for {path}: {:?}", + error.code + ); + assert!( + error.message.contains(expected), + "unexpected error for {path}: {}", + error.message ); } @@ -4361,14 +4914,24 @@ mod tests { ) .expect_err("large file should fail"); assert!( - large_error.contains("too large"), - "unexpected error: {large_error}" + matches!(large_error.code, FsErrorCode::TooLarge), + "unexpected error: {:?}", + large_error.code + ); + assert!( + large_error.message.contains("too large"), + "unexpected error: {}", + large_error.message ); for path in ["", "/tmp/liveagent-outside", "../outside"] { let error = fs_read_editable_text_sync(workdir.display().to_string(), path.to_string()) .expect_err("invalid path should fail"); - assert!(!error.trim().is_empty(), "expected error for {path:?}"); + assert!( + matches!(error.code, FsErrorCode::InvalidPath), + "expected invalid path error for {path:?}, got {:?}", + error.code + ); } let _ = fs::remove_dir_all(workdir); @@ -4388,7 +4951,7 @@ mod tests { for path in ["", "/tmp/liveagent-outside", "../outside", "src"] { let error = fs_create_dir_sync(workdir.display().to_string(), path.to_string()) .expect_err("invalid or existing target should fail"); - assert!(!error.trim().is_empty(), "expected error for {path:?}"); + assert!(!error.message.trim().is_empty(), "expected error for {path:?}"); } let _ = fs::remove_dir_all(workdir); @@ -4431,8 +4994,9 @@ mod tests { ) .expect_err("rename should reject overwrite"); assert!( - overwrite_error.contains("already exists"), - "unexpected error: {overwrite_error}" + overwrite_error.message.contains("already exists"), + "unexpected error: {}", + overwrite_error.message ); fs::create_dir_all(workdir.join("other")).expect("create other"); @@ -4443,8 +5007,9 @@ mod tests { ) .expect_err("rename should reject cross-directory move"); assert!( - move_error.contains("same directory"), - "unexpected error: {move_error}" + move_error.message.contains("same directory"), + "unexpected error: {}", + move_error.message ); let _ = fs::remove_dir_all(workdir); @@ -4544,7 +5109,11 @@ mod tests { Some(10), ) .expect_err("outside path should fail"); - assert!(!outside_error.trim().is_empty()); + assert!( + matches!(outside_error.code, FsErrorCode::InvalidPath), + "unexpected error: {:?}", + outside_error.code + ); let _ = fs::remove_dir_all(workdir); } @@ -4563,7 +5132,11 @@ mod tests { "outside_link/new-dir".to_string(), ) .expect_err("create dir should reject symlink parent outside workdir"); - assert!(!create_error.trim().is_empty()); + assert!( + matches!(create_error.code, FsErrorCode::OutOfBounds), + "unexpected error: {:?}", + create_error.code + ); fs::write(outside.join("old.txt"), "outside").expect("write outside"); let rename_error = fs_rename_sync( @@ -4572,7 +5145,11 @@ mod tests { "outside_link/new.txt".to_string(), ) .expect_err("rename should reject symlink parent outside workdir"); - assert!(!rename_error.trim().is_empty()); + assert!( + matches!(rename_error.code, FsErrorCode::OutOfBounds), + "unexpected error: {:?}", + rename_error.code + ); let _ = fs::remove_dir_all(workdir); let _ = fs::remove_dir_all(outside); @@ -4665,4 +5242,142 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + + #[test] + fn read_missing_file_reports_not_found_with_did_you_mean() { + let workdir = unique_test_workdir("not-found-suggestion"); + fs::create_dir_all(&workdir).expect("create workdir"); + fs::write(workdir.join("App.tsx"), "export {}").expect("write file"); + + let error = fs_read_text_sync( + workdir.display().to_string(), + "App.tx".to_string(), + None, + None, + None, + None, + None, + None, + ) + .expect_err("missing file should fail"); + + assert!( + matches!(error.code, FsErrorCode::NotFound), + "unexpected error: {:?}", + error.code + ); + assert_eq!(error.path.as_deref(), Some("App.tx")); + assert!( + error.did_you_mean.iter().any(|value| value == "App.tsx"), + "expected App.tsx suggestion, got {:?}", + error.did_you_mean + ); + + let _ = fs::remove_dir_all(workdir); + } + + #[cfg(unix)] + #[test] + fn path_status_reports_same_file_id_for_case_variant_queries() { + let workdir = unique_test_workdir("case-identity"); + fs::create_dir_all(&workdir).expect("create workdir"); + fs::write(workdir.join("App.tsx"), "export {}").expect("write file"); + + let exact = fs_path_status_sync(workdir.display().to_string(), "App.tsx".to_string()) + .expect("exact case status should succeed"); + assert!(exact.exists); + assert!(exact.file_id.is_some()); + + let lower = fs_path_status_sync(workdir.display().to_string(), "app.tsx".to_string()) + .expect("lowercase status should succeed"); + if !lower.exists { + return; + } + assert!(lower.file_id.is_some()); + assert_eq!(exact.file_id, lower.file_id); + + let _ = fs::remove_dir_all(workdir); + } + + #[test] + fn list_glob_grep_accept_file_targets() { + let workdir = unique_test_workdir("file-targets"); + fs::create_dir_all(workdir.join("src")).expect("create workdir"); + fs::write(workdir.join("src/app.ts"), "export const needle = 1;\n").expect("write file"); + fs::write(workdir.join("src/other.ts"), "export {}\n").expect("write other"); + + let list = fs_list_sync( + workdir.display().to_string(), + Some("src/app.ts".to_string()), + None, + None, + None, + ) + .expect("list should accept a file target"); + assert_eq!(list.target_kind.as_deref(), Some("file")); + assert_eq!(list.total, 1); + assert_eq!(list.entries.len(), 1); + assert_eq!(list.entries[0].path, "src/app.ts"); + assert_eq!(list.entries[0].kind, "file"); + + let glob = fs_glob_sync( + workdir.display().to_string(), + Some("src/app.ts".to_string()), + "*.ts".to_string(), + None, + None, + None, + ) + .expect("glob should accept a file target"); + assert_eq!(glob.target_kind.as_deref(), Some("file")); + assert!( + glob.paths.contains(&"src/app.ts".to_string()), + "unexpected glob paths: {:?}", + glob.paths + ); + + let grep = fs_grep_sync( + workdir.display().to_string(), + Some("src/app.ts".to_string()), + "needle".to_string(), + Some("*.md".to_string()), + None, + None, + None, + None, + None, + None, + ) + .expect("grep should accept a file target"); + assert_eq!(grep.target_kind.as_deref(), Some("file")); + assert_eq!(grep.match_count, 1); + assert_eq!(grep.matches.len(), 1); + assert_eq!(grep.matches[0].path, "src/app.ts"); + + let _ = fs::remove_dir_all(workdir); + } + + #[test] + fn write_response_file_id_matches_path_status() { + let workdir = unique_test_workdir("write-file-id"); + fs::create_dir_all(&workdir).expect("create workdir"); + + let write = fs_write_text_sync( + workdir.display().to_string(), + "notes.txt".to_string(), + "hello\n".to_string(), + "rewrite".to_string(), + None, + None, + ) + .expect("write should succeed"); + assert!(write.file_id.is_some()); + + let status = fs_path_status_sync(workdir.display().to_string(), "notes.txt".to_string()) + .expect("status should succeed"); + assert!(status.exists); + assert_eq!(write.file_id, status.file_id); + + let _ = fs::remove_dir_all(workdir); + } } diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs index bcf60604..25fa3a29 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs @@ -183,6 +183,25 @@ fn ensure_within_workdir_existing(workdir: &Path, target: &Path) -> Result bool { + if value.starts_with('/') || value.starts_with('\\') { + return true; + } + let bytes = value.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn resolve_absolute_cwd(value: &str) -> Result { + let invalid = + || ShellError::Other(format!("cwd does not exist or is not a directory: {value}")); + let canon = fs::canonicalize(value).map_err(|_| invalid())?; + let md = fs::metadata(&canon).map_err(|_| invalid())?; + if !md.is_dir() { + return Err(invalid()); + } + Ok(canon) +} + #[derive(Default)] struct StreamReadState { buf: Vec, @@ -607,6 +626,9 @@ pub(crate) fn run_shell_script( let actual_cwd = match cwd { None => wd.clone(), + Some(cwd_value) if is_absolute_cwd_input(cwd_value.trim()) => { + resolve_absolute_cwd(cwd_value.trim()).map_err(|e| e.to_string())? + } Some(cwd_rel) => match sanitize_rel_path_core(&cwd_rel).map_err(|e| e.to_string())? { None => wd.clone(), Some(rel) => { @@ -876,4 +898,73 @@ mod tests { "background stdio leak should not block until the background process exits" ); } + + #[cfg(unix)] + #[test] + fn run_shell_script_accepts_absolute_cwd_outside_workdir() { + let workdir = std::env::temp_dir().join(format!( + "liveagent-shell-abs-cwd-workdir-{}", + std::process::id() + )); + let external = std::env::temp_dir().join(format!( + "liveagent-shell-abs-cwd-external-{}", + std::process::id() + )); + let _ = fs::create_dir_all(&workdir); + let _ = fs::create_dir_all(&external); + let external_canonical = fs::canonicalize(&external).expect("canonical external dir"); + + let result = run_shell_script( + workdir.display().to_string(), + "pwd".to_string(), + Some(external.display().to_string()), + Some(30_000), + None, + None, + None, + ) + .expect("absolute cwd should run"); + + assert_eq!(result.exit_code, 0); + assert!( + result + .stdout + .contains(&external_canonical.display().to_string()), + "unexpected stdout: {}", + result.stdout + ); + + let _ = fs::remove_dir_all(&workdir); + let _ = fs::remove_dir_all(&external); + } + + #[test] + fn run_shell_script_rejects_missing_absolute_cwd() { + let workdir = std::env::temp_dir().join(format!( + "liveagent-shell-abs-cwd-missing-{}", + std::process::id() + )); + let _ = fs::create_dir_all(&workdir); + let missing = std::env::temp_dir() + .join(format!("liveagent-missing-cwd-{}", std::process::id())) + .join("nope"); + + let error = run_shell_script( + workdir.display().to_string(), + "echo hi".to_string(), + Some(missing.display().to_string()), + Some(30_000), + None, + None, + None, + ) + .expect_err("missing absolute cwd should fail"); + + assert!( + error.contains("cwd does not exist or is not a directory"), + "unexpected error: {error}" + ); + + let _ = fs::remove_dir_all(&workdir); + } } diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index 98e35ad2..cc6e199c 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -417,6 +417,7 @@ pub async fn handle_fs_list( }) .await .map_err(|e| format!("gateway fs list join failed: {e}"))? + .map_err(|e| e.message) .map(|response| { let has_path = response.path.is_some(); proto::FsListResponse { @@ -447,6 +448,7 @@ pub async fn handle_fs_read_editable_text( }) .await .map_err(|e| format!("gateway fs read editable text join failed: {e}"))? + .map_err(|e| e.message) .map(|response| proto::FsReadEditableTextResponse { path: response.path, content: response.content, @@ -465,6 +467,7 @@ pub async fn handle_fs_read_workspace_image( }) .await .map_err(|e| format!("gateway fs read workspace preview join failed: {e}"))? + .map_err(|e| e.message) .and_then(|response| { Ok(proto::FsReadWorkspaceImageResponse { path: response.path, @@ -507,6 +510,7 @@ pub async fn handle_fs_write_text( }) .await .map_err(|e| format!("gateway fs write text join failed: {e}"))? + .map_err(|e| e.message) .map(|response| proto::FsWriteTextResponse { path: response.path, mode: response.mode, @@ -524,6 +528,7 @@ pub async fn handle_fs_create_dir( tauri::async_runtime::spawn_blocking(move || fs_create_dir_sync(request.workdir, request.path)) .await .map_err(|e| format!("gateway fs create dir join failed: {e}"))? + .map_err(|e| e.message) .map(|response| proto::FsCreateDirResponse { path: response.path, kind: response.kind, @@ -538,6 +543,7 @@ pub async fn handle_fs_rename( }) .await .map_err(|e| format!("gateway fs rename join failed: {e}"))? + .map_err(|e| e.message) .map(|response| proto::FsRenameResponse { from_path: response.from_path, path: response.path, @@ -551,6 +557,7 @@ pub async fn handle_fs_delete( tauri::async_runtime::spawn_blocking(move || fs_delete_sync(request.workdir, request.path)) .await .map_err(|e| format!("gateway fs delete join failed: {e}"))? + .map_err(|e| e.message) .map(|response| proto::FsDeleteResponse { path: response.path, kind: response.kind, diff --git a/crates/agent-gui/src/components/chat/MentionComposer.tsx b/crates/agent-gui/src/components/chat/MentionComposer.tsx index ed30d527..384c75b3 100644 --- a/crates/agent-gui/src/components/chat/MentionComposer.tsx +++ b/crates/agent-gui/src/components/chat/MentionComposer.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; import { type ClipboardEvent, @@ -29,6 +28,7 @@ import { formatMarkdownReferenceDestination, } from "../../lib/chat/messages/mentionReferences"; import { cn } from "../../lib/shared/utils"; +import { invokeFs } from "../../lib/tools/fsBackend"; import { ClipboardPaste, Copy, ScanText, Scissors } from "../icons"; import { getFileTypeIcon, getFileTypeIconSvg } from "./fileTypeIcons"; import { mentionChipClassName } from "./mentionChipStyles"; @@ -1800,7 +1800,7 @@ export const MentionComposer = memo( return; } - invoke("fs_mention_list", { + invokeFs("fs_mention_list", { workdir: normalizedWorkdir, max_results: MENTION_INDEX_MAX_RESULTS, query: ctx.query, diff --git a/crates/agent-gui/src/components/project-tools/ProjectFileTreePanel.tsx b/crates/agent-gui/src/components/project-tools/ProjectFileTreePanel.tsx index 8fe98794..b1226344 100644 --- a/crates/agent-gui/src/components/project-tools/ProjectFileTreePanel.tsx +++ b/crates/agent-gui/src/components/project-tools/ProjectFileTreePanel.tsx @@ -1,8 +1,8 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocale } from "../../i18n"; import type { RightDockFileTreeState, RightDockFileTreeStatePatch } from "../../lib/settings"; import { cn } from "../../lib/shared/utils"; +import { invokeFs } from "../../lib/tools/fsBackend"; import { getFileTypeIcon } from "../chat/fileTypeIcons"; import { Check, @@ -257,7 +257,7 @@ export function ProjectFileTreePanel(props: { if (!shouldLoad) return; try { - const response = await invoke("fs_list", { + const response = await invokeFs("fs_list", { workdir: cwd, path: path || undefined, depth: 1, @@ -425,7 +425,7 @@ export function ProjectFileTreePanel(props: { const timer = window.setTimeout(() => { setSearchLoading(true); setSearchError(null); - void invoke("fs_mention_list", { + void invokeFs("fs_mention_list", { workdir: cwd, query, max_results: SEARCH_MAX_RESULTS, @@ -559,7 +559,7 @@ export function ProjectFileTreePanel(props: { targetNode?.kind === "dir" ? targetNode.path : dirname(targetNode?.path ?? targetPath); if (pendingAction === "file") { const nextPath = joinPath(targetDir, name); - await invoke("fs_write_text", { + await invokeFs("fs_write_text", { workdir: cwd, path: nextPath, content: "", @@ -575,7 +575,7 @@ export function ProjectFileTreePanel(props: { }); } else if (pendingAction === "folder") { const nextPath = joinPath(targetDir, name); - await invoke("fs_create_dir", { + await invokeFs("fs_create_dir", { workdir: cwd, path: nextPath, }); @@ -596,7 +596,7 @@ export function ProjectFileTreePanel(props: { } else if (pendingAction === "rename" && targetPath) { const parent = dirname(targetPath); const nextPath = joinPath(parent, name); - await invoke("fs_rename", { + await invokeFs("fs_rename", { workdir: cwd, from_path: targetPath, to_path: nextPath, @@ -674,7 +674,7 @@ export function ProjectFileTreePanel(props: { setBusyAction(true); setActionError(null); try { - await invoke("fs_delete", { workdir: cwd, path: targetPath }); + await invokeFs("fs_delete", { workdir: cwd, path: targetPath }); const nextExpanded = state.expanded.filter( (item) => item !== targetPath && !item.startsWith(`${targetPath}/`), ); @@ -726,7 +726,7 @@ export function ProjectFileTreePanel(props: { if (!targetPath) return; setActionError(null); try { - await invoke("fs_open_workspace_path", { + await invokeFs("fs_open_workspace_path", { workdir: cwd, path: targetPath, mode: "reveal", @@ -745,7 +745,7 @@ export function ProjectFileTreePanel(props: { if (!targetPath) return; setActionError(null); try { - await invoke("fs_open_workspace_path", { + await invokeFs("fs_open_workspace_path", { workdir: cwd, path: targetPath, mode: "open", diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx index 905f3c0f..b6f5a7a0 100644 --- a/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceCodeEditorOverlay.tsx @@ -1,4 +1,3 @@ -import { invoke } from "@tauri-apps/api/core"; import * as monaco from "monaco-editor"; import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; import CssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"; @@ -16,6 +15,7 @@ import { } from "react"; import { useLocale } from "../../i18n"; import { cn } from "../../lib/shared/utils"; +import { invokeFs, isFsBackendError } from "../../lib/tools/fsBackend"; import type { IconComponent } from "../icons"; import { AlertTriangle, @@ -231,6 +231,7 @@ function languageForPath(path: string) { } function isVersionConflict(error: unknown) { + if (isFsBackendError(error) && error.code === "stale_file") return true; const message = error instanceof Error ? error.message : String(error ?? ""); return message.includes("File changed since the last full Read"); } @@ -391,7 +392,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp const contentToSave = tab.content; updateTab(tabKey, (current) => ({ ...current, status: "saving", error: null })); try { - const response = await invoke("fs_write_text", { + const response = await invokeFs("fs_write_text", { workdir: tab.workdir, path: tab.path, content: contentToSave, @@ -444,7 +445,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp ]); setGlobalError(null); try { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: request.workdir, path: request.path, }); @@ -484,7 +485,7 @@ export function WorkspaceCodeEditorOverlay(props: WorkspaceCodeEditorOverlayProp setOpeningPaths((current) => [...current.filter((item) => item !== tab.path), tab.path]); setGlobalError(null); try { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: tab.workdir, path: tab.path, }); diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx index 40b541b2..b1415073 100644 --- a/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceFilePreviewOverlay.tsx @@ -1,9 +1,9 @@ -import { invoke } from "@tauri-apps/api/core"; import { renderAsync } from "docx-preview"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { read, utils } from "xlsx"; import { useLocale } from "../../i18n"; import { cn } from "../../lib/shared/utils"; +import { invokeFs } from "../../lib/tools/fsBackend"; import { type FileTypeIconComponent, getFileTypeIcon } from "../chat/fileTypeIcons"; import { AlertTriangle, @@ -407,7 +407,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr replacePreview(null); } try { - const response = await invoke("fs_read_workspace_image", { + const response = await invokeFs("fs_read_workspace_image", { workdir: request.workdir, path: request.path, }); @@ -486,7 +486,7 @@ export function WorkspaceFilePreviewOverlay(props: WorkspaceFilePreviewOverlayPr const path = activePath || activePreviewRequest.path; try { setError(null); - await invoke("fs_open_workspace_path", { + await invokeFs("fs_open_workspace_path", { workdir: activePreviewRequest.workdir, path, mode: "open", diff --git a/crates/agent-gui/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx b/crates/agent-gui/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx index 4986ad42..fabc58ce 100644 --- a/crates/agent-gui/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx +++ b/crates/agent-gui/src/components/workspace-editor/WorkspaceImagePreviewOverlay.tsx @@ -1,7 +1,7 @@ -import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocale } from "../../i18n"; import { cn } from "../../lib/shared/utils"; +import { invokeFs } from "../../lib/tools/fsBackend"; import { AlertTriangle, ImageIcon, ImageOff, Loader2, RefreshCw, X } from "../icons"; import { MacOsTitleBarSpacer } from "../MacOsTitleBarSpacer"; @@ -91,7 +91,7 @@ export function WorkspaceImagePreviewOverlay(props: WorkspaceImagePreviewOverlay setError(null); setImage(null); try { - const response = await invoke("fs_read_workspace_image", { + const response = await invokeFs("fs_read_workspace_image", { workdir: request.workdir, path: request.path, }); diff --git a/crates/agent-gui/src/lib/chat/context/requestContextSanitizer.ts b/crates/agent-gui/src/lib/chat/context/requestContextSanitizer.ts index 8f926899..c7966a4a 100644 --- a/crates/agent-gui/src/lib/chat/context/requestContextSanitizer.ts +++ b/crates/agent-gui/src/lib/chat/context/requestContextSanitizer.ts @@ -15,7 +15,6 @@ function normalizeDisplayImageItem(value: unknown): DisplayImageItemDetails | nu absolutePath, relativePath, displayPath, - pathRef, sourceType, renderMode, sourceUrl, @@ -33,7 +32,6 @@ function normalizeDisplayImageItem(value: unknown): DisplayImageItemDetails | nu ...(typeof absolutePath === "string" ? { absolutePath } : {}), ...(typeof relativePath === "string" ? { relativePath } : {}), ...(typeof displayPath === "string" ? { displayPath } : {}), - ...(typeof pathRef === "string" ? { pathRef } : {}), ...(typeof sourceType === "string" ? { sourceType: sourceType as DisplayImageItemDetails["sourceType"] } : {}), diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index ac19edfc..b9f7a266 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -172,20 +172,18 @@ export function buildToolsSuffix( ].join("\n"), ); - if (hasFileTool || has("Bash")) { - const subject = - hasFileTool && has("Bash") ? "File tools and Bash" : hasFileTool ? "File tools" : "Bash"; + if (hasFileTool || hasAny("Bash", "ManagedProcess", "SSHManager", "McpManager", "Agent")) { sections.push( [ "## Workspace & Paths", `- Workspace root (sandbox): \`${workdir}\``, - `- ${subject} ${subject === "Bash" ? "accepts" : "accept"} the path you see: workspace-relative, absolute, ~/..., file://, pathRef values, or skill:///... paths.`, - "- To target the workspace root for optional `path` / `cwd` values, omit the argument.", - "- Use `/` as the separator everywhere, including Glob and Grep patterns. Windows `\\` is auto-normalized.", - "- For enabled Skill files, prefer skill:///... or a pathRef returned by SkillsManager/List/Glob/Grep/Read.", - ] - .filter((line): line is string => Boolean(line)) - .join("\n"), + "- Preferred form: workspace-relative paths exactly as tools return them, e.g. `src/App.tsx`. To target the root itself, omit the optional `path` / `cwd` argument.", + "- Files inside an enabled Skill: use `skill:///...` exactly as returned by SkillsManager or file tools.", + "- Absolute paths, `~/...`, and `file://` URLs are also accepted and auto-normalized; never construct one when a returned path is available.", + "- Write, Edit, and Delete operate only inside the workspace or enabled Skills. Bash `cwd` may point outside the workspace when the task requires it.", + "- Use `/` as the separator everywhere, including Glob and Grep patterns; Windows `\\` is auto-normalized.", + '- On a path error, follow its guidance: reuse a "Did you mean" candidate verbatim, or locate the file with Glob/Grep first, then retry with the returned path.', + ].join("\n"), ); } @@ -199,7 +197,7 @@ export function buildToolsSuffix( if (canWrite) { lines.push( "- Existing files: Read the full file before Write or Edit; stale writes are rejected, so re-Read if needed.", - "- New files: call Write with a file path that includes the filename and the full content; parent directories are created automatically.", + "- New files: call Write with a file path that includes the filename and the full content; parent directories are created automatically. There is no separate create-directory step — to create a directory, Write a file inside it.", ); } if (has("Read")) { @@ -225,7 +223,9 @@ export function buildToolsSuffix( ); } if (has("Delete")) { - lines.push("- For workspace or Skill deletion, use Delete with the exact path or pathRef."); + lines.push( + "- For workspace or Skill deletion, use Delete with the exact path returned by List/Glob/Grep/Read.", + ); } if (hasAny("Grep", "Glob", "List")) { lines.push( @@ -234,7 +234,7 @@ export function buildToolsSuffix( } if (has("SkillsManager") && hasReadFamily) { lines.push( - "- For files inside a Skill, call file tools with a path like `skill:///references/guide.md` or a pathRef returned by a tool.", + "- For files inside a Skill, call file tools with a path like `skill:///references/guide.md`.", ); } if (has("Bash")) { @@ -273,7 +273,7 @@ export function buildToolsSuffix( "- Do not embed images with Markdown syntax like ![alt](path), HTML , file:// URLs, or local relative image paths in your final text.", has("SkillsManager") ? [ - "- Local image: pass `path` exactly as seen, including workspace-relative, absolute, pathRef, or skill:// paths. Remote image: pass `url` / `urls` or `source` / `sources` directly — do not download unless the user asked to save it locally.", + "- Local image: pass `path` exactly as seen, including workspace-relative, absolute, or skill:// paths. Remote image: pass `url` / `urls` or `source` / `sources` directly — do not download unless the user asked to save it locally.", "- Do not use Bash, open, xdg-open, Markdown, or HTML to display Skill images.", ].join("\n") : "- Local image: pass `path` exactly as seen. Remote image: pass `url` / `urls` or `source` / `sources` directly — do not download unless the user asked to save it locally.", diff --git a/crates/agent-gui/src/lib/skills/index.ts b/crates/agent-gui/src/lib/skills/index.ts index 75b1259d..58763d7e 100644 --- a/crates/agent-gui/src/lib/skills/index.ts +++ b/crates/agent-gui/src/lib/skills/index.ts @@ -697,7 +697,7 @@ export function buildSkillsSystemPrompt(params: { }); return [ - "The following Skills are enabled by the user. Skill files are exposed to file tools through skill:///... paths and pathRef values returned by tools.", + "The following Skills are enabled by the user. Skill files are exposed to file tools through skill:///... paths.", "", "Usage Rules (aligned with Claude Code behavior):", "- Skills with metadata use progressive disclosure; their full contents are not automatically injected into context.", @@ -707,7 +707,7 @@ export function buildSkillsSystemPrompt(params: { "- SkillsManager.path uses the skillFile below and may point to SKILL.md, skill.md, skill.json, or README.md.", '- For files referenced inside an enabled Skill, use file tools with skill:///... paths, for example Read(path="skill:///references/guide.md"), List, Glob, Grep, Write, Edit, or Delete.', "- You may update files inside enabled Skills when the user asks you to optimize or maintain them. Create, install, search/install from ClawHub, validate, package, or delete user Skills through SkillsManager actions.", - "- You may pass absolute local paths, ~/..., file://, skill://, or pathRef values to file tools; prefer skill:///... for enabled Skill files.", + "- Absolute local paths, ~/..., and file:// forms are auto-normalized by file tools; prefer skill:///... for enabled Skill files.", "- If Skill content contains shell snippets that read Skill files with cat/ls/find/grep, route those reads through Read/List/Glob/Grep instead of Bash.", "- Do not guess a Skill's exact instructions or script paths before reading the Skill file.", "- Relative paths inside a Skill (scripts/, references/, assets/, and so on) are resolved relative to baseDir.", diff --git a/crates/agent-gui/src/lib/ssh/scan.ts b/crates/agent-gui/src/lib/ssh/scan.ts index 48808ac6..9efbdae5 100644 --- a/crates/agent-gui/src/lib/ssh/scan.ts +++ b/crates/agent-gui/src/lib/ssh/scan.ts @@ -1,5 +1,5 @@ -import { invoke } from "@tauri-apps/api/core"; import type { SshHostConfig } from "../settings"; +import { invokeFs } from "../tools/fsBackend"; type FsRoot = { id: string; @@ -211,7 +211,7 @@ function toHomeRelativePath(homePath: string, path: string) { } async function readHomeFile(homePath: string, relativePath: string) { - const response = await invoke("fs_read_editable_text", { + const response = await invokeFs("fs_read_editable_text", { workdir: homePath, path: relativePath, }); @@ -227,7 +227,7 @@ async function readOptionalHomeFile(homePath: string, relativePath: string) { } async function findHomePath() { - const response = await invoke("fs_roots"); + const response = await invokeFs("fs_roots", {}); const home = response.roots.find((root) => root.kind === "home") ?? response.roots.find((root) => root.id === "home"); @@ -236,7 +236,7 @@ async function findHomePath() { async function listSshDirectory(homePath: string) { try { - const response = await invoke("fs_list", { + const response = await invokeFs("fs_list", { workdir: homePath, path: SSH_DIR_PATH, depth: 1, diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index bb2cc8b2..0ee2fe3a 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -1,4 +1,5 @@ import type { ToolCall, ToolResultMessage } from "@earendil-works/pi-ai"; +import { homeDir } from "@tauri-apps/api/path"; import { listSubagentIdentities, listSubagentRuns, @@ -232,6 +233,8 @@ type BuildBuiltinBaseToolRegistryParams = { onTunnelsChanged?: (change: TunnelManagerChange) => void | Promise; }; +const resolveHomeDir = () => homeDir(); + async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryParams) { let currentMcpSettings = params.mcpSettings; const baseBundles: BuiltinToolBundle[] = [ @@ -241,6 +244,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP skillsRootEnabled: params.skillsEnabled, skillsRootDir: params.skillsRootDir, skillAccessPolicy: params.skillAccessPolicy, + resolveHomeDir, }), createShellTools({ workdir: params.workdir, @@ -250,6 +254,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP skillsRootDir: params.skillsRootDir, skillAccessPolicy: params.skillAccessPolicy, managedProcessEnabled: params.runtimeScope === "chat", + resolveHomeDir, }), ...(params.skillsEnabled ? [ @@ -273,6 +278,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP } : undefined, runtimeScope: params.runtimeScope, + resolveHomeDir, }), createCustomSystemTools({ selectedToolIds: params.selectedSystemToolIds, @@ -300,6 +306,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP projectPathKey: params.tunnelProjectPathKey, hosts: params.sshHosts, associatedHostIds: params.associatedSshHostIds, + resolveHomeDir, onSshSessionsChanged: params.onSshSessionsChanged, }), ...(params.runtimeScope === "chat" @@ -372,6 +379,7 @@ export async function buildBuiltinToolRegistry( runtime: params.delegateRuntime.runtime, runtimePlatform: params.runtimePlatform, workdir: params.workdir, + resolveHomeDir, parentConversationId: params.delegateRuntime.conversationId, sessionId: params.delegateRuntime.sessionId, agentTemplates: params.delegateRuntime.agentTemplates, diff --git a/crates/agent-gui/src/lib/tools/builtinTypes.ts b/crates/agent-gui/src/lib/tools/builtinTypes.ts index 52f5fe98..25a9cae5 100644 --- a/crates/agent-gui/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gui/src/lib/tools/builtinTypes.ts @@ -73,14 +73,14 @@ export function createBuiltinMetadataMap( } export type FsEntryKind = "file" | "dir"; -export type PathScope = "workspace" | "skill" | "external" | "temp" | "artifact"; +export type PathScope = "workspace" | "skill" | "external"; export type ResolvedPathResultDetails = { scope?: PathScope; absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; }; export type ReadTextResultDetails = { @@ -90,7 +90,7 @@ export type ReadTextResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; startLine: number; numLines: number; totalLines: number; @@ -108,7 +108,7 @@ export type ReadImageResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; mimeType: string; sizeBytes: number; mtimeMs: number; @@ -122,7 +122,7 @@ export type DisplayImageItemDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; sourceType?: "path" | "url" | "base64" | "auto"; renderMode?: "inline" | "proxy"; sourceUrl?: string; @@ -150,7 +150,7 @@ export type ReadPdfResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; pageStart: number; numPages: number; totalPages: number; @@ -167,7 +167,7 @@ export type ReadNotebookResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; cellStart: number; numCells: number; totalCells: number; @@ -184,7 +184,7 @@ export type ReadDocumentResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; truncated: boolean; mimeType?: string; sizeBytes?: number; @@ -331,7 +331,7 @@ export type WriteResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; mode: "rewrite"; existedBefore: boolean; bytesWritten: number; @@ -348,7 +348,7 @@ export type EditResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; replacements: number; replaceAll: boolean; expectedReplacements?: number; @@ -366,7 +366,7 @@ export type DeleteResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; targetKind: string; }; @@ -382,7 +382,8 @@ export type ListResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; + targetKind?: FsEntryKind; depth: number; offset: number; maxResults: number; @@ -398,7 +399,8 @@ export type GlobResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; + targetKind?: FsEntryKind; pattern: string; sortBy: "path"; offset: number; @@ -429,7 +431,8 @@ export type GrepResultDetails = { absolutePath?: string; relativePath?: string; displayPath?: string; - pathRef?: string; + fileId?: string; + targetKind?: FsEntryKind; pattern: string; filePattern?: string; ignoreCase: boolean; diff --git a/crates/agent-gui/src/lib/tools/delegate/createDelegateTools.ts b/crates/agent-gui/src/lib/tools/delegate/createDelegateTools.ts index ecf8862d..882b4d71 100644 --- a/crates/agent-gui/src/lib/tools/delegate/createDelegateTools.ts +++ b/crates/agent-gui/src/lib/tools/delegate/createDelegateTools.ts @@ -118,8 +118,12 @@ import { async function resolveAgentAllowedOutputPaths(params: { agents: DelegateAgentInput[]; workdir: string; + resolveHomeDir?: () => Promise; }): Promise { - const resolver = new ToolPathResolver({ workdir: params.workdir }); + const resolver = new ToolPathResolver({ + workdir: params.workdir, + resolveHomeDir: params.resolveHomeDir, + }); const resolvedAgents: DelegateAgentInput[] = []; for (const agent of params.agents) { const allowedOutputPaths: string[] = []; @@ -133,7 +137,7 @@ async function resolveAgentAllowedOutputPaths(params: { }); if (resolved.scope !== "workspace") { throw new Error( - `Agent.allowed_output_paths must resolve inside the workspace. Received ${JSON.stringify(input)}.`, + `Agent.allowed_output_paths entries must be workspace-relative paths (worktree agents share the workspace layout). "${input}" does not resolve inside the workspace.`, ); } const relativePath = resolved.relativePath ?? ""; @@ -152,6 +156,7 @@ export function createDelegateTools(params: { runtime: DelegateRuntime; runtimePlatform?: RuntimePlatform; workdir: string; + resolveHomeDir?: () => Promise; parentConversationId?: string; sessionId?: string; agentTemplates?: DelegateAgentTemplate[]; @@ -288,6 +293,7 @@ export function createDelegateTools(params: { const rawAgents = await resolveAgentAllowedOutputPaths({ agents: normalizeDelegateAgents(args), workdir: params.workdir, + resolveHomeDir: params.resolveHomeDir, }); const agents: DelegateAgentInput[] = []; const canonicalizationNotes: string[] = []; diff --git a/crates/agent-gui/src/lib/tools/delegate/input.ts b/crates/agent-gui/src/lib/tools/delegate/input.ts index 3ed2ff91..065721d4 100644 --- a/crates/agent-gui/src/lib/tools/delegate/input.ts +++ b/crates/agent-gui/src/lib/tools/delegate/input.ts @@ -29,28 +29,6 @@ function normalizeBoolean(value: unknown, fallback: boolean) { return typeof value === "boolean" ? value : fallback; } -export function normalizeRelativePath(value: string) { - const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\//, ""); - if ( - !normalized || - /^[a-zA-Z]:\//.test(normalized) || - normalized.startsWith("/") || - normalized.startsWith("//") || - normalized === "." || - normalized === ".." - ) { - return ""; - } - - const segments: string[] = []; - for (const segment of normalized.split("/")) { - if (!segment || segment === ".") continue; - if (segment === ".." || segment.includes(":")) return ""; - segments.push(segment); - } - return segments.join("/"); -} - function normalizePathList(value: unknown): string[] { const rawItems = Array.isArray(value) ? value diff --git a/crates/agent-gui/src/lib/tools/delegate/schema.ts b/crates/agent-gui/src/lib/tools/delegate/schema.ts index ebde067b..d4b045bf 100644 --- a/crates/agent-gui/src/lib/tools/delegate/schema.ts +++ b/crates/agent-gui/src/lib/tools/delegate/schema.ts @@ -79,7 +79,7 @@ export const DELEGATE_AGENT_PARAMETERS = Type.Object( allowed_output_paths: Type.Optional( Type.Array(Type.String(), { description: - "Workspace files/directories allowed when apply_policy=explicit. Accepts workspace-relative paths, absolute paths inside the workspace, file://, or workspace:pathRef values.", + "Workspace files/directories allowed when apply_policy=explicit. Accepts workspace-relative paths exactly as returned by file tools, absolute paths inside the workspace, or file:// values.", }), ), resume: Type.Optional( diff --git a/crates/agent-gui/src/lib/tools/delegate/worktree.ts b/crates/agent-gui/src/lib/tools/delegate/worktree.ts index cb5dfeda..c871f6c8 100644 --- a/crates/agent-gui/src/lib/tools/delegate/worktree.ts +++ b/crates/agent-gui/src/lib/tools/delegate/worktree.ts @@ -1,7 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import type { DelegateAgentItemResultDetails } from "../builtinTypes"; -import { normalizeRelativePath } from "./input"; import type { DelegateAgentInput, DelegateWorktreeApplyResult, @@ -12,6 +11,29 @@ import type { } from "./types"; import { sanitizeLabelPart } from "./utils"; +// Cleans paths coming out of git status/porcelain output. Malformed entries +// are dropped, not surfaced — git plumbing noise is not a tool-input error. +function normalizeGitStatusPath(value: string) { + const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\//, ""); + if ( + !normalized || + /^[a-zA-Z]:\//.test(normalized) || + normalized.startsWith("/") || + normalized === "." || + normalized === ".." + ) { + return ""; + } + + const segments: string[] = []; + for (const segment of normalized.split("/")) { + if (!segment || segment === ".") continue; + if (segment === ".." || segment.includes(":")) return ""; + segments.push(segment); + } + return segments.join("/"); +} + function pathMatchesAllowedOutput(path: string, allowedPath: string) { if (!allowedPath || allowedPath === ".") return true; if (allowedPath.includes("*") || allowedPath.includes("?") || allowedPath.includes("[")) { @@ -99,11 +121,11 @@ function parseWorktreeStatusPath(line: string) { const trimmed = line.trim(); if (!trimmed) return ""; if (trimmed.startsWith("?? ")) { - return normalizeRelativePath(decodeGitQuotedPath(trimmed.slice(3))); + return normalizeGitStatusPath(decodeGitQuotedPath(trimmed.slice(3))); } const body = line.length > 3 ? line.slice(3).trim() : trimmed.slice(2).trim(); const renamedPath = body.includes(" -> ") ? (body.split(" -> ").pop() ?? body) : body; - return normalizeRelativePath(decodeGitQuotedPath(renamedPath)); + return normalizeGitStatusPath(decodeGitQuotedPath(renamedPath)); } function removeParentDirectoryPaths(paths: string[]) { @@ -121,7 +143,7 @@ function shouldIgnoreChangedPath(path: string) { function collectWorktreeChangedPaths(status: DelegateWorktreeStatus) { const paths = new Set(); for (const file of status.untracked_files ?? []) { - const normalized = normalizeRelativePath(decodeGitQuotedPath(file)); + const normalized = normalizeGitStatusPath(decodeGitQuotedPath(file)); if (normalized && !shouldIgnoreChangedPath(normalized)) paths.add(normalized); } for (const line of (status.status || "").split(/\r?\n/g)) { @@ -132,7 +154,7 @@ function collectWorktreeChangedPaths(status: DelegateWorktreeStatus) { } function isLikelyCommunicationArtifact(path: string) { - const normalized = normalizeRelativePath(path); + const normalized = normalizeGitStatusPath(path); if (!normalized) return false; const parts = normalized.split("/"); const basename = (parts[parts.length - 1] ?? "").toLowerCase(); diff --git a/crates/agent-gui/src/lib/tools/fileToolState.ts b/crates/agent-gui/src/lib/tools/fileToolState.ts index 845f0438..b7037d25 100644 --- a/crates/agent-gui/src/lib/tools/fileToolState.ts +++ b/crates/agent-gui/src/lib/tools/fileToolState.ts @@ -7,8 +7,8 @@ import type { type SnapshotPathKey = { path: string; + fileId?: string; absolutePath?: string; - pathRef?: string; }; type FileReadSnapshot = @@ -51,14 +51,14 @@ type FileSnapshotBucket = { function buildBucketKey(path: SnapshotPathKey | string) { if (typeof path === "string") return path; - return path.absolutePath || path.pathRef || path.path; + return path.fileId || path.absolutePath || path.path; } function snapshotPathKey(details: SnapshotPathKey): SnapshotPathKey { return { path: details.path, + fileId: details.fileId, absolutePath: details.absolutePath, - pathRef: details.pathRef, }; } @@ -159,11 +159,13 @@ export function createFileToolState() { bucket.byRangeKey.set(buildNotebookRangeKey(snapshot), snapshot); } - function recordTextMutation(params: SnapshotPathKey & { - mtimeMs: number; - contentHash: string; - totalLines: number; - }) { + function recordTextMutation( + params: SnapshotPathKey & { + mtimeMs: number; + contentHash: string; + totalLines: number; + }, + ) { const snapshot: Extract = { ...snapshotPathKey(params), kind: "text", diff --git a/crates/agent-gui/src/lib/tools/fsBackend.ts b/crates/agent-gui/src/lib/tools/fsBackend.ts new file mode 100644 index 00000000..a932fc3a --- /dev/null +++ b/crates/agent-gui/src/lib/tools/fsBackend.ts @@ -0,0 +1,84 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type FsErrorCode = + | "invalid_workdir" + | "invalid_path" + | "out_of_bounds" + | "not_found" + | "not_a_directory" + | "not_a_file" + | "unsupported_target" + | "requires_full_read" + | "stale_file" + | "edit_no_match" + | "edit_ambiguous" + | "edit_count_mismatch" + | "too_large" + | "not_utf8" + | "regex_invalid" + | "glob_invalid" + | "io" + | "other"; + +export class FsBackendError extends Error { + readonly code: FsErrorCode; + readonly path?: string; + readonly workdir?: string; + readonly entryKind?: string; + readonly didYouMean: string[]; + + constructor(params: { + code: FsErrorCode; + message: string; + path?: string; + workdir?: string; + entryKind?: string; + didYouMean?: string[]; + }) { + super(params.message); + this.name = "FsBackendError"; + this.code = params.code; + this.path = params.path; + this.workdir = params.workdir; + this.entryKind = params.entryKind; + this.didYouMean = params.didYouMean ?? []; + } +} + +export function isFsBackendError(error: unknown): error is FsBackendError { + return error instanceof FsBackendError; +} + +function toFsBackendError(error: unknown): FsBackendError { + if (error instanceof FsBackendError) return error; + if (error && typeof error === "object" && !Array.isArray(error)) { + const record = error as Record; + if (typeof record.code === "string" && typeof record.message === "string") { + return new FsBackendError({ + code: record.code as FsErrorCode, + message: record.message, + path: typeof record.path === "string" ? record.path : undefined, + workdir: typeof record.workdir === "string" ? record.workdir : undefined, + entryKind: typeof record.entryKind === "string" ? record.entryKind : undefined, + didYouMean: Array.isArray(record.didYouMean) + ? record.didYouMean.filter((value): value is string => typeof value === "string") + : undefined, + }); + } + } + const message = + typeof error === "string" + ? error + : error instanceof Error + ? error.message + : String(error ?? "Unknown filesystem error"); + return new FsBackendError({ code: "other", message }); +} + +export async function invokeFs(command: string, args: Record): Promise { + try { + return await invoke(command, args); + } catch (error) { + throw toFsBackendError(error); + } +} diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index d803e79d..9a790689 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -27,11 +27,9 @@ import { type WriteResultDetails, } from "./builtinTypes"; import type { FileToolState } from "./fileToolState"; -import { - formatResolvedTarget, - type ResolvedPath, - ToolPathResolver, -} from "./pathUtils"; +import { invokeFs, isFsBackendError } from "./fsBackend"; +import { buildFsErrorText, buildRequiresFullReadText, buildWriteDirectoryText } from "./pathErrors"; +import { formatResolvedTarget, type ResolvedPath, ToolPathResolver } from "./pathUtils"; import type { SkillAccessPolicy } from "./skillAccessPolicy"; type ToolOk = { @@ -96,6 +94,7 @@ type ReadCommandResponse = { mimeType?: string | null; data?: string | null; sizeBytes?: number | null; + fileId?: string | null; }; type WriteCommandResponse = { @@ -106,6 +105,7 @@ type WriteCommandResponse = { mtimeMs: number; contentHash: string; totalLines: number; + fileId?: string | null; }; type PathStatusCommandResponse = { @@ -114,6 +114,7 @@ type PathStatusCommandResponse = { kind?: "file" | "dir" | "symlink" | "other" | null; sizeBytes?: number | null; mtimeMs?: number | null; + fileId?: string | null; }; type EditCommandResponse = { @@ -123,6 +124,7 @@ type EditCommandResponse = { mtimeMs: number; contentHash: string; totalLines: number; + fileId?: string | null; }; type DeleteCommandResponse = { @@ -132,6 +134,7 @@ type DeleteCommandResponse = { type ListCommandResponse = { path?: string | null; + targetKind?: "file" | "dir" | null; depth: number; offset: number; maxResults: number; @@ -142,6 +145,7 @@ type ListCommandResponse = { type GlobCommandResponse = { path?: string | null; + targetKind?: "file" | "dir" | null; pattern: string; sortBy: "path"; offset: number; @@ -153,6 +157,7 @@ type GlobCommandResponse = { type GrepCommandResponse = { path?: string | null; + targetKind?: "file" | "dir" | null; pattern: string; filePattern?: string | null; ignoreCase: boolean; @@ -188,43 +193,28 @@ function previewSnippet(input: string, maxChars = 500) { return `${text.slice(0, maxChars)}\n...(${text.length} chars total)...`; } -function childDisplayPath(parent: string, fileName: string) { - const base = parent && parent !== "." ? parent.replace(/\/+$/g, "") : ""; - return base ? `${base}/${fileName}` : fileName; -} - function formatLineWindow(startLine: number, numLines: number, totalLines: number) { if (totalLines === 0 || numLines === 0) return "empty"; const endLine = startLine + numLines - 1; return `${startLine}-${endLine} / ${totalLines}`; } -function splitRelativeFilePath(path: string) { - const parts = path.split("/"); - const fileName = parts.pop()?.trim() ?? ""; - const parentPath = parts.join("/"); - return { - parentPath: parentPath || undefined, - fileName, - }; -} - -function pathDetails(resolved: ResolvedPath) { +function pathDetails(resolved: ResolvedPath, fileId?: string | null) { return { path: resolved.displayPath, scope: resolved.scope, absolutePath: resolved.absolutePath, relativePath: resolved.relativePath, displayPath: resolved.displayPath, - pathRef: resolved.pathRef, + fileId: fileId ?? undefined, }; } -function statePathKey(resolved: ResolvedPath) { +function statePathKey(resolved: ResolvedPath, fileId?: string | null) { return { path: resolved.displayPath, + fileId: fileId ?? undefined, absolutePath: resolved.absolutePath, - pathRef: resolved.pathRef, }; } @@ -232,28 +222,19 @@ function backendPath(resolved: ResolvedPath) { return resolved.relativePath || undefined; } -function buildPathToolRuntimeError(params: { - toolName: string; - resolved: ResolvedPath; - error: unknown; -}) { - return [ - `${params.toolName} failed for ${formatResolvedTarget(params.resolved)}: ${asErrorMessage(params.error)}`, - "Use the exact path, pathRef, or displayPath returned by List/Glob/Grep/Read.", - "Do not use Bash for workspace or Skill file operations.", - ].join(" "); -} - -async function invokePathFileCommand(params: { +async function invokeFsToolCommand(params: { toolName: string; resolved: ResolvedPath; command: string; args: Record; }) { try { - return await invoke(params.command, params.args); + return await invokeFs(params.command, params.args); } catch (error) { - throw new Error(buildPathToolRuntimeError({ ...params, error })); + if (isFsBackendError(error)) { + throw new Error(buildFsErrorText(params.toolName, params.resolved, error)); + } + throw error; } } @@ -272,6 +253,7 @@ function buildToolErrorResult(toolCall: ToolCall, text: string): ToolResultMessa export function createFsTools(params: { workdir: string; fileState: FileToolState; + resolveHomeDir?: () => Promise; skillsRootEnabled?: boolean; skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; @@ -298,6 +280,7 @@ export function createFsTools(params: { const pathResolver = new ToolPathResolver({ workdir, + resolveHomeDir: params.resolveHomeDir, skillsRootEnabled: allowSkillsRoot, skillsRootDir: cachedSkillsRootDir, skillAccessPolicy, @@ -305,18 +288,36 @@ export function createFsTools(params: { }); const writePreflightSafePathByToolCallId = new Map(); - function buildWriteRequiresFullReadMessage(resolved: ResolvedPath) { - return `Write requires a full-file Read first for existing files: ${formatResolvedTarget(resolved)}. Retry with Read using the same path before rewriting. Do not use Bash for workspace or Skill file operations.`; + // Loop breaker: models sometimes retry a failing call with identical + // arguments. Track consecutive identical failures and escalate the error + // text so the retry loop is explicitly interrupted. + let lastFailureKey = ""; + let lastFailureCount = 0; + let lastFailureToolCallId = ""; + + function annotateRepeatedFailure(toolCall: ToolCall, text: string) { + const args = + toolCall.arguments && typeof toolCall.arguments === "object" + ? (toolCall.arguments as Record) + : {}; + const path = typeof args.path === "string" ? args.path : ""; + const key = `${toolCall.name}\0${path}\0${text}`; + if (key !== lastFailureKey) { + lastFailureKey = key; + lastFailureCount = 1; + lastFailureToolCallId = toolCall.id; + } else if (toolCall.id !== lastFailureToolCallId) { + lastFailureCount += 1; + lastFailureToolCallId = toolCall.id; + } + if (lastFailureCount < 2) return text; + return `${text} This exact call has now failed ${lastFailureCount} times in a row. Do not retry with the same arguments — apply the correction above, or take a different approach.`; } - function buildWriteDirectoryPathMessage(resolved: ResolvedPath) { - const directoryPath = formatResolvedTarget(resolved); - const examplePath = childDisplayPath(directoryPath, "file.txt"); - return [ - `Write.path points to a directory, not a file: ${directoryPath}.`, - `Retry Write with the intended filename appended to that directory, for example path="${examplePath}" if file.txt is the file you mean.`, - "Write creates missing parent directories, but it does not choose a filename from a directory path.", - ].join(" "); + function noteToolCallSucceeded() { + lastFailureKey = ""; + lastFailureCount = 0; + lastFailureToolCallId = ""; } type NormalizedWriteArgs = { @@ -356,13 +357,13 @@ export function createFsTools(params: { return { resolved, path: backendPath(resolved) }; } - async function readPathStatus(resolved: ResolvedPath, path: string) { - return invokePathFileCommand({ - toolName: "Write", + async function readPathStatus(toolName: string, resolved: ResolvedPath, path: string) { + return invokeFsToolCommand({ + toolName, resolved, command: "fs_path_status", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, }, }); @@ -392,11 +393,11 @@ export function createFsTools(params: { const toolRead: Tool = { name: "Read", description: - "Read a text, image, PDF, notebook, Word, Excel/spreadsheet, or archive file from the workspace or an enabled Skill. Pass the path exactly as you see it: workspace-relative, absolute, ~/..., file://, workspace:pathRef, or skill:///... are accepted. For text files, use start_line (1-based) and limit for pagination. For PDFs, use page_start and page_limit. For notebooks (.ipynb), use cell_start and cell_limit. Word/Excel/archive files return a best-effort text preview or entry listing. Returns version metadata and may return an `unchanged` stub when content has not changed since the previous read. Use Image instead when the user asks to show, view, render, or display an image in the chat UI. Do not use Markdown image syntax or HTML img tags to display files.", + "Read a text, image, PDF, notebook, Word, Excel/spreadsheet, or archive file from the workspace or an enabled Skill. Pass the path exactly as returned by other tools. For text files, use start_line (1-based) and limit for pagination. For PDFs, use page_start and page_limit. For notebooks (.ipynb), use cell_start and cell_limit. Word/Excel/archive files return a best-effort text preview or entry listing. Returns version metadata and may return an `unchanged` stub when content has not changed since the previous read. Use Image instead when the user asks to show, view, render, or display an image in the chat UI. Do not use Markdown image syntax or HTML img tags to display files.", parameters: strictToolParameters({ path: Type.String({ description: - 'Required file path. Accepts workspace-relative paths such as "src/App.tsx", absolute paths inside the workspace, "~/..." or file:// paths, pathRef values returned by tools, and skill:///... paths.', + 'Required file path. Prefer the workspace-relative form such as "src/App.tsx" or the skill:// form, exactly as returned by other tools; absolute, ~/..., and file:// forms are auto-normalized.', }), start_line: Type.Optional( Type.Number({ @@ -440,19 +441,18 @@ export function createFsTools(params: { const toolImage: Tool = { name: "Image", description: - "Display one or more images in the chat UI. This is the only supported way for assistant-side image rendering. Call it whenever the user asks to show, view, render, preview, open, or display images, and whenever another tool saves, downloads, screenshots, generates, or returns an image path/URL that the user should see. Supports workspace paths, enabled Skill paths, external absolute paths, http/https URLs, base64 data URLs, and SVG images (file, data URL, or raw XML). Pass local paths exactly as you see them, including absolute paths or pathRef values. For remote images, pass url/urls or source/sources directly instead of downloading the image first, unless the user explicitly asks to save it locally. Do not embed images in final text with Markdown image syntax, HTML img tags, file:// URLs, or local relative image paths.", + "Display one or more images in the chat UI. This is the only supported way for assistant-side image rendering. Call it whenever the user asks to show, view, render, preview, open, or display images, and whenever another tool saves, downloads, screenshots, generates, or returns an image path/URL that the user should see. Supports workspace paths, enabled Skill paths, external absolute paths, http/https URLs, base64 data URLs, and SVG images (file, data URL, or raw XML). Pass local paths exactly as you see them. For remote images, pass url/urls or source/sources directly instead of downloading the image first, unless the user explicitly asks to save it locally. Do not embed images in final text with Markdown image syntax, HTML img tags, file:// URLs, or local relative image paths.", parameters: strictToolParameters({ path: Type.Optional( Type.String({ description: - 'Single local image path. Accepts workspace-relative paths, absolute paths, ~/..., file://, pathRef values, and skill:///... paths.', + "Single local image path, exactly as returned by other tools. Workspace-relative, skill://, absolute, ~/..., and file:// forms are accepted; external absolute paths are allowed for images.", }), ), paths: Type.Optional( Type.Array( Type.String({ - description: - "Local image path. Same accepted forms as `path`.", + description: "Local image path. Same accepted forms as `path`.", }), { minItems: 1, @@ -508,14 +508,13 @@ export function createFsTools(params: { source: Type.Optional( Type.String({ description: - "Single generic image source. Accepted forms: local path/pathRef, http/https URL, data URL, raw base64, or raw SVG XML. Prefer this for mixed or unknown source types.", + "Single generic image source. Accepted forms: local path, http/https URL, data URL, raw base64, or raw SVG XML. Prefer this for mixed or unknown source types.", }), ), sources: Type.Optional( Type.Array( Type.String({ - description: - "Generic image source. Same accepted forms as `source`.", + description: "Generic image source. Same accepted forms as `source`.", }), { minItems: 1, @@ -535,7 +534,7 @@ export function createFsTools(params: { parameters: strictToolParameters({ path: Type.String({ description: - 'Required file path. Accepts workspace-relative paths, absolute paths inside the workspace, pathRef values, and skill:///... paths. External paths outside workspace/enabled Skills are rejected for writes.', + "Required file path. Prefer the workspace-relative or skill:// form returned by other tools; absolute and ~/... forms are auto-normalized. Must resolve inside the workspace or an enabled Skill.", }), content: Type.String({ description: "Entire text content to write" }), mode: Type.Optional( @@ -554,7 +553,7 @@ export function createFsTools(params: { parameters: strictToolParameters({ path: Type.String({ description: - 'Required file path. Accepts workspace-relative paths, absolute paths inside the workspace, pathRef values, and skill:///... paths. External paths outside workspace/enabled Skills are rejected for edits.', + "Required file path. Prefer the workspace-relative or skill:// form returned by other tools; absolute and ~/... forms are auto-normalized. Must resolve inside the workspace or an enabled Skill.", }), old_string: Type.String({ description: "Exact text to replace" }), new_string: Type.String({ description: "Replacement text" }), @@ -581,7 +580,7 @@ export function createFsTools(params: { parameters: strictToolParameters({ path: Type.String({ description: - 'Required path to the file or directory. Accepts workspace-relative paths, absolute paths inside the workspace, pathRef values, and skill:///... paths. External paths outside workspace/enabled Skills are rejected for deletion.', + "Required path to the file or directory. Prefer the workspace-relative or skill:// form returned by other tools. Must resolve inside the workspace or an enabled Skill.", }), }), }; @@ -594,7 +593,7 @@ export function createFsTools(params: { path: Type.Optional( Type.String({ description: - 'Optional directory. Omit to list the workspace root. Accepts workspace-relative paths, absolute paths, pathRef values, and skill:///... paths.', + "Optional directory to list; a file path returns that single entry. Omit to list the workspace root. Prefer the workspace-relative or skill:// form returned by other tools.", }), ), depth: Type.Optional( @@ -624,7 +623,7 @@ export function createFsTools(params: { path: Type.Optional( Type.String({ description: - 'Optional sub-directory to search under. Omit to search from the workspace root. Accepts workspace-relative paths, absolute paths, pathRef values, and skill:///... paths.', + "Optional base directory to search under. Omit to search from the workspace root. Prefer the workspace-relative or skill:// form returned by other tools.", }), ), offset: Type.Optional( @@ -653,7 +652,7 @@ export function createFsTools(params: { path: Type.Optional( Type.String({ description: - 'Optional sub-directory to search under. Omit to search from the workspace root. Accepts workspace-relative paths, absolute paths, pathRef values, and skill:///... paths.', + "Optional directory or single file to search. Omit to search from the workspace root. Prefer the workspace-relative or skill:// form returned by other tools.", }), ), file_pattern: Type.Optional( @@ -703,26 +702,8 @@ export function createFsTools(params: { ]; const allowedArgumentsByToolName: Record = { - Read: [ - "path", - "start_line", - "limit", - "page_start", - "page_limit", - "cell_start", - "cell_limit", - ], - Image: [ - "path", - "paths", - "url", - "urls", - "base64", - "base64s", - "mimeType", - "source", - "sources", - ], + Read: ["path", "start_line", "limit", "page_start", "page_limit", "cell_start", "cell_limit"], + Image: ["path", "paths", "url", "urls", "base64", "base64s", "mimeType", "source", "sources"], Write: ["path", "content", "mode"], Edit: ["path", "old_string", "new_string", "expected_replacements", "replace_all"], Delete: ["path"], @@ -758,12 +739,12 @@ export function createFsTools(params: { const cell_start = typeof args?.cell_start === "number" ? args.cell_start : undefined; const cell_limit = typeof args?.cell_limit === "number" ? args.cell_limit : undefined; - const res = await invokePathFileCommand({ + const res = await invokeFsToolCommand({ toolName: "Read", resolved, command: "fs_read_text", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, start_line, limit, @@ -777,14 +758,14 @@ export function createFsTools(params: { if (res.kind === "image") { const baseDetails: ReadImageResultDetails = { kind: "read_image", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), mimeType: String(res.mimeType || "application/octet-stream"), sizeBytes: typeof res.sizeBytes === "number" ? res.sizeBytes : 0, mtimeMs: res.mtimeMs, contentHash: res.contentHash, reusedExisting: false, }; - const previous = fileState.getExactImageRead(statePathKey(resolved)); + const previous = fileState.getExactImageRead(statePathKey(resolved, res.fileId)); const reusedExisting = previous?.kind === "image" && previous.mtimeMs === baseDetails.mtimeMs && @@ -833,7 +814,7 @@ export function createFsTools(params: { const totalPages = typeof res.totalPages === "number" ? res.totalPages : 0; const baseDetails: ReadPdfResultDetails = { kind: "read_pdf", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), pageStart, numPages, totalPages, @@ -842,14 +823,11 @@ export function createFsTools(params: { contentHash: res.contentHash, reusedExisting: false, }; - const previous = fileState.getExactPdfRead( - statePathKey(resolved), - { - pageStart, - numPages, - totalPages, - }, - ); + const previous = fileState.getExactPdfRead(statePathKey(resolved, res.fileId), { + pageStart, + numPages, + totalPages, + }); const reusedExisting = previous?.kind === "pdf" && previous.mtimeMs === baseDetails.mtimeMs && @@ -895,7 +873,7 @@ export function createFsTools(params: { const totalCells = typeof res.totalCells === "number" ? res.totalCells : 0; const baseDetails: ReadNotebookResultDetails = { kind: "read_notebook", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), cellStart, numCells, totalCells, @@ -904,14 +882,11 @@ export function createFsTools(params: { contentHash: res.contentHash, reusedExisting: false, }; - const previous = fileState.getExactNotebookRead( - statePathKey(resolved), - { - cellStart, - numCells, - totalCells, - }, - ); + const previous = fileState.getExactNotebookRead(statePathKey(resolved, res.fileId), { + cellStart, + numCells, + totalCells, + }); const reusedExisting = previous?.kind === "notebook" && previous.mtimeMs === baseDetails.mtimeMs && @@ -965,7 +940,7 @@ export function createFsTools(params: { : res.kind === "spreadsheet" ? "read_spreadsheet" : "read_archive", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), truncated: Boolean(res.truncated), mimeType: typeof res.mimeType === "string" ? res.mimeType : undefined, sizeBytes: typeof res.sizeBytes === "number" ? res.sizeBytes : undefined, @@ -1000,7 +975,7 @@ export function createFsTools(params: { const totalLines = typeof res.totalLines === "number" ? res.totalLines : 0; const baseDetails: ReadTextResultDetails = { kind: "read_text", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), startLine, numLines, totalLines, @@ -1010,14 +985,11 @@ export function createFsTools(params: { contentHash: res.contentHash, reusedExisting: false, }; - const previous = fileState.getExactTextRead( - statePathKey(resolved), - { - startLine, - numLines, - totalLines, - }, - ); + const previous = fileState.getExactTextRead(statePathKey(resolved, res.fileId), { + startLine, + numLines, + totalLines, + }); const reusedExisting = previous?.kind === "text" && previous.mtimeMs === baseDetails.mtimeMs && @@ -1057,11 +1029,18 @@ export function createFsTools(params: { }; } + function buildEditFullReadLimitMessage(resolved: ResolvedPath, totalLines: number) { + const target = formatResolvedTarget(resolved); + return `Edit requires a full-file Read first: ${target} has ${totalLines} lines, which exceeds the automatic full-read limit (${AUTO_EDIT_FULL_READ_MAX_LINES}). Call Read with path="${target}" and limit=${totalLines}, then retry Edit with the same path.`; + } + async function primeFullTextSnapshotForEdit(params: { resolved: ResolvedPath; + path: string; signal?: AbortSignal; }) { - const key = statePathKey(params.resolved); + const status = await readPathStatus("Edit", params.resolved, params.path); + const key = statePathKey(params.resolved, status.fileId); const existingSnapshot = fileState.getLatestFullText(key); if (existingSnapshot) { return { snapshot: existingSnapshot, autoRead: false }; @@ -1071,16 +1050,14 @@ export function createFsTools(params: { let readLimit = AUTO_EDIT_FULL_READ_MAX_LINES; if (latest?.kind === "text" && latest.totalLines > 0) { if (latest.totalLines > AUTO_EDIT_FULL_READ_MAX_LINES) { - throw new Error( - `Edit requires a full-file Read first. ${formatResolvedTarget(params.resolved)} has ${latest.totalLines} lines, which exceeds the automatic full-read limit (${AUTO_EDIT_FULL_READ_MAX_LINES}). Retry with Read using the same path and a limit that covers the full file before editing. Do not use Bash for workspace or Skill file operations.`, - ); + throw new Error(buildEditFullReadLimitMessage(params.resolved, latest.totalLines)); } readLimit = latest.totalLines; } await execRead( { - path: params.resolved.pathRef, + path: params.resolved.displayPath, limit: readLimit, }, params.signal, @@ -1093,14 +1070,10 @@ export function createFsTools(params: { const latestAfterRead = fileState.getLatest(key); if (latestAfterRead?.kind === "text" && latestAfterRead.isPartialView) { - throw new Error( - `Edit requires a full-file Read first. ${formatResolvedTarget(params.resolved)} has ${latestAfterRead.totalLines} lines, which exceeds the automatic full-read limit (${AUTO_EDIT_FULL_READ_MAX_LINES}). Retry with Read using the same path and a limit that covers the full file before editing. Do not use Bash for workspace or Skill file operations.`, - ); + throw new Error(buildEditFullReadLimitMessage(params.resolved, latestAfterRead.totalLines)); } - throw new Error( - `Edit requires a full-file text Read first for ${formatResolvedTarget(params.resolved)}. Retry with Read using the same path before editing. Do not use Bash for workspace or Skill file operations.`, - ); + throw new Error(buildRequiresFullReadText("Edit", params.resolved)); } function normalizeRequiredImageSource(input: unknown, label: string) { @@ -1154,7 +1127,7 @@ export function createFsTools(params: { : (backendPath(resolved) ?? resolved.absolutePath), sourceType: "path", resolvedPath: resolved, - workdir: resolved.scope === "external" ? workdir : resolved.workdir, + workdir: resolved.scope === "external" ? workdir : resolved.root, }; } @@ -1286,13 +1259,16 @@ export function createFsTools(params: { let res: ReadCommandResponse; try { - res = await invoke("fs_read_image_source", { + res = await invokeFs("fs_read_image_source", { workdir: input.workdir ?? workdir, source: input.source, source_type: input.sourceType, mime_type: input.mimeType, } as any); } catch (error) { + if (isFsBackendError(error) && input.resolvedPath) { + throw new Error(buildFsErrorText("Image", input.resolvedPath, error)); + } throw new Error( [ `Image failed for ${input.resolvedPath ? formatResolvedTarget(input.resolvedPath) : `source="${input.source}"`}: ${asErrorMessage(error)}`, @@ -1311,7 +1287,8 @@ export function createFsTools(params: { const mimeType = String(res.mimeType || "application/octet-stream"); const displayPath = - input.resolvedPath?.displayPath || (typeof res.path === "string" && res.path ? res.path : input.source); + input.resolvedPath?.displayPath || + (typeof res.path === "string" && res.path ? res.path : input.source); const sizeBytes = typeof res.sizeBytes === "number" ? res.sizeBytes : 0; return { content: { @@ -1327,7 +1304,7 @@ export function createFsTools(params: { absolutePath: input.resolvedPath.absolutePath, relativePath: input.resolvedPath.relativePath, displayPath: input.resolvedPath.displayPath, - pathRef: input.resolvedPath.pathRef, + fileId: res.fileId ?? undefined, } : {}), sourceType: input.sourceType, @@ -1420,37 +1397,31 @@ export function createFsTools(params: { if (!path) throw new Error("Write.path must identify a file"); const content = writeArgs.content; - const latest = fileState.getLatest(statePathKey(resolved)); + const status = await readPathStatus("Write", resolved, path); + const key = statePathKey(resolved, status.fileId); + const latest = fileState.getLatest(key); if (latest?.kind === "text" && latest.isPartialView) { - throw new Error(buildWriteRequiresFullReadMessage(resolved)); + throw new Error(buildRequiresFullReadText("Write", resolved, latest.totalLines)); } - const fullSnapshot = fileState.getLatestFullText(statePathKey(resolved)); + const fullSnapshot = fileState.getLatestFullText(key); - let res: WriteCommandResponse; - try { - res = await invokePathFileCommand({ - toolName: "Write", - resolved, - command: "fs_write_text", - args: { - workdir: resolved.workdir, - path, - content, - mode: "rewrite", - expected_mtime_ms: fullSnapshot?.mtimeMs, - expected_content_hash: fullSnapshot?.contentHash, - }, - }); - } catch (error) { - if (/Cannot write to a directory path/i.test(asErrorMessage(error))) { - throw new Error(buildWriteDirectoryPathMessage(resolved)); - } - throw error; - } + const res = await invokeFsToolCommand({ + toolName: "Write", + resolved, + command: "fs_write_text", + args: { + workdir: resolved.root, + path, + content, + mode: "rewrite", + expected_mtime_ms: fullSnapshot?.mtimeMs, + expected_content_hash: fullSnapshot?.contentHash, + }, + }); const details: WriteResultDetails = { kind: "write", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), mode: "rewrite", existedBefore: Boolean(res.existedBefore), bytesWritten: res.bytesWritten, @@ -1460,7 +1431,7 @@ export function createFsTools(params: { preview: previewSnippet(content), }; fileState.recordTextMutation({ - ...statePathKey(resolved), + ...statePathKey(resolved, res.fileId), mtimeMs: res.mtimeMs, contentHash: res.contentHash, totalLines: res.totalLines, @@ -1495,16 +1466,25 @@ export function createFsTools(params: { toolCall: completeWriteToolCallForPreflight(toolCall, { content: writeArgs.content, }), - toolResult: buildToolErrorResult(toolCall, "Write.path must identify a file"), + toolResult: buildToolErrorResult( + toolCall, + annotateRepeatedFailure(toolCall, "Write.path must identify a file"), + ), }; } - const preflightPathKey = `${resolved.workdir}\0${path}`; + const preflightPathKey = `${resolved.root}\0${path}`; if (writePreflightSafePathByToolCallId.get(toolCall.id) === preflightPathKey) { return null; } - const key = statePathKey(resolved); + const status = await readPathStatus("Write", resolved, path); + if (!status.exists) { + writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); + return null; + } + + const key = statePathKey(resolved, status.fileId); const latest = fileState.getLatest(key); if (latest?.kind === "text" && latest.isPartialView) { return { @@ -1512,7 +1492,13 @@ export function createFsTools(params: { path: resolved.displayPath, content: writeArgs.content, }), - toolResult: buildToolErrorResult(toolCall, buildWriteRequiresFullReadMessage(resolved)), + toolResult: buildToolErrorResult( + toolCall, + annotateRepeatedFailure( + toolCall, + buildRequiresFullReadText("Write", resolved, latest.totalLines), + ), + ), }; } @@ -1521,12 +1507,6 @@ export function createFsTools(params: { return null; } - const status = await readPathStatus(resolved, path); - if (!status.exists) { - writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); - return null; - } - const completedToolCall = completeWriteToolCallForPreflight(toolCall, { path: resolved.displayPath, content: writeArgs.content, @@ -1537,14 +1517,17 @@ export function createFsTools(params: { toolCall: completedToolCall, toolResult: buildToolErrorResult( toolCall, - buildWriteDirectoryPathMessage(resolved), + annotateRepeatedFailure(toolCall, buildWriteDirectoryText(resolved)), ), }; } return { toolCall: completedToolCall, - toolResult: buildToolErrorResult(toolCall, buildWriteRequiresFullReadMessage(resolved)), + toolResult: buildToolErrorResult( + toolCall, + annotateRepeatedFailure(toolCall, buildRequiresFullReadText("Write", resolved)), + ), }; } @@ -1570,15 +1553,16 @@ export function createFsTools(params: { const { snapshot, autoRead } = await primeFullTextSnapshotForEdit({ resolved, + path, signal, }); - const res = await invokePathFileCommand({ + const res = await invokeFsToolCommand({ toolName: "Edit", resolved, command: "fs_edit_text", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, old_string, new_string, @@ -1591,7 +1575,7 @@ export function createFsTools(params: { const details: EditResultDetails = { kind: "edit", - ...pathDetails(resolved), + ...pathDetails(resolved, res.fileId), replacements: res.replacements, replaceAll: res.replaceAll, expectedReplacements: expected_replacements, @@ -1602,7 +1586,7 @@ export function createFsTools(params: { newPreview: previewSnippet(new_string), }; fileState.recordTextMutation({ - ...statePathKey(resolved), + ...statePathKey(resolved, res.fileId), mtimeMs: res.mtimeMs, contentHash: res.contentHash, totalLines: res.totalLines, @@ -1633,12 +1617,12 @@ export function createFsTools(params: { }); const path = backendPath(resolved); if (!path) throw new Error("Delete.path must identify a file or directory"); - const res = await invokePathFileCommand({ + const res = await invokeFsToolCommand({ toolName: "Delete", resolved, command: "fs_delete", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, }, }); @@ -1672,12 +1656,12 @@ export function createFsTools(params: { const offset = typeof args?.offset === "number" ? args.offset : undefined; const max_results = typeof args?.max_results === "number" ? args.max_results : undefined; - const res = await invokePathFileCommand({ + const res = await invokeFsToolCommand({ toolName: "List", resolved, command: "fs_list", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, depth, offset, @@ -1688,6 +1672,7 @@ export function createFsTools(params: { const details: ListResultDetails = { kind: "list", ...pathDetails(resolved), + targetKind: res.targetKind ?? undefined, depth: res.depth, offset: res.offset, maxResults: res.maxResults, @@ -1707,6 +1692,7 @@ export function createFsTools(params: { type: "text", text: `List: ${formatResolvedTarget(resolved)}\n` + + (res.targetKind === "file" ? "note=path is a file; listed that single entry\n" : "") + `offset=${details.offset} total=${details.total}\n` + `${lines.join("\n")}${suffix}`, }, @@ -1734,12 +1720,12 @@ export function createFsTools(params: { throw new Error("Glob.sort_by only supports path"); } - const res = await invokePathFileCommand({ + const res = await invokeFsToolCommand({ toolName: "Glob", resolved, command: "fs_glob", args: { - workdir: resolved.workdir, + workdir: resolved.root, path, pattern, offset, @@ -1751,6 +1737,7 @@ export function createFsTools(params: { const details: GlobResultDetails = { kind: "glob", ...pathDetails(resolved), + targetKind: res.targetKind ?? undefined, pattern: res.pattern, sortBy: res.sortBy, offset: res.offset, @@ -1768,6 +1755,9 @@ export function createFsTools(params: { text: `Glob: ${pattern}\n` + `${formatResolvedTarget(resolved)} offset=${details.offset} total=${details.total}\n` + + (res.targetKind === "file" + ? "note=path is a file; searched its parent directory\n" + : "") + `${res.paths.join("\n")}${suffix}`, }, ], @@ -1781,12 +1771,12 @@ export function createFsTools(params: { const pattern = typeof args?.pattern === "string" ? args.pattern : ""; if (!pattern.trim()) throw new Error("Grep.pattern is required"); - let resolved = await pathResolver.resolvePath(args?.path, { + const resolved = await pathResolver.resolvePath(args?.path, { label: "Grep.path", intent: "search", required: false, }); - let path = backendPath(resolved); + const path = backendPath(resolved); const file_pattern = typeof args?.file_pattern === "string" ? args.file_pattern.trim() : undefined; const ignore_case = typeof args?.ignore_case === "boolean" ? args.ignore_case : true; @@ -1797,69 +1787,28 @@ export function createFsTools(params: { const context = typeof args?.context === "number" ? args.context : undefined; const multiline = args?.multiline === true; - let effectiveFilePattern = file_pattern; - let correctedFilePath: string | undefined; - let res: GrepCommandResponse; - try { - res = await invokePathFileCommand({ - toolName: "Grep", - resolved, - command: "fs_grep", - args: { - workdir: resolved.workdir, - path, - pattern, - file_pattern: effectiveFilePattern, - ignore_case, - output_mode, - head_limit, - offset, - context, - multiline, - }, - }); - } catch (error) { - if (!path || !/Grep\.path must be a directory/.test(asErrorMessage(error))) { - throw error; - } - const split = splitRelativeFilePath(path); - if (!split.fileName) { - throw error; - } - correctedFilePath = formatResolvedTarget(resolved); - const parentRef = - resolved.scope === "skill" - ? `skill:${split.parentPath ?? ""}` - : `workspace:${split.parentPath ?? ""}`; - resolved = await pathResolver.resolvePath(parentRef, { - label: "Grep.path", - intent: "search", - required: false, - }); - path = backendPath(resolved); - effectiveFilePattern = split.fileName; - res = await invokePathFileCommand({ - toolName: "Grep", - resolved, - command: "fs_grep", - args: { - workdir: resolved.workdir, - path, - pattern, - file_pattern: effectiveFilePattern, - ignore_case, - output_mode, - head_limit, - offset, - context, - multiline, - }, - }); - } + const res = await invokeFsToolCommand({ + toolName: "Grep", + resolved, + command: "fs_grep", + args: { + workdir: resolved.root, + path, + pattern, + file_pattern, + ignore_case, + output_mode, + head_limit, + offset, + context, + multiline, + }, + }); const details: GrepResultDetails = { kind: "grep", ...pathDetails(resolved), + targetKind: res.targetKind ?? undefined, pattern: res.pattern, filePattern: res.filePattern ?? undefined, ignoreCase: res.ignoreCase, @@ -1899,9 +1848,7 @@ export function createFsTools(params: { text: `Grep: ${pattern}\n` + `${formatResolvedTarget(resolved)}\n` + - (correctedFilePath - ? `autoCorrectedPath=${correctedFilePath} file_pattern=${effectiveFilePattern}\n` - : "") + + (res.targetKind === "file" ? "note=path is a file; searched that single file\n" : "") + `mode=${res.outputMode} matches=${res.matchCount} files=${res.fileCount}\n` + `${body}${suffix}`, }, @@ -1920,9 +1867,7 @@ export function createFsTools(params: { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, - content: [ - { type: "text", text: "Working directory is not configured; cannot run tools." }, - ], + content: [{ type: "text", text: "Working directory is not configured; cannot run tools." }], details: {}, isError: true, timestamp: now, @@ -1972,6 +1917,7 @@ export function createFsTools(params: { }; } + noteToolCallSucceeded(); return { role: "toolResult", toolCallId: toolCall.id, @@ -1986,7 +1932,7 @@ export function createFsTools(params: { role: "toolResult", toolCallId: toolCall.id, toolName: toolCall.name, - content: [{ type: "text", text: asErrorMessage(err) }], + content: [{ type: "text", text: annotateRepeatedFailure(toolCall, asErrorMessage(err)) }], details: {}, isError: true, timestamp: now, @@ -2007,8 +1953,12 @@ export function createFsTools(params: { } } catch (err) { return { - toolCall: toolCall.name === "Write" ? completeWriteToolCallForPreflight(toolCall) : toolCall, - toolResult: buildToolErrorResult(toolCall, asErrorMessage(err)), + toolCall: + toolCall.name === "Write" ? completeWriteToolCallForPreflight(toolCall) : toolCall, + toolResult: buildToolErrorResult( + toolCall, + annotateRepeatedFailure(toolCall, asErrorMessage(err)), + ), }; } } diff --git a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts index 4c3664c3..406fda0b 100644 --- a/crates/agent-gui/src/lib/tools/mcpManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/mcpManagerTools.ts @@ -110,7 +110,7 @@ const MCP_SERVER_PARAMETERS = Type.Object( cwd: Type.Optional( Type.String({ description: - "Optional local working directory for stdio servers. Accepts workspace-relative paths, absolute paths, ~/..., file://, or workspace:pathRef values.", + "Optional local working directory for stdio servers. Accepts workspace-relative paths exactly as returned by file tools, absolute paths, ~/..., or file:// values.", }), ), env: Type.Optional(MCP_STRING_MAP_SCHEMA), @@ -133,7 +133,7 @@ const MCP_SERVER_PATCH_PARAMETERS = Type.Object( cwd: Type.Optional( Type.String({ description: - "Optional local working directory for stdio servers. Accepts workspace-relative paths, absolute paths, ~/..., file://, or workspace:pathRef values.", + "Optional local working directory for stdio servers. Accepts workspace-relative paths exactly as returned by file tools, absolute paths, ~/..., or file:// values.", }), ), env: Type.Optional(MCP_STRING_MAP_SCHEMA), @@ -620,12 +620,17 @@ export function createMcpManagerTools(params: { getMcpSettings: () => McpSettings; setMcpSettings?: (next: McpSettings) => void; runtimeScope: SystemToolRuntimeScope; + resolveHomeDir?: () => Promise; }): BuiltinToolBundle { + const pathResolver = new ToolPathResolver({ + workdir: params.workdir, + resolveHomeDir: params.resolveHomeDir, + }); + async function resolveMcpCwd(cwd: string | undefined, label: string) { const input = typeof cwd === "string" ? cwd.trim() : ""; if (!input) return undefined; - const resolver = new ToolPathResolver({ workdir: params.workdir }); - const resolved = await resolver.resolvePath(input, { + const resolved = await pathResolver.resolvePath(input, { label, intent: "cwd", required: true, diff --git a/crates/agent-gui/src/lib/tools/pathErrors.ts b/crates/agent-gui/src/lib/tools/pathErrors.ts new file mode 100644 index 00000000..bbc49edd --- /dev/null +++ b/crates/agent-gui/src/lib/tools/pathErrors.ts @@ -0,0 +1,80 @@ +import type { FsBackendError } from "./fsBackend"; +import { formatResolvedTarget, type ResolvedPath } from "./pathUtils"; + +function displayCandidate(resolved: ResolvedPath, candidate: string) { + return resolved.scope === "skill" ? `skill://${candidate}` : candidate; +} + +function rootNote(resolved: ResolvedPath) { + return resolved.scope === "workspace" ? ` (workspace root: ${resolved.root})` : ""; +} + +function childDisplayPath(target: string, fileName: string) { + return target === "." || target === "" ? fileName : `${target}/${fileName}`; +} + +export function buildRequiresFullReadText( + toolName: string, + resolved: ResolvedPath, + totalLines?: number, +) { + const target = formatResolvedTarget(resolved); + const lineNote = typeof totalLines === "number" && totalLines > 0 ? ` (${totalLines} lines)` : ""; + const limitNote = + typeof totalLines === "number" && totalLines > 200 ? ` and limit=${totalLines}` : ""; + return `${toolName} requires a full-file Read first for existing files: ${target}${lineNote}. Call Read with path="${target}"${limitNote}, then retry ${toolName} with the same path.`; +} + +export function buildStaleFileText(toolName: string, resolved: ResolvedPath) { + const target = formatResolvedTarget(resolved); + return `${target} changed on disk after your last Read. Read it again with the same path, re-derive your change from the fresh content, then retry ${toolName}.`; +} + +export function buildWriteDirectoryText(resolved: ResolvedPath) { + const target = formatResolvedTarget(resolved); + return `Write.path points to a directory, not a file: ${target}. Directories are created implicitly — there is no separate create-directory step. To create or populate ${target}, Write a file inside it with the filename appended, for example path="${childDisplayPath(target, "notes.md")}"; missing parent directories are created automatically.`; +} + +export function buildFsErrorText( + toolName: string, + resolved: ResolvedPath, + error: FsBackendError, +): string { + const target = formatResolvedTarget(resolved); + switch (error.code) { + case "not_found": { + const lead = `${toolName} failed: ${target} does not exist${rootNote(resolved)}.`; + if (error.didYouMean.length > 0) { + const candidates = error.didYouMean + .map((candidate) => displayCandidate(resolved, candidate)) + .join(", "); + return `${lead} Did you mean: ${candidates}? Retry with one of these exact paths.`; + } + const fileName = target.split("/").filter(Boolean).at(-1) ?? target; + return `${lead} Locate it with Glob pattern="**/${fileName}" or List the parent directory, then retry with the returned path.`; + } + case "out_of_bounds": + return `${toolName}.path resolves outside the allowed root: ${resolved.absolutePath}. Write, Edit, and Delete only operate inside the workspace root (${resolved.root}) or an enabled skill:// path. Use a path returned by a previous tool.`; + case "not_a_file": + if (toolName === "Write") return buildWriteDirectoryText(resolved); + return `${toolName}.path points to a directory, not a file: ${target}. Use List with this path to inspect its contents instead.`; + case "not_a_directory": + return `${toolName}.path must be a directory but ${target} is not one. Retry with the parent directory as path.`; + case "requires_full_read": + return buildRequiresFullReadText(toolName, resolved); + case "stale_file": + return buildStaleFileText(toolName, resolved); + case "edit_no_match": + return `Edit found no occurrence of old_string in ${target}. Read the exact region again and copy old_string verbatim, including whitespace and indentation.`; + case "edit_ambiguous": + return `Edit failed for ${target}: ${error.message}. Extend old_string with surrounding lines until it is unique, or set replace_all=true deliberately.`; + case "edit_count_mismatch": + return `Edit failed for ${target}: ${error.message}. Set expected_replacements to the actual match count, or refine old_string.`; + case "too_large": + return `${toolName} failed for ${target}: ${error.message}. Read supports pagination: retry with start_line and limit (text), page_start (PDF), or cell_start (notebook) to read a smaller window.`; + case "not_utf8": + return `${toolName} failed for ${target}: ${error.message}. This file is not UTF-8 text; Read previews binary document formats, and Image displays images.`; + default: + return `${toolName} failed for ${target}: ${error.message}`; + } +} diff --git a/crates/agent-gui/src/lib/tools/pathUtils.ts b/crates/agent-gui/src/lib/tools/pathUtils.ts index f58c29de..a3e85b22 100644 --- a/crates/agent-gui/src/lib/tools/pathUtils.ts +++ b/crates/agent-gui/src/lib/tools/pathUtils.ts @@ -6,26 +6,23 @@ import { type SkillAccessPolicy, } from "./skillAccessPolicy"; -export type PathScope = "workspace" | "skill" | "external" | "temp" | "artifact"; - -export type PathIntent = - | "read" - | "write" - | "edit" - | "delete" - | "list" - | "search" - | "cwd" - | "image"; +// Encoding contract: `file://` inputs are URLs and are always percent-decoded. +// `workspace:` / `skill:` / `skill://` references are literal tool-produced +// strings and are never encoded or decoded. + +export type PathScope = "workspace" | "skill" | "external"; + +export type PathIntent = "read" | "write" | "edit" | "delete" | "list" | "search" | "cwd" | "image"; export type ResolvedPath = { scope: PathScope; input: string; + // Backend base directory: workdir for workspace, skills root for skill, + // the absolute path itself for external. + root: string; absolutePath: string; relativePath?: string; displayPath: string; - pathRef: string; - workdir: string; intent: PathIntent; skillBaseDir?: string; }; @@ -40,6 +37,8 @@ type ResolveOptions = { type ResolverOptions = { workdir: string; + homeDir?: string; + resolveHomeDir?: () => Promise; skillsRootEnabled?: boolean; skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; @@ -69,7 +68,11 @@ function collapseDuplicateSeparators(value: string) { export function normalizeComparablePath(path: string) { const normalized = collapseDuplicateSeparators( - normalizeWindowsExtendedPrefix(normalizeUnicode(String(path || "")).trim().replace(/\\/g, "/")), + normalizeWindowsExtendedPrefix( + normalizeUnicode(String(path || "")) + .trim() + .replace(/\\/g, "/"), + ), ); if (/^[a-zA-Z]:\/?$/.test(normalized)) return normalized.replace(/\/?$/, "/"); if (normalized === "/") return "/"; @@ -80,7 +83,7 @@ function isWindowsDrivePath(value: string) { return /^[a-zA-Z]:\//.test(value); } -function isAbsolutePath(value: string) { +export function isAbsolutePath(value: string) { return value.startsWith("/") || isWindowsDrivePath(value); } @@ -108,15 +111,6 @@ export function relativePathFromAbsolute(rawPath: string, rootDir: string) { return comparablePath.startsWith(`${comparableRoot}/`) ? path.slice(root.length + 1) : null; } -function inferHomeDirFromKnownRoot(rootDir: string | undefined) { - const value = normalizeComparablePath(rootDir || ""); - if (!value) return null; - const unixHome = value.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/); - if (unixHome) return unixHome[1]; - const windowsHome = value.match(/^([a-zA-Z]:\/Users\/[^/]+)/); - return windowsHome ? windowsHome[1] : null; -} - function parseFileUrl(value: string) { if (!/^file:\/\//i.test(value)) return null; try { @@ -148,24 +142,7 @@ function normalizeRawPathInput(input: unknown, label: string) { return value; } -function isWindowsReservedPathComponent(input: string) { - const stem = input - .split(".") - .at(0) - ?.trim() - .replace(/[ .]+$/g, "") - .toUpperCase(); - if (!stem) return false; - return ( - stem === "CON" || - stem === "PRN" || - stem === "AUX" || - stem === "NUL" || - (/^(COM|LPT)[1-9]$/.test(stem)) - ); -} - -function sanitizeRelativePath(input: string, label: string, required: boolean) { +export function sanitizeRelativePath(input: string, label: string, required: boolean) { const normalized = normalizeUnicode(input.trim()).replace(/\\/g, "/"); if (!normalized) { if (required) throw new Error(`${label} is required`); @@ -183,9 +160,6 @@ function sanitizeRelativePath(input: string, label: string, required: boolean) { if (segment === "..") throw new Error(`${label} cannot contain .. segments`); if (segment.includes(":")) throw new Error(`${label} cannot contain ':' path segments`); if (segment.includes("\0")) throw new Error(`${label} contains a NUL byte`); - if (isWindowsReservedPathComponent(segment)) { - throw new Error(`${label} contains a Windows reserved path component: ${segment}`); - } segments.push(segment); } @@ -196,7 +170,7 @@ function sanitizeRelativePath(input: string, label: string, required: boolean) { return segments.join("/"); } -function joinNormalizedPath(rootDir: string, relativePath?: string) { +export function joinNormalizedPath(rootDir: string, relativePath?: string) { const root = normalizeRootPath(rootDir); if (!relativePath) return root; if (root === "/") return `/${relativePath}`; @@ -207,22 +181,6 @@ function firstPathSegment(path: string | undefined) { return path?.split("/").find(Boolean) ?? ""; } -function pathRefFor(scope: PathScope, relativePath: string | undefined, absolutePath: string) { - if (scope === "workspace") return `workspace:${relativePath ?? ""}`; - if (scope === "skill") return `skill:${relativePath ?? ""}`; - return fileUrlForAbsolutePath(absolutePath); -} - -function fileUrlForAbsolutePath(absolutePath: string) { - const normalized = normalizeComparablePath(absolutePath); - const parts = normalized.split("/").map((segment, index) => { - if (index === 0 && /^[a-zA-Z]:$/.test(segment)) return segment; - return encodeURIComponent(segment); - }); - const encodedPath = parts.join("/"); - return isWindowsDrivePath(normalized) ? `file:///${encodedPath}` : `file://${encodedPath}`; -} - function displayPathFor(scope: PathScope, relativePath: string | undefined, absolutePath: string) { if (scope === "workspace") return relativePath || "."; if (scope === "skill") return `skill://${relativePath || ""}`; @@ -240,8 +198,7 @@ function parseScopedPathRef(value: string) { function parseSkillUrl(value: string) { if (!/^skill:\/\//i.test(value)) return null; - const rest = value.replace(/^skill:\/\//i, "").replace(/^\/+/, ""); - return rest; + return value.replace(/^skill:\/\//i, "").replace(/^\/+/, ""); } function fixedSkillsRelativePathFromAbsolute(value: string) { @@ -268,7 +225,6 @@ function operationForIntent(intent: PathIntent, label: string) { return `Bash(${label})`; case "image": return `Image(${label})`; - case "read": default: return `Read(${label})`; } @@ -280,16 +236,25 @@ export function formatResolvedTarget(path: Pick | u export class ToolPathResolver { private readonly workdir: string; + private readonly resolveHomeDirFn?: () => Promise; private readonly skillsRootEnabled: boolean; private readonly skillAccessPolicy?: SkillAccessPolicy; private readonly resolveSkillsRootDir?: () => Promise; + private homeDir: string; + private homeDirResolved: boolean; private skillsRootDir: string; constructor(options: ResolverOptions) { this.workdir = normalizeRootPath(options.workdir); + this.homeDir = + typeof options.homeDir === "string" ? normalizeComparablePath(options.homeDir) : ""; + this.homeDirResolved = this.homeDir.length > 0; + this.resolveHomeDirFn = options.resolveHomeDir; this.skillsRootEnabled = options.skillsRootEnabled === true; this.skillsRootDir = - typeof options.skillsRootDir === "string" ? normalizeComparablePath(options.skillsRootDir) : ""; + typeof options.skillsRootDir === "string" + ? normalizeComparablePath(options.skillsRootDir) + : ""; this.skillAccessPolicy = options.skillAccessPolicy; this.resolveSkillsRootDir = options.resolveSkillsRootDir; } @@ -306,17 +271,27 @@ export class ToolPathResolver { return this.skillsRootDir; } - private inferHomeDir() { - return inferHomeDirFromKnownRoot(this.skillsRootDir) ?? inferHomeDirFromKnownRoot(this.workdir); + private async getHomeDir() { + if (this.homeDirResolved) return this.homeDir; + this.homeDirResolved = true; + try { + const resolved = await this.resolveHomeDirFn?.(); + this.homeDir = typeof resolved === "string" ? normalizeComparablePath(resolved) : ""; + } catch { + this.homeDir = ""; + } + return this.homeDir; } - private expandTilde(value: string) { + private async expandTilde(value: string) { if (value !== "~" && !value.startsWith("~/")) return value; - const home = this.inferHomeDir(); - if (!home) { - throw new Error("Cannot resolve ~/ because the user home directory is unknown"); + const homeDir = await this.getHomeDir(); + if (!homeDir) { + throw new Error( + "Cannot resolve ~/ paths in this session; use a workspace-relative or absolute path instead", + ); } - return normalizeComparablePath(`${home}${value === "~" ? "" : value.slice(1)}`); + return normalizeComparablePath(`${homeDir}${value === "~" ? "" : value.slice(1)}`); } private async resolveSkillRelativePath( @@ -327,7 +302,11 @@ export class ToolPathResolver { if (!skillsRootDir) { throw new Error(`${options.label} points to a Skill path, but Skills are not enabled`); } - const sanitized = sanitizeRelativePath(relativePath ?? "", options.label, options.required === true); + const sanitized = sanitizeRelativePath( + relativePath ?? "", + options.label, + options.required === true, + ); if (!sanitized && isSkillAccessPolicyRestrictive(this.skillAccessPolicy)) { throw new Error( buildSkillAccessDeniedMessage({ @@ -347,11 +326,10 @@ export class ToolPathResolver { return { scope: "skill", input: relativePath ?? "", + root: skillsRootDir, absolutePath, relativePath: sanitized, displayPath: displayPathFor("skill", sanitized, absolutePath), - pathRef: pathRefFor("skill", sanitized, absolutePath), - workdir: skillsRootDir, intent: options.intent, skillBaseDir: firstPathSegment(sanitized), }; @@ -361,34 +339,36 @@ export class ToolPathResolver { relativePath: string | undefined, options: ResolveOptions, ): ResolvedPath { - const sanitized = sanitizeRelativePath(relativePath ?? "", options.label, options.required === true); + const sanitized = sanitizeRelativePath( + relativePath ?? "", + options.label, + options.required === true, + ); const absolutePath = joinNormalizedPath(this.workdir, sanitized); return { scope: "workspace", input: relativePath ?? "", + root: this.workdir, absolutePath, relativePath: sanitized, displayPath: displayPathFor("workspace", sanitized, absolutePath), - pathRef: pathRefFor("workspace", sanitized, absolutePath), - workdir: this.workdir, intent: options.intent, }; } private resolveExternalAbsolutePath(value: string, options: ResolveOptions): ResolvedPath { + const absolutePath = normalizeComparablePath(value); if (!options.allowExternal) { throw new Error( - `${options.label} resolves outside the workspace and enabled Skills. Use a workspace path, an enabled skill:// path, or a pathRef returned by a previous tool.`, + `${options.label} resolves outside the workspace and enabled Skills: ${absolutePath}. Pass a workspace-relative path instead — for example path="notes.md" targets /notes.md — or an enabled skill:// path, exactly as returned by List/Glob/Grep/Read.`, ); } - const absolutePath = normalizeComparablePath(value); return { scope: "external", input: value, + root: absolutePath, absolutePath, displayPath: displayPathFor("external", undefined, absolutePath), - pathRef: pathRefFor("external", undefined, absolutePath), - workdir: absolutePath, intent: options.intent, }; } @@ -396,23 +376,28 @@ export class ToolPathResolver { private async resolveAbsolutePath(value: string, options: ResolveOptions): Promise { const absolutePath = normalizeComparablePath(value); const workspaceRel = relativePathFromAbsolute(absolutePath, this.workdir); + const skillsRootDir = await this.getSkillsRootDir(); + const skillRel = skillsRootDir ? relativePathFromAbsolute(absolutePath, skillsRootDir) : null; + + // When both roots contain the path (nested roots), the longer root is the + // more specific owner and its scope policy must win. + if (workspaceRel !== null && skillRel !== null) { + return skillsRootDir.length >= this.workdir.length + ? this.resolveSkillRelativePath(skillRel, options) + : this.resolveWorkspaceRelativePath(workspaceRel, options); + } + if (skillRel !== null) { + return this.resolveSkillRelativePath(skillRel, options); + } if (workspaceRel !== null) { return this.resolveWorkspaceRelativePath(workspaceRel, options); } - const skillsRootDir = await this.getSkillsRootDir(); - if (skillsRootDir) { - const skillRel = relativePathFromAbsolute(absolutePath, skillsRootDir); - if (skillRel !== null) { - return this.resolveSkillRelativePath(skillRel, options); - } - } - const fixedSkillRel = fixedSkillsRelativePathFromAbsolute(absolutePath); if (fixedSkillRel !== null) { if (!this.skillsRootEnabled) { throw new Error( - `${options.label} points to installed Skill files, but Skills are not enabled for this conversation. Enable the Skill, then use skill://${fixedSkillRel} or a pathRef returned by a file tool.`, + `${options.label} points to installed Skill files, but Skills are not enabled for this conversation. Enable the Skill, then retry with skill://${fixedSkillRel}`, ); } return this.resolveSkillRelativePath(fixedSkillRel, options); @@ -448,7 +433,21 @@ export class ToolPathResolver { return this.resolveAbsolutePath(fileUrlPath, options); } - const expanded = raw.startsWith("~") ? this.expandTilde(raw) : raw; + if (raw.startsWith("~")) { + // "~/.liveagent/skills/..." is recognizable without knowing the home + // directory; resolve it as a Skill path before attempting ~ expansion. + const fixedSkillRel = fixedSkillsRelativePathFromAbsolute(raw); + if (fixedSkillRel !== null) { + if (!this.skillsRootEnabled) { + throw new Error( + `${options.label} points to installed Skill files, but Skills are not enabled for this conversation. Enable the Skill, then retry with skill://${fixedSkillRel}`, + ); + } + return this.resolveSkillRelativePath(fixedSkillRel, options); + } + } + + const expanded = raw.startsWith("~") ? await this.expandTilde(raw) : raw; if (isUncPath(expanded)) throw new Error(`${options.label} cannot be a UNC path`); if (isAbsolutePath(expanded)) { return this.resolveAbsolutePath(expanded, options); diff --git a/crates/agent-gui/src/lib/tools/shellTools.ts b/crates/agent-gui/src/lib/tools/shellTools.ts index 1e77c9c8..90dc9ba4 100644 --- a/crates/agent-gui/src/lib/tools/shellTools.ts +++ b/crates/agent-gui/src/lib/tools/shellTools.ts @@ -371,6 +371,7 @@ export function createShellTools(params: { skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; managedProcessEnabled?: boolean; + resolveHomeDir?: () => Promise; }): BuiltinToolBundle { const timeoutPolicy = resolveBashTimeoutPolicy(params.providerId); const runtimePlatform = @@ -409,6 +410,7 @@ export function createShellTools(params: { const pathResolver = new ToolPathResolver({ workdir, + resolveHomeDir: params.resolveHomeDir, skillsRootEnabled: allowSkillsRoot, skillsRootDir: cachedSkillsRootDir, skillAccessPolicy, @@ -416,7 +418,9 @@ export function createShellTools(params: { }); function backendCwd(resolved: ResolvedPath) { - return resolved.scope === "external" ? undefined : resolved.relativePath || undefined; + return resolved.scope === "workspace" + ? resolved.relativePath || undefined + : resolved.absolutePath; } function normalizeCommandForPolicy(command: string) { @@ -635,7 +639,7 @@ export function createShellTools(params: { cwd: Type.Optional( Type.String({ description: - "Optional working directory. Omit to use the workspace root. Accepts workspace-relative paths, absolute paths, ~/..., file://, pathRef values, and skill:///... paths.", + "Optional working directory. Omit to use the workspace root. Prefer the workspace-relative or skill:// form returned by other tools; may also be an absolute path outside the workspace.", }), ), timeout_ms: Type.Optional( @@ -672,7 +676,7 @@ export function createShellTools(params: { cwd: Type.Optional( Type.String({ description: - 'Optional working directory for action="start". Omit to use the workspace root. Accepts workspace-relative paths, absolute paths, ~/..., file://, pathRef values, and skill:///... paths.', + 'Optional working directory for action="start". Omit to use the workspace root. Prefer the workspace-relative or skill:// form returned by other tools; may also be an absolute path outside the workspace.', }), ), label: Type.Optional( @@ -779,7 +783,7 @@ export function createShellTools(params: { ? toolCall.arguments.label.trim() : undefined; const response = await invoke("managed_process_start", { - workdir: cwdResolved.workdir, + workdir, command, cwd: cwd || undefined, label: label || undefined, @@ -1014,7 +1018,7 @@ export function createShellTools(params: { } } const res = await invoke("shell_run", { - workdir: cwdResolved.workdir, + workdir, command, cwd: cwd || undefined, timeout_ms, diff --git a/crates/agent-gui/src/lib/tools/skillTools.ts b/crates/agent-gui/src/lib/tools/skillTools.ts index c7b9fdde..7f303eaf 100644 --- a/crates/agent-gui/src/lib/tools/skillTools.ts +++ b/crates/agent-gui/src/lib/tools/skillTools.ts @@ -9,6 +9,12 @@ import { type SkillsManagerActionResultDetails, type SkillsManagerResultDetails, } from "./builtinTypes"; +import { + isAbsolutePath, + joinNormalizedPath, + normalizeComparablePath, + relativePathFromAbsolute, +} from "./pathUtils"; import { assertSkillInventoryAllowed, assertSkillManagementAllowed, @@ -29,10 +35,16 @@ function normalizeSkillPath(input: unknown) { const raw = typeof input === "string" ? input.trim() : ""; if (!raw) return ""; if (/^skill:\/\//i.test(raw)) { - return raw.replace(/^skill:\/\//i, "").replace(/^\/+/, "").replace(/\\/g, "/"); + return raw + .replace(/^skill:\/\//i, "") + .replace(/^\/+/, "") + .replace(/\\/g, "/"); } if (/^skill:/i.test(raw)) { - return raw.replace(/^skill:/i, "").replace(/^\/+/, "").replace(/\\/g, "/"); + return raw + .replace(/^skill:/i, "") + .replace(/^\/+/, "") + .replace(/\\/g, "/"); } if (/^[a-zA-Z]:[\\/]/.test(raw)) return ""; if (raw.startsWith("/") || raw.startsWith("\\\\")) return ""; @@ -202,23 +214,10 @@ function optionalBoundedInteger( return Math.max(min, Math.min(max, Math.trunc(value))); } -function normalizeDisplayPath(path: string) { - return path.trim().replace(/\\/g, "/").replace(/\/+$/g, ""); -} - function isUrlLike(value: string) { return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value); } -function isAbsoluteLocalPath(value: string) { - return value.startsWith("/") || value.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(value); -} - -function joinLocalPath(base: string, rel: string) { - const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/"; - return `${base.replace(/[\\/]+$/g, "")}${separator}${rel.replace(/^[\\/]+/g, "")}`; -} - function resolveInstallSourceForWorkspace(source: string, workdir?: string) { const normalizedSource = source.trim(); const normalizedWorkdir = typeof workdir === "string" ? workdir.trim() : ""; @@ -227,21 +226,23 @@ function resolveInstallSourceForWorkspace(source: string, workdir?: string) { !normalizedWorkdir || normalizedSource.startsWith("~") || isUrlLike(normalizedSource) || - isAbsoluteLocalPath(normalizedSource) + isAbsolutePath(normalizedSource.replace(/\\/g, "/")) ) { return normalizedSource; } - return joinLocalPath(normalizedWorkdir, normalizedSource.replace(/^[.][\\/]/, "")); + const relative = normalizedSource + .replace(/^[.][\\/]/, "") + .replace(/\\/g, "/") + .replace(/^\/+/, ""); + return joinNormalizedPath(normalizedWorkdir, relative); } function displaySkillRootPath(rootDir: string, path: string | null | undefined) { - const value = typeof path === "string" ? normalizeDisplayPath(path) : ""; + const value = typeof path === "string" ? normalizeComparablePath(path) : ""; if (!value) return ""; - const root = normalizeDisplayPath(rootDir); - if (root && value === root) return "skill://"; - if (root && value.startsWith(`${root}/`)) { - return `skill://${value.slice(root.length + 1)}`; - } + const relative = relativePathFromAbsolute(value, rootDir); + if (relative === "") return "skill://"; + if (relative) return `skill://${relative}`; return value; } @@ -276,7 +277,7 @@ function collectManagedSkillAccess(result: Awaited"; } @@ -293,7 +294,7 @@ function buildSkillReadPathRules(path: string) { } function isSkillEntryPath(path: string) { - const normalized = normalizeDisplayPath(path).toLowerCase(); + const normalized = normalizeComparablePath(path).toLowerCase(); return /(^|\/)(skill\.md|skill\.json|readme\.md)$/.test(normalized); } @@ -463,7 +464,7 @@ function formatManageSkillResultText( const lines = [ `SkillsManager action=${result.action}`, "pathScheme=skill:///...", - 'Use Read/List/Glob/Grep/Write/Edit/Delete with skill:///... paths for files inside enabled Skills. Use SkillsManager actions for Skill creation, local/GitHub/ClawHub installation, validation, packaging, and deletion.', + "Use Read/List/Glob/Grep/Write/Edit/Delete with skill:///... paths for files inside enabled Skills. Use SkillsManager actions for Skill creation, local/GitHub/ClawHub installation, validation, packaging, and deletion.", ]; if (result.action === "list") { diff --git a/crates/agent-gui/src/lib/tools/sshManagerTools.ts b/crates/agent-gui/src/lib/tools/sshManagerTools.ts index 8e273b2a..0ad90e47 100644 --- a/crates/agent-gui/src/lib/tools/sshManagerTools.ts +++ b/crates/agent-gui/src/lib/tools/sshManagerTools.ts @@ -195,7 +195,7 @@ const SSH_MANAGER_TOOL: Tool = { local_path: Type.Optional( Type.String({ description: - "Local workspace path for upload/download. Accepts workspace-relative paths, absolute paths inside the workspace, ~/..., file://, or workspace:pathRef values.", + "Local workspace path for upload/download. Accepts workspace-relative paths exactly as returned by file tools, absolute paths inside the workspace, ~/..., or file:// values.", }), ), remote_path: Type.Optional(Type.String({ description: "Remote path for upload/download." })), @@ -530,6 +530,7 @@ async function executeSSHManager( projectPathKey: string; hosts: SshHostConfig[]; associatedHostIds: string[]; + pathResolver: ToolPathResolver; onSshSessionsChanged?: (change: SshManagerSessionChange) => void | Promise; }, signal?: AbortSignal, @@ -542,14 +543,15 @@ async function executeSSHManager( intent: "read" | "write", label: string, ) => { - const pathResolver = new ToolPathResolver({ workdir: params.workdir }); - const resolved = await pathResolver.resolvePath(input, { + const resolved = await params.pathResolver.resolvePath(input, { label, intent, required: true, }); if (resolved.scope !== "workspace") { - throw new Error(`${label} must resolve inside the current workspace.`); + throw new Error( + `${label} must resolve inside the workspace (workspace root: ${params.workdir}). Got: ${String(input)}. Use a workspace-relative path.`, + ); } return resolved.relativePath ?? ""; }; @@ -987,11 +989,16 @@ export function createSSHManagerTools(params: { projectPathKey?: string; hosts?: SshHostConfig[]; associatedHostIds?: string[]; + resolveHomeDir?: () => Promise; onSshSessionsChanged?: (change: SshManagerSessionChange) => void | Promise; }): BuiltinToolBundle { const projectPathKey = params.projectPathKey?.trim() || params.workdir.trim(); const hosts = params.hosts ?? []; const associatedHostIds = params.associatedHostIds ?? []; + const pathResolver = new ToolPathResolver({ + workdir: params.workdir, + resolveHomeDir: params.resolveHomeDir, + }); const tools = params.enabled && params.runtimeScope === "chat" && @@ -1011,6 +1018,7 @@ export function createSSHManagerTools(params: { projectPathKey, hosts, associatedHostIds, + pathResolver, onSshSessionsChanged: params.onSshSessionsChanged, }, signal, diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 070f954a..44b63dc2 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -161,6 +161,7 @@ import { } from "../lib/terminal/sessionStore"; import { tauriTerminalClient } from "../lib/terminal/tauriTerminalClient"; import type { TerminalSession } from "../lib/terminal/types"; +import { invokeFs } from "../lib/tools/fsBackend"; import type { SkillAccessPolicy } from "../lib/tools/skillAccessPolicy"; import type { LocalTunnelClient, @@ -1017,7 +1018,7 @@ export function ChatPage(props: ChatPageProps) { return false; } try { - await invoke("fs_list", { + await invokeFs("fs_list", { workdir: path, path: null, depth: 1, diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx index 6e61600b..026f4603 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/ToolResultDisplay.tsx @@ -111,14 +111,12 @@ function buildPagedResultTags(params: { function filePathTags(details: { scope?: string; displayPath?: string; - pathRef?: string; absolutePath?: string; }): MetaTag[] { return [ ...(details.scope && details.scope !== "workspace" ? [{ label: "scope", value: details.scope }] : []), - ...(details.pathRef ? [{ label: "pathRef", value: details.pathRef }] : []), ]; } diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index f359e777..98a62ba2 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -165,7 +165,7 @@ test("agent tool rules require Image for chat-visible images", () => { ); assert.match( suffix, - /Local image: pass `path` exactly as seen, including workspace-relative, absolute, pathRef, or skill:\/\/ paths\./, + /Local image: pass `path` exactly as seen, including workspace-relative, absolute, or skill:\/\/ paths\./, ); assert.match( suffix, @@ -217,7 +217,7 @@ test("agent tool rules keep local file discovery on file tools instead of Bash", "Bash", "SkillsManager", ]); - assert.match(suffix, /File tools and Bash accept the path you see/); + assert.match(suffix, /Preferred form: workspace-relative paths exactly as tools return them/); assert.match(suffix, /For files inside a Skill, call file tools with a path like `skill:\/\/\/references\/guide\.md`/); assert.match(suffix, /Do not run Bash cat\/ls\/find\/grep/); }); @@ -242,7 +242,10 @@ test("agent tool rules keep workspace and Skills deletion on Delete", () => { "Bash", "SkillsManager", ]); - assert.match(suffix, /For workspace or Skill deletion, use Delete with the exact path or pathRef/); + assert.match( + suffix, + /For workspace or Skill deletion, use Delete with the exact path returned by List\/Glob\/Grep\/Read/, + ); assert.match(suffix, /Do not run Bash rm, rmdir, unlink, or find -delete/); }); diff --git a/crates/agent-gui/test/tools/delegate-tools.test.mjs b/crates/agent-gui/test/tools/delegate-tools.test.mjs index 0d6494a5..b72f221b 100644 --- a/crates/agent-gui/test/tools/delegate-tools.test.mjs +++ b/crates/agent-gui/test/tools/delegate-tools.test.mjs @@ -59,20 +59,6 @@ function createToolCall(argumentsValue) { }; } -test("delegate relative path normalization rejects Windows absolute paths", () => { - const loader = createTsModuleLoader(); - const { normalizeRelativePath } = loader.loadModule("src/lib/tools/delegate/input.ts"); - - assert.equal(normalizeRelativePath(" docs\\report.md "), "docs/report.md"); - assert.equal(normalizeRelativePath("docs/./report.md"), "docs/report.md"); - assert.equal(normalizeRelativePath("docs//report.md"), "docs/report.md"); - assert.equal(normalizeRelativePath("C:\\Users\\me\\report.md"), ""); - assert.equal(normalizeRelativePath("C:/Users/me/report.md"), ""); - assert.equal(normalizeRelativePath("\\\\server\\share\\report.md"), ""); - assert.equal(normalizeRelativePath("safe:name.md"), ""); - assert.equal(normalizeRelativePath("docs/../secret.md"), ""); -}); - function createSubagentIdentity(overrides = {}) { const logicalAgentId = overrides.logicalAgentId ?? "expert-a"; const displayName = overrides.displayName ?? overrides.name ?? "Existing Expert"; diff --git a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs index 3203548d..6dda16b4 100644 --- a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs +++ b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs @@ -21,7 +21,8 @@ test("ToolPathResolver accepts broad workspace path inputs", async () => { assert.equal(relative.relativePath, "src/App.tsx"); assert.equal(relative.absolutePath, "/workspace/project/src/App.tsx"); assert.equal(relative.displayPath, "src/App.tsx"); - assert.equal(relative.pathRef, "workspace:src/App.tsx"); + assert.equal(relative.root, "/workspace/project"); + assert.ok(!("pathRef" in relative)); const absolute = await resolver.resolvePath("/workspace/project/src/App.tsx", { label: "Read.path", @@ -165,15 +166,6 @@ test("ToolPathResolver normalizes Windows workspace path variants", async () => }), /cannot contain ':' path segments/, ); - await assert.rejects( - () => - resolver.resolvePath("CON/file.txt", { - label: "Read.path", - intent: "read", - required: true, - }), - /Windows reserved path component/, - ); await assert.rejects( () => resolver.resolvePath("\\\\server\\share\\file.txt", { @@ -216,7 +208,8 @@ test("ToolPathResolver resolves enabled Skill paths and gates external paths by assert.equal(skillUrl.relativePath, "skills-creator/SKILL.md"); assert.equal(skillUrl.absolutePath, "/Users/me/.liveagent/skills/skills-creator/SKILL.md"); assert.equal(skillUrl.displayPath, "skill://skills-creator/SKILL.md"); - assert.equal(skillUrl.pathRef, "skill:skills-creator/SKILL.md"); + assert.equal(skillUrl.root, "/Users/me/.liveagent/skills"); + assert.ok(!("pathRef" in skillUrl)); const absoluteSkill = await resolver.resolvePath( "/Users/me/.liveagent/skills/skills-creator/SKILL.md", @@ -246,7 +239,8 @@ test("ToolPathResolver resolves enabled Skill paths and gates external paths by allowExternal: true, }); assert.equal(externalImage.scope, "external"); - assert.equal(externalImage.pathRef, "file:///Users/me/Pictures/chart.png"); + assert.equal(externalImage.root, "/Users/me/Pictures/chart.png"); + assert.equal(externalImage.displayPath, "/Users/me/Pictures/chart.png"); await assert.rejects( () => @@ -259,6 +253,119 @@ test("ToolPathResolver resolves enabled Skill paths and gates external paths by ); }); +test("ToolPathResolver prefers the skill scope when the skills root nests inside the workspace", async () => { + const resolver = new pathUtils.ToolPathResolver({ + workdir: "/workspace/project", + skillsRootEnabled: true, + skillsRootDir: "/workspace/project/.liveagent/skills", + }); + + const nestedSkill = await resolver.resolvePath( + "/workspace/project/.liveagent/skills/demo/SKILL.md", + { + label: "Read.path", + intent: "read", + required: true, + }, + ); + assert.equal(nestedSkill.scope, "skill"); + assert.equal(nestedSkill.relativePath, "demo/SKILL.md"); + assert.equal(nestedSkill.root, "/workspace/project/.liveagent/skills"); + assert.equal(nestedSkill.displayPath, "skill://demo/SKILL.md"); + + const workspaceFile = await resolver.resolvePath("/workspace/project/src/App.tsx", { + label: "Read.path", + intent: "read", + required: true, + }); + assert.equal(workspaceFile.scope, "workspace"); + assert.equal(workspaceFile.relativePath, "src/App.tsx"); + assert.equal(workspaceFile.root, "/workspace/project"); +}); + +test("ToolPathResolver expands ~ only with an injected home directory", async () => { + const withHome = new pathUtils.ToolPathResolver({ + workdir: "/Users/me/project", + homeDir: "/Users/me", + }); + const expanded = await withHome.resolvePath("~/project/notes/todo.md", { + label: "Read.path", + intent: "read", + required: true, + }); + assert.equal(expanded.scope, "workspace"); + assert.equal(expanded.relativePath, "notes/todo.md"); + assert.equal(expanded.absolutePath, "/Users/me/project/notes/todo.md"); + + const withAsyncHome = new pathUtils.ToolPathResolver({ + workdir: "/workspace/project", + resolveHomeDir: async () => "/Users/me", + }); + const external = await withAsyncHome.resolvePath("~/notes.md", { + label: "Image.path", + intent: "image", + required: true, + allowExternal: true, + }); + assert.equal(external.scope, "external"); + assert.equal(external.absolutePath, "/Users/me/notes.md"); + assert.equal(external.root, "/Users/me/notes.md"); + assert.equal(external.displayPath, "/Users/me/notes.md"); + + const withoutHome = new pathUtils.ToolPathResolver({ workdir: "/workspace/project" }); + await assert.rejects( + () => + withoutHome.resolvePath("~/notes.md", { + label: "Read.path", + intent: "read", + required: true, + }), + /Cannot resolve ~\/ paths in this session; use a workspace-relative or absolute path instead/, + ); + + const fixedSkills = new pathUtils.ToolPathResolver({ + workdir: "/workspace/project", + skillsRootEnabled: true, + skillsRootDir: "/Users/me/.liveagent/skills", + }); + const skillViaHome = await fixedSkills.resolvePath("~/.liveagent/skills/demo/SKILL.md", { + label: "Read.path", + intent: "read", + required: true, + }); + assert.equal(skillViaHome.scope, "skill"); + assert.equal(skillViaHome.relativePath, "demo/SKILL.md"); + assert.equal(skillViaHome.displayPath, "skill://demo/SKILL.md"); +}); + +test("ToolPathResolver still accepts legacy workspace:/skill: prefixed inputs", async () => { + const resolver = new pathUtils.ToolPathResolver({ + workdir: "/workspace/project", + skillsRootEnabled: true, + skillsRootDir: "/Users/me/.liveagent/skills", + }); + + const workspaceRef = await resolver.resolvePath("workspace:src/App.tsx", { + label: "Read.path", + intent: "read", + required: true, + }); + assert.equal(workspaceRef.scope, "workspace"); + assert.equal(workspaceRef.relativePath, "src/App.tsx"); + assert.equal(workspaceRef.displayPath, "src/App.tsx"); + assert.ok(!("pathRef" in workspaceRef)); + + const skillRef = await resolver.resolvePath("skill:demo/SKILL.md", { + label: "Read.path", + intent: "read", + required: true, + }); + assert.equal(skillRef.scope, "skill"); + assert.equal(skillRef.relativePath, "demo/SKILL.md"); + assert.equal(skillRef.displayPath, "skill://demo/SKILL.md"); + assert.ok(!("pathRef" in skillRef)); +}); + test("builtin agent skills stay selected and sort first", () => { assert.deepEqual(skillBuiltinHelpers.mergeAlwaysEnabledSkillNames(["demo-skill"]), [ "skills-creator", @@ -297,6 +404,7 @@ test("file tools can read enabled Skill files via skill URLs", async () => { isPartialView: false, mtimeMs: 10, contentHash: "hash", + fileId: "5:77", }; }, }, @@ -331,7 +439,8 @@ test("file tools can read enabled Skill files via skill URLs", async () => { assert.equal(result.details.scope, "skill"); assert.equal(result.details.path, "skill://skills-creator/SKILL.md"); assert.equal(result.details.relativePath, "skills-creator/SKILL.md"); - assert.equal(result.details.pathRef, "skill:skills-creator/SKILL.md"); + assert.equal(result.details.fileId, "5:77"); + assert.ok(!("pathRef" in result.details)); assert.match(result.content[0].text, /Read: skill:\/\/skills-creator\/SKILL\.md/); assert.deepEqual(invocations, [ { @@ -447,8 +556,19 @@ test("file tools allow direct mutations inside enabled Skills when mutation is g "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); + if (command === "fs_path_status") { + return { + path: args.path, + exists: false, + kind: null, + sizeBytes: null, + mtimeMs: null, + fileId: null, + }; + } assert.equal(command, "fs_write_text"); return { + path: args.path, existedBefore: false, bytesWritten: 34, mtimeMs: 123, @@ -487,6 +607,13 @@ test("file tools allow direct mutations inside enabled Skills when mutation is g assert.match(result.content[0].text, /File created successfully at: skill:\/\/demo\/SKILL\.md/); assert.doesNotMatch(result.content[0].text, /mode=rewrite/); assert.deepEqual(invocations, [ + { + command: "fs_path_status", + args: { + workdir: "/Users/me/.liveagent/skills", + path: "demo/SKILL.md", + }, + }, { command: "fs_write_text", args: { @@ -639,8 +766,8 @@ test("Write preflight gives generic filename guidance for directory paths", asyn assert.equal(blocked.toolResult.isError, true); assert.match(blocked.toolResult.content[0].text, /directory, not a file: output/); - assert.match(blocked.toolResult.content[0].text, /path="output\/file\.txt"/); - assert.match(blocked.toolResult.content[0].text, /does not choose a filename/); + assert.match(blocked.toolResult.content[0].text, /path="output\/notes\.md"/); + assert.match(blocked.toolResult.content[0].text, /no separate create-directory step/); assert.equal(blocked.toolCall.arguments.content, ""); assert.deepEqual(invocations, [ { @@ -660,9 +787,24 @@ test("Write does not infer filenames from content when path is a directory", asy "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); + if (command === "fs_path_status") { + return { + path: args.path, + exists: true, + kind: "dir", + sizeBytes: 96, + mtimeMs: 55, + fileId: null, + }; + } assert.equal(command, "fs_write_text"); assert.equal(args.path, "test8"); - throw new Error("Cannot write to a directory path"); + throw { + code: "not_a_file", + message: "Cannot write to a directory path", + path: "test8", + workdir: "/workspace", + }; }, }, }, @@ -686,9 +828,17 @@ test("Write does not infer filenames from content when path is a directory", asy assert.equal(result.isError, true); assert.match(result.content[0].text, /directory, not a file: test8/); - assert.match(result.content[0].text, /path="test8\/file\.txt"/); + assert.match(result.content[0].text, /path="test8\/notes\.md"/); + assert.match(result.content[0].text, /no separate create-directory step/); assert.doesNotMatch(result.content[0].text, /data\.json|index\.html/); assert.deepEqual(invocations, [ + { + command: "fs_path_status", + args: { + workdir: "/workspace", + path: "test8", + }, + }, { command: "fs_write_text", args: { @@ -710,8 +860,19 @@ test("Write preserves extensionless file paths instead of adding a content-deriv "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); + if (command === "fs_path_status") { + return { + path: args.path, + exists: false, + kind: null, + sizeBytes: null, + mtimeMs: null, + fileId: null, + }; + } assert.equal(command, "fs_write_text"); return { + path: args.path, existedBefore: false, bytesWritten: args.content.length, mtimeMs: 125, @@ -743,6 +904,13 @@ test("Write preserves extensionless file paths instead of adding a content-deriv assert.match(result.content[0].text, /File created successfully at: scripts\/run/); assert.equal(result.details.path, "scripts/run"); assert.deepEqual(invocations, [ + { + command: "fs_path_status", + args: { + workdir: "/workspace", + path: "scripts/run", + }, + }, { command: "fs_write_text", args: { @@ -775,6 +943,7 @@ test("Write replays the Gomoku failure sequence with generic directory recovery } assert.equal(command, "fs_write_text"); return { + path: args.path, existedBefore: false, bytesWritten: args.content.length, mtimeMs: 126, @@ -823,7 +992,7 @@ test("Write replays the Gomoku failure sequence with generic directory recovery assert.equal(directoryBlocked.toolResult.isError, true); assert.match(directoryBlocked.toolResult.content[0].text, /directory, not a file: test8/); - assert.match(directoryBlocked.toolResult.content[0].text, /path="test8\/file\.txt"/); + assert.match(directoryBlocked.toolResult.content[0].text, /path="test8\/notes\.md"/); assert.doesNotMatch(directoryBlocked.toolResult.content[0].text, /mode constant|index\.html/); const recovered = await bundle.executeToolCall({ @@ -848,6 +1017,13 @@ test("Write replays the Gomoku failure sequence with generic directory recovery path: "test8", }, }, + { + command: "fs_path_status", + args: { + workdir: "/workspace", + path: "test8/index.html", + }, + }, { command: "fs_write_text", args: { @@ -967,14 +1143,14 @@ test("file tools normalize absolute enabled Skill paths", async () => { ]); }); -test("file tool runtime errors point to exact path/pathRef recovery", async () => { +test("file tool runtime string errors surface the backend message with the display path", async () => { const invocations = []; const fsLoader = createTsModuleLoader({ mocks: { "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); - throw new Error("I/O error: No such file or directory (os error 2)"); + throw "I/O error: No such file or directory (os error 2)"; }, }, }, @@ -998,9 +1174,11 @@ test("file tool runtime errors point to exact path/pathRef recovery", async () = }); assert.equal(result.isError, true); - assert.match(result.content[0].text, /Read failed for skill:\/\/demo\/missing\.md/); - assert.match(result.content[0].text, /Use the exact path, pathRef, or displayPath/); - assert.match(result.content[0].text, /Do not use Bash/); + assert.equal( + result.content[0].text, + "Read failed for skill://demo/missing.md: I/O error: No such file or directory (os error 2)", + ); + assert.doesNotMatch(result.content[0].text, /pathRef/); assert.deepEqual(invocations, [ { command: "fs_read_text", @@ -1018,7 +1196,56 @@ test("file tool runtime errors point to exact path/pathRef recovery", async () = ]); }); -test("Grep retries file paths as parent directory plus file_pattern", async () => { +test("file tool not_found errors offer didYouMean or Glob/List recovery", async () => { + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + assert.equal(command, "fs_read_text"); + throw { + code: "not_found", + message: "file not found", + path: args.path, + workdir: args.workdir, + didYouMean: args.path === "src/Appp.tsx" ? ["src/App.tsx"] : [], + }; + }, + }, + }, + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const withSuggestion = await bundle.executeToolCall({ + type: "toolCall", + id: "not-found-suggestion", + name: "Read", + arguments: { path: "src/Appp.tsx" }, + }); + assert.equal(withSuggestion.isError, true); + assert.equal( + withSuggestion.content[0].text, + "Read failed: src/Appp.tsx does not exist (workspace root: /workspace). Did you mean: src/App.tsx? Retry with one of these exact paths.", + ); + + const withoutSuggestion = await bundle.executeToolCall({ + type: "toolCall", + id: "not-found-plain", + name: "Read", + arguments: { path: "missing/none.md" }, + }); + assert.equal(withoutSuggestion.isError, true); + assert.equal( + withoutSuggestion.content[0].text, + 'Read failed: missing/none.md does not exist (workspace root: /workspace). Locate it with Glob pattern="**/none.md" or List the parent directory, then retry with the returned path.', + ); +}); + +test("Grep passes file paths straight to the backend and reports the single-file note", async () => { const invocations = []; const fsLoader = createTsModuleLoader({ mocks: { @@ -1026,17 +1253,13 @@ test("Grep retries file paths as parent directory plus file_pattern", async () = async invoke(command, args) { invocations.push({ command, args }); assert.equal(command, "fs_grep"); - if (invocations.length === 1) { - assert.equal(args.path, "src/App.tsx"); - assert.equal(args.file_pattern, undefined); - throw new Error("Grep.path must be a directory"); - } - assert.equal(args.path, "src"); - assert.equal(args.file_pattern, "App.tsx"); + assert.equal(args.path, "src/App.tsx"); + assert.equal(args.file_pattern, undefined); return { - path: "src", + path: "src/App.tsx", + targetKind: "file", pattern: "render", - filePattern: "App.tsx", + filePattern: null, ignoreCase: true, outputMode: "content", headLimit: 20, @@ -1081,10 +1304,12 @@ test("Grep retries file paths as parent directory plus file_pattern", async () = }); assert.equal(result.isError, false); - assert.match(result.content[0].text, /autoCorrectedPath=src\/App\.tsx file_pattern=App\.tsx/); - assert.equal(result.details.path, "src"); - assert.equal(result.details.filePattern, "App.tsx"); - assert.equal(invocations.length, 2); + assert.match(result.content[0].text, /note=path is a file; searched that single file/); + assert.match(result.content[0].text, /src\/App\.tsx:12: render\(\);/); + assert.equal(result.details.path, "src/App.tsx"); + assert.equal(result.details.targetKind, "file"); + assert.equal(result.details.filePattern, undefined); + assert.equal(invocations.length, 1); }); test("Edit auto-primes a full text snapshot before replacement", async () => { @@ -1094,6 +1319,17 @@ test("Edit auto-primes a full text snapshot before replacement", async () => { "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); + if (command === "fs_path_status") { + assert.equal(args.path, "src/App.tsx"); + return { + path: "src/App.tsx", + exists: true, + kind: "file", + sizeBytes: 21, + mtimeMs: 44, + fileId: null, + }; + } if (command === "fs_read_text") { assert.equal(args.path, "src/App.tsx"); assert.equal(args.limit, 5000); @@ -1147,7 +1383,98 @@ test("Edit auto-primes a full text snapshot before replacement", async () => { assert.equal(result.isError, false); assert.match(result.content[0].text, /autoRead=full/); assert.equal(result.details.replacements, 1); - assert.deepEqual(invocations.map((call) => call.command), ["fs_read_text", "fs_edit_text"]); + assert.deepEqual( + invocations.map((call) => call.command), + ["fs_path_status", "fs_read_text", "fs_edit_text"], + ); +}); + +test("Edit reuses full-read snapshots across path spellings via fileId", async () => { + const invocations = []; + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + invocations.push({ command, args }); + if (command === "fs_read_text") { + assert.equal(args.path, "src/App.tsx"); + return { + kind: "text", + path: "src/App.tsx", + content: "1\tconst value = 'old';\n", + truncated: false, + startLine: 1, + numLines: 1, + totalLines: 1, + isPartialView: false, + mtimeMs: 44, + contentHash: "before-hash", + fileId: "1:42", + }; + } + if (command === "fs_path_status") { + assert.equal(args.path, "SRC/App.TSX"); + return { + path: "SRC/App.TSX", + exists: true, + kind: "file", + sizeBytes: 21, + mtimeMs: 44, + fileId: "1:42", + }; + } + assert.equal(command, "fs_edit_text"); + assert.equal(args.path, "SRC/App.TSX"); + assert.equal(args.expected_mtime_ms, 44); + assert.equal(args.expected_content_hash, "before-hash"); + return { + path: "SRC/App.TSX", + replacements: 1, + replaceAll: false, + mtimeMs: 45, + contentHash: "after-hash", + totalLines: 1, + fileId: "1:42", + }; + }, + }, + }, + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const readResult = await bundle.executeToolCall({ + type: "toolCall", + id: "read-lowercase-path", + name: "Read", + arguments: { path: "src/App.tsx" }, + }); + assert.equal(readResult.isError, false); + assert.equal(readResult.details.fileId, "1:42"); + + const editResult = await bundle.executeToolCall({ + type: "toolCall", + id: "edit-uppercase-path", + name: "Edit", + arguments: { + path: "SRC/App.TSX", + old_string: "old", + new_string: "new", + }, + }); + + assert.equal(editResult.isError, false); + assert.doesNotMatch(editResult.content[0].text, /autoRead/); + assert.equal(editResult.details.fileId, "1:42"); + assert.equal(editResult.details.replacements, 1); + assert.deepEqual( + invocations.map((call) => call.command), + ["fs_read_text", "fs_path_status", "fs_edit_text"], + ); }); test("SkillsManager read accepts explicit skill entry paths", async () => { @@ -1869,7 +2196,7 @@ test("Image file tool returns display image details and inline image content", a absolutePath: "/workspace/uploads/001.jpg", relativePath: "uploads/001.jpg", displayPath: "uploads/001.jpg", - pathRef: "workspace:uploads/001.jpg", + fileId: undefined, sourceType: "path", renderMode: "inline", mimeType: "image/jpeg", @@ -1940,7 +2267,7 @@ test("Image file tool reads installed Skill images through skill URLs", async () assert.equal(result.details.images[0].scope, "skill"); assert.equal(result.details.images[0].path, "skill://demo/assets/logo.png"); assert.equal(result.details.images[0].relativePath, "demo/assets/logo.png"); - assert.equal(result.details.images[0].pathRef, "skill:demo/assets/logo.png"); + assert.ok(!("pathRef" in result.details.images[0])); assert.match(result.content[0].text, /Display image: skill:\/\/demo\/assets\/logo\.png/); assert.deepEqual(invocations, [ { @@ -2055,14 +2382,14 @@ test("Image file tool blocks fixed Skills root paths when Skills are disabled", assert.deepEqual(invocations, []); }); -test("Image runtime errors point to exact path/pathRef recovery", async () => { +test("Image runtime errors surface the backend message for resolved local paths", async () => { const invocations = []; const fsLoader = createTsModuleLoader({ mocks: { "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); - throw new Error("I/O error: No such file or directory (os error 2)"); + throw "I/O error: No such file or directory (os error 2)"; }, }, }, @@ -2084,9 +2411,11 @@ test("Image runtime errors point to exact path/pathRef recovery", async () => { }); assert.equal(result.isError, true); - assert.match(result.content[0].text, /Image failed for skill:\/\/demo\/assets\/missing\.png/); - assert.match(result.content[0].text, /Pass the path exactly as returned/); - assert.match(result.content[0].text, /Do not use Bash/); + assert.equal( + result.content[0].text, + "Image failed for skill://demo/assets/missing.png: I/O error: No such file or directory (os error 2)", + ); + assert.doesNotMatch(result.content[0].text, /Pass the path exactly as returned/); assert.deepEqual(invocations, [ { command: "fs_read_image_source", @@ -2100,6 +2429,40 @@ test("Image runtime errors point to exact path/pathRef recovery", async () => { ]); }); +test("Image base64 errors keep exact-source guidance when no path was resolved", async () => { + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command) { + assert.equal(command, "fs_read_image_source"); + throw "unsupported image data"; + }, + }, + }, + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const result = await bundle.executeToolCall({ + type: "toolCall", + id: "broken-base64-image", + name: "Image", + arguments: { base64: "data:image/png;base64,abc123" }, + }); + + assert.equal(result.isError, true); + assert.match( + result.content[0].text, + /Image failed for source="data:image\/png;base64,abc123": unsupported image data/, + ); + assert.match(result.content[0].text, /Pass the path exactly as returned/); + assert.match(result.content[0].text, /Do not use Bash/); +}); + test("Image file tool returns multiple inline images from one call", async () => { const invocations = []; const imageByPath = new Map([ @@ -2392,3 +2755,75 @@ test("system tool options include user-selectable tools", () => { }, ]); }); + +test("Write rejection for external paths echoes the resolved path and a corrected example", async () => { + const resolver = new pathUtils.ToolPathResolver({ workdir: "/workspace/project" }); + + await assert.rejects( + resolver.resolvePath("/", { + label: "Write.path", + intent: "write", + required: true, + }), + (error) => { + assert.match(error.message, /Write\.path resolves outside the workspace and enabled Skills: \//); + assert.match(error.message, /path="notes\.md"/); + assert.match(error.message, /skill:\/\//); + return true; + }, + ); +}); + +test("repeated identical failing calls escalate with a loop-breaking notice", async () => { + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + assert.equal(command, "fs_path_status"); + return { + path: args.path, + exists: true, + kind: "dir", + sizeBytes: 96, + mtimeMs: 55, + fileId: null, + }; + }, + }, + }, + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const callWriteToDirectory = (id) => + bundle.preflightToolCall({ + type: "toolCall", + id, + name: "Write", + arguments: { path: "tool-test", content: "test" }, + }); + + const first = await callWriteToDirectory("loop-1"); + assert.equal(first.toolResult.isError, true); + assert.doesNotMatch(first.toolResult.content[0].text, /times in a row/); + + const second = await callWriteToDirectory("loop-2"); + assert.match(second.toolResult.content[0].text, /failed 2 times in a row/); + assert.match(second.toolResult.content[0].text, /Do not retry with the same arguments/); + + const third = await callWriteToDirectory("loop-3"); + assert.match(third.toolResult.content[0].text, /failed 3 times in a row/); + + // Re-evaluating the same physical tool call (streaming preflight) must not + // inflate the counter. + const replay = await callWriteToDirectory("loop-3"); + assert.match(replay.toolResult.content[0].text, /failed 3 times in a row/); + + // A different failing call resets the consecutive counter. + const different = await callWriteToDirectory("loop-4"); + assert.match(different.toolResult.content[0].text, /failed 4 times in a row/); +}); diff --git a/crates/agent-gui/test/tools/shell-tools.test.mjs b/crates/agent-gui/test/tools/shell-tools.test.mjs index 7131d79d..a9530969 100644 --- a/crates/agent-gui/test/tools/shell-tools.test.mjs +++ b/crates/agent-gui/test/tools/shell-tools.test.mjs @@ -556,8 +556,8 @@ test("Bash tool can execute from the fixed Skills root with relative cwd", async assert.equal(result.isError, false); assert.match(result.content[0].text, /cwd: skill:\/\/metaphysics-steward\/scripts/); - assert.equal(calls[0].args.workdir, "/Users/me/.liveagent/skills"); - assert.equal(calls[0].args.cwd, "metaphysics-steward/scripts"); + assert.equal(calls[0].args.workdir, "/repo"); + assert.equal(calls[0].args.cwd, "/Users/me/.liveagent/skills/metaphysics-steward/scripts"); }); test("Bash tool allows enabled Skill scripts by direct absolute path without cd", async () => { From 4f498ec2be7ca5e4d36986710cf263a4cdf78076 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 20:49:17 +0800 Subject: [PATCH 29/64] fix(tools): harden streamed tool calls and file writes --- .../src/lib/chat/runner/agentRunner.ts | 202 +++-------- .../lib/chat/runner/toolCallArgumentGuard.ts | 128 +++++++ .../src/lib/tools/builtinRegistry.ts | 18 - .../agent-gui/src/lib/tools/builtinTypes.ts | 11 - crates/agent-gui/src/lib/tools/fsTools.ts | 304 +++++----------- crates/agent-gui/src/lib/tools/pathUtils.ts | 11 +- .../chat/turns/runAgentConversationTurn.ts | 2 - .../agent-gui/test/chat/agent-runner.test.mjs | 336 ++++++++++++++---- .../test/chat/markdown-image-policy.test.mjs | 2 +- .../chat/tool-call-argument-guard.test.mjs | 159 +++++++++ .../agent-gui/test/helpers/load-ts-module.mjs | 23 +- .../providers/deepseek-dsml-stream.test.mjs | 39 ++ .../test/tools/path-and-system-tools.test.mjs | 306 +++++++++++----- 13 files changed, 991 insertions(+), 550 deletions(-) create mode 100644 crates/agent-gui/src/lib/chat/runner/toolCallArgumentGuard.ts create mode 100644 crates/agent-gui/test/chat/tool-call-argument-guard.test.mjs diff --git a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts index b9f7a266..ff33fa22 100644 --- a/crates/agent-gui/src/lib/chat/runner/agentRunner.ts +++ b/crates/agent-gui/src/lib/chat/runner/agentRunner.ts @@ -1,7 +1,6 @@ import { Agent, type AgentTool } from "@earendil-works/pi-agent-core"; import type { AssistantMessage, - AssistantMessageEvent, Context, Message, ToolCall, @@ -61,6 +60,7 @@ import { import { createSubagentScheduler, type SubagentScheduler } from "../subagent/subagentScheduler"; import { comparableToolCall } from "./flattenedToolCallText"; import { recoverAssistantSeedToolCalls } from "./seedToolCalls"; +import { wrapStreamWithToolCallArgumentGuard } from "./toolCallArgumentGuard"; function createLinkedAbortSignal(signals: Array): { signal?: AbortSignal; @@ -196,7 +196,7 @@ export function buildToolsSuffix( } if (canWrite) { lines.push( - "- Existing files: Read the full file before Write or Edit; stale writes are rejected, so re-Read if needed.", + "- Existing files: Write and Edit check the file's current on-disk state automatically; a separate Read is only needed for very large files (>5000 lines). Stale writes are rejected — re-Read and retry if the file changed underneath you.", "- New files: call Write with a file path that includes the filename and the full content; parent directories are created automatically. There is no separate create-directory step — to create a directory, Write a file inside it.", ); } @@ -214,7 +214,7 @@ export function buildToolsSuffix( } if (has("Write")) { lines.push( - "- Write fully creates or overwrites one text file. Do not set `mode`. The path must include the intended filename, not just a directory.", + "- Write fully creates or overwrites one text file. The path must include the intended filename, not just a directory.", ); } if (has("Edit")) { @@ -647,10 +647,6 @@ export async function runAssistantWithTools(params: { signal?: AbortSignal, context?: ToolExecutionEventContext, ) => Promise; - preflightToolCall?: ( - toolCall: ToolCall, - signal?: AbortSignal, - ) => Promise<{ toolCall?: ToolCall; toolResult: ToolResultMessage } | null>; onTurnStart?: (round: number) => void; onTextDelta: (delta: string, round: number) => void; onThinkingDelta?: (delta: string, round: number) => void; @@ -723,7 +719,11 @@ export async function runAssistantWithTools(params: { const toolResultErrorFlags = new Map(); const toolCallsById = new Map(); - const streamPreflightToolResults = new Map(); + const incompleteToolCallArguments = new Map(); + const refusedTruncatedToolCallIds = new Set(); + const buildTruncatedToolCallText = (toolName: string, reason: string) => + `${toolName} was not executed: its arguments were truncated in transit (${reason}). ` + + `This is a transport error, not a mistake in your call — re-issue the complete ${toolName} call with full arguments.`; const parallelBatchKeyByToolCallId = new Map(); const parallelToolBatches = new Map(); const llmTools = params.tools ?? []; @@ -1093,16 +1093,6 @@ export async function runAssistantWithTools(params: { }); toolCallsById.set(toolCall.id, toolCall); - const preflightToolResult = streamPreflightToolResults.get(toolCall.id); - if (preflightToolResult) { - streamPreflightToolResults.delete(toolCall.id); - toolResultErrorFlags.set(toolCall.id, Boolean(preflightToolResult.isError)); - return { - content: preflightToolResult.content, - details: preflightToolResult.details ?? {}, - }; - } - if (tool.name === "Bash" || tool.name === "Agent") { const batchKey = parallelBatchKeyByToolCallId.get(toolCallId); if (batchKey) { @@ -1150,118 +1140,6 @@ export async function runAssistantWithTools(params: { agentTools = [...visibleAgentTools, ...hiddenProviderNativeWebSearchAgentTools]; let streamRound = 0; - function getToolCallFromStreamEvent(event: AssistantMessageEvent) { - if ( - event.type !== "toolcall_start" && - event.type !== "toolcall_delta" && - event.type !== "toolcall_end" - ) { - return null; - } - - const toolCall = - event.type === "toolcall_end" ? event.toolCall : event.partial.content[event.contentIndex]; - return toolCall?.type === "toolCall" - ? { - contentIndex: event.contentIndex, - toolCall, - partial: event.partial, - } - : null; - } - - function buildPreflightToolUseAssistant( - partial: AssistantMessage, - contentIndex: number, - toolCall: ToolCall, - ): AssistantMessage { - const content = partial.content.slice(); - content[contentIndex] = toolCall; - return { - ...partial, - content, - stopReason: "toolUse", - errorMessage: undefined, - }; - } - - function wrapStreamWithToolPreflight( - source: ReturnType, - signal: AbortSignal | undefined, - abortEarly: () => void, - cleanup: () => void, - ): ReturnType { - let preflightFinalMessage: AssistantMessage | null = null; - - return { - async *[Symbol.asyncIterator]() { - const iterator = source[Symbol.asyncIterator](); - try { - while (true) { - const next = await iterator.next(); - if (next.done) return; - - const event = next.value; - const candidate = getToolCallFromStreamEvent(event); - const effectiveToolCall = candidate - ? normalizeToolCallNameForExecution(candidate.toolCall) - : null; - if (!candidate || !effectiveToolCall || !params.preflightToolCall) { - yield event; - continue; - } - - const preflight = await params.preflightToolCall(effectiveToolCall, signal); - - if (!preflight) { - yield event; - continue; - } - - const completedToolCall = normalizeToolCallNameForExecution( - preflight.toolCall ?? effectiveToolCall, - ); - toolCallsById.set(completedToolCall.id, completedToolCall); - streamPreflightToolResults.set(completedToolCall.id, { - ...preflight.toolResult, - toolCallId: completedToolCall.id, - toolName: completedToolCall.name, - }); - preflightFinalMessage = buildPreflightToolUseAssistant( - candidate.partial, - candidate.contentIndex, - completedToolCall, - ); - - abortEarly(); - await iterator.return?.(); - - yield event; - if (event.type !== "toolcall_end") { - yield { - type: "toolcall_end", - contentIndex: candidate.contentIndex, - toolCall: completedToolCall, - partial: preflightFinalMessage, - }; - } - yield { - type: "done", - reason: "toolUse", - message: preflightFinalMessage, - }; - return; - } - } finally { - cleanup(); - } - }, - result() { - return preflightFinalMessage ? Promise.resolve(preflightFinalMessage) : source.result(); - }, - } as unknown as ReturnType; - } - const streamFn = (streamModel: typeof model, streamContext: Context, options?: any) => { const round = ++streamRound; const streamTools = @@ -1285,11 +1163,6 @@ export async function runAssistantWithTools(params: { const hostedSearchProbeId = shouldProbeHostedSearch ? createHostedSearchProbeId(params.providerId) : undefined; - const earlyPreflightAbortController = new AbortController(); - const streamAbortSignal = createLinkedAbortSignal([ - options?.signal, - earlyPreflightAbortController.signal, - ]); let streamOptions: StreamOptionsEx = { ...(options ?? {}), apiKey: options?.apiKey ?? params.runtime.apiKey, @@ -1300,7 +1173,7 @@ export async function runAssistantWithTools(params: { }, hostedSearchProbeId, ), - signal: streamAbortSignal.signal, + signal: options?.signal, sessionId: options?.sessionId ?? params.sessionId, cacheRetention: options?.cacheRetention ?? @@ -1362,12 +1235,36 @@ export async function runAssistantWithTools(params: { ); const sourceStream = streamSimpleByApi(streamModel, effectiveContext, streamOptions); - return wrapStreamWithToolPreflight( - sourceStream, - options?.signal, - () => earlyPreflightAbortController.abort(), - streamAbortSignal.cleanup, - ); + return wrapStreamWithToolCallArgumentGuard(sourceStream, (toolCall, reason) => { + incompleteToolCallArguments.set(toolCall.id, reason); + }); + }; + + // A truncated call whose repaired arguments also fail schema validation + // never reaches beforeToolCall (pi-agent-core validates first), so the + // model would see a schema error blaming its own call. Rewrite such tool + // results into the truthful transport-error teaching before the next turn. + const reconcileTruncatedToolResults = () => { + if (incompleteToolCallArguments.size === 0) return; + const messages = getAgentMessages(agent); + let changed = false; + const next = messages.map((message) => { + if (message.role !== "toolResult" || !message.isError) return message; + const reason = incompleteToolCallArguments.get(message.toolCallId); + if (!reason) return message; + incompleteToolCallArguments.delete(message.toolCallId); + refusedTruncatedToolCallIds.add(message.toolCallId); + changed = true; + return { + ...message, + content: [ + { type: "text" as const, text: buildTruncatedToolCallText(message.toolName, reason) }, + ], + }; + }); + if (changed && agent) { + agent.state.messages = next; + } }; agent = new Agent({ @@ -1389,6 +1286,15 @@ export async function runAssistantWithTools(params: { const effectiveAssistantMessage = normalizeAssistantToolCallNamesForExecution(assistantMessage); toolCallsById.set(effectiveToolCall.id, effectiveToolCall); + const truncationReason = incompleteToolCallArguments.get(effectiveToolCall.id); + if (truncationReason) { + refusedTruncatedToolCallIds.add(effectiveToolCall.id); + incompleteToolCallArguments.delete(effectiveToolCall.id); + return { + block: true, + reason: buildTruncatedToolCallText(effectiveToolCall.name, truncationReason), + }; + } if (effectiveToolCall.name !== "Agent") { return undefined; } @@ -1398,7 +1304,16 @@ export async function runAssistantWithTools(params: { effectiveToolCall.name, ); if (!rawGroup || rawGroup.length <= 1) return undefined; - const group = rawGroup.map(normalizeToolCallNameForExecution); + // A member with truncated arguments must not ride into execution on a + // sibling's batch — it is refused individually by the guard above. + const group = rawGroup + .map(normalizeToolCallNameForExecution) + .filter( + (call) => + !incompleteToolCallArguments.has(call.id) && + !refusedTruncatedToolCallIds.has(call.id), + ); + if (group.length <= 1) return undefined; const batchKey = buildParallelToolBatchKey(group); if (!parallelToolBatches.has(batchKey)) { @@ -1420,6 +1335,7 @@ export async function runAssistantWithTools(params: { if (override) { applyTurnContextOverride(override); } + reconcileTruncatedToolResults(); return getAgentMessages(agent).slice(); }, }); diff --git a/crates/agent-gui/src/lib/chat/runner/toolCallArgumentGuard.ts b/crates/agent-gui/src/lib/chat/runner/toolCallArgumentGuard.ts new file mode 100644 index 00000000..bd659356 --- /dev/null +++ b/crates/agent-gui/src/lib/chat/runner/toolCallArgumentGuard.ts @@ -0,0 +1,128 @@ +import { + type AssistantMessageEventStream, + parseJsonWithRepair, + parseStreamingJson, + type ToolCall, +} from "@earendil-works/pi-ai"; + +export type OnIncompleteToolCall = (toolCall: ToolCall, reason: string) => void; + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "undefined"; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`; +} + +function bufferIsCompleteJson(buffer: string): boolean { + try { + parseJsonWithRepair(buffer); + return true; + } catch { + return false; + } +} + +/** + * Watches a provider event stream for tool calls whose argument JSON never + * arrived complete — a half-streamed buffer that lenient repair would silently + * turn into plausible-but-wrong arguments (e.g. a path cut at its first + * segment). The wrapper never alters, reorders, or aborts events; it only + * reports incomplete calls so the runner can refuse to execute them. + */ +export function wrapStreamWithToolCallArgumentGuard( + source: AssistantMessageEventStream, + onIncompleteToolCall: OnIncompleteToolCall, +): AssistantMessageEventStream { + const rawArgumentsByContentIndex = new Map(); + const lastDeltaByContentIndex = new Map(); + const flagged = new Set(); + + const flag = (toolCall: ToolCall, reason: string) => { + if (flagged.has(toolCall.id)) return; + flagged.add(toolCall.id); + onIncompleteToolCall(toolCall, reason); + }; + + const checkEndedToolCall = (contentIndex: number, toolCall: ToolCall) => { + const buffer = rawArgumentsByContentIndex.get(contentIndex) ?? ""; + const lastDelta = lastDeltaByContentIndex.get(contentIndex) ?? ""; + rawArgumentsByContentIndex.delete(contentIndex); + lastDeltaByContentIndex.delete(contentIndex); + // No deltas observed (or whitespace only): the provider delivered the + // arguments whole on the end event — nothing to distrust. + if (!buffer.trim()) return; + if (bufferIsCompleteJson(buffer)) return; + const finalArguments = stableStringify(toolCall.arguments ?? {}); + // Cumulative-snapshot or duplicated-frame streams concatenate into invalid + // JSON, but their final delta is a complete standalone copy of the + // arguments — that stream is healthy. + if (lastDelta.trim() && bufferIsCompleteJson(lastDelta)) { + if (stableStringify(parseStreamingJson(lastDelta)) === finalArguments) return; + } + // The raw buffer never formed complete JSON. If the finalized arguments + // equal the lenient repair of that same truncated buffer, the provider had + // no independent complete source — the call is genuinely truncated. If + // they differ, the end event carried its own complete arguments (e.g. + // openai-responses `arguments.done`) and the buffer mismatch is benign. + const repaired = parseStreamingJson(buffer); + if (stableStringify(repaired) === finalArguments) { + flag(toolCall, "the argument JSON stream ended before it was complete"); + } + }; + + const endedByToolCallId = new Set(); + + const checkDanglingToolCalls = (content: ReadonlyArray<{ type: string }>) => { + for (const block of content) { + if (block.type !== "toolCall") continue; + const toolCall = block as unknown as ToolCall; + if (!endedByToolCallId.has(toolCall.id)) { + flag(toolCall, "the stream ended before this tool call finished streaming"); + } + } + }; + + return { + async *[Symbol.asyncIterator]() { + for await (const event of source) { + switch (event.type) { + case "toolcall_start": { + rawArgumentsByContentIndex.set(event.contentIndex, ""); + lastDeltaByContentIndex.delete(event.contentIndex); + break; + } + case "toolcall_delta": { + const buffer = rawArgumentsByContentIndex.get(event.contentIndex) ?? ""; + rawArgumentsByContentIndex.set(event.contentIndex, buffer + event.delta); + if (event.delta) { + lastDeltaByContentIndex.set(event.contentIndex, event.delta); + } + break; + } + case "toolcall_end": { + endedByToolCallId.add(event.toolCall.id); + checkEndedToolCall(event.contentIndex, event.toolCall); + break; + } + case "done": { + checkDanglingToolCalls(event.message.content); + break; + } + default: + break; + } + yield event; + } + }, + result() { + return source.result(); + }, + } as unknown as AssistantMessageEventStream; +} diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 0ee2fe3a..f1993ca3 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -22,7 +22,6 @@ import type { BuiltinToolBundle, BuiltinToolExecutionContext, BuiltinToolMetadata, - BuiltinToolPreflightResult, } from "./builtinTypes"; import { createCronTools } from "./cronTools"; import { createCustomSystemTools } from "./customSystemTools"; @@ -48,10 +47,6 @@ export type BuiltinToolRegistry = { signal?: AbortSignal, context?: BuiltinToolExecutionContext, ) => Promise; - preflightToolCall: ( - toolCall: ToolCall, - signal?: AbortSignal, - ) => Promise; metadataByName: Map; hasTool: (toolName: string) => boolean; }; @@ -111,7 +106,6 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg const tools: BuiltinToolBundle["tools"] = []; const metadataByName = new Map(); const executorsByName = new Map(); - const preflightsByName = new Map>(); const canonicalToolNameByLookupKey = new Map(); const registerCanonicalToolName = (toolName: string) => { @@ -138,9 +132,6 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg } tools.push(tool); executorsByName.set(tool.name, bundle.executeToolCall); - if (bundle.preflightToolCall) { - preflightsByName.set(tool.name, bundle.preflightToolCall); - } registerCanonicalToolName(tool.name); const metadata = bundle.metadataByName.get(tool.name); if (metadata) { @@ -182,15 +173,6 @@ function createBuiltinToolRegistry(bundles: BuiltinToolBundle[]): BuiltinToolReg resolvedToolName === toolCall.name ? toolCall : { ...toolCall, name: resolvedToolName }; return execute(effectiveToolCall, signal, context); }, - async preflightToolCall(toolCall, signal) { - const resolvedToolName = resolveToolName(toolCall.name); - if (!resolvedToolName) return null; - const preflight = preflightsByName.get(resolvedToolName); - if (!preflight) return null; - const effectiveToolCall = - resolvedToolName === toolCall.name ? toolCall : { ...toolCall, name: resolvedToolName }; - return preflight(effectiveToolCall, signal); - }, }; } diff --git a/crates/agent-gui/src/lib/tools/builtinTypes.ts b/crates/agent-gui/src/lib/tools/builtinTypes.ts index 25a9cae5..b188bada 100644 --- a/crates/agent-gui/src/lib/tools/builtinTypes.ts +++ b/crates/agent-gui/src/lib/tools/builtinTypes.ts @@ -41,21 +41,10 @@ export type BuiltinToolExecutor = ( context?: BuiltinToolExecutionContext, ) => Promise; -export type BuiltinToolPreflightResult = { - toolCall?: ToolCall; - toolResult: ToolResultMessage; -}; - -export type BuiltinToolPreflight = ( - toolCall: ToolCall, - signal?: AbortSignal, -) => Promise; - export type BuiltinToolBundle = TExtra & { groupId: BuiltinToolGroupId; tools: Tool[]; executeToolCall: BuiltinToolExecutor; - preflightToolCall?: BuiltinToolPreflight; metadataByName: Map; }; diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index 9a790689..f5065779 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -9,7 +9,6 @@ import { invoke } from "@tauri-apps/api/core"; import { Type } from "typebox"; import { type BuiltinToolBundle, - type BuiltinToolPreflightResult, type BuiltinToolResultDetails, createBuiltinMetadataMap, type DeleteResultDetails, @@ -38,7 +37,7 @@ type ToolOk) { return Type.Object(properties as any, { additionalProperties: false }); @@ -238,18 +237,6 @@ async function invokeFsToolCommand(params: { } } -function buildToolErrorResult(toolCall: ToolCall, text: string): ToolResultMessage { - return { - role: "toolResult", - toolCallId: toolCall.id, - toolName: toolCall.name, - content: [{ type: "text", text }], - details: {}, - isError: true, - timestamp: Date.now(), - }; -} - export function createFsTools(params: { workdir: string; fileState: FileToolState; @@ -286,7 +273,6 @@ export function createFsTools(params: { skillAccessPolicy, resolveSkillsRootDir, }); - const writePreflightSafePathByToolCallId = new Map(); // Loop breaker: models sometimes retry a failing call with identical // arguments. Track consecutive identical failures and escalate the error @@ -323,8 +309,6 @@ export function createFsTools(params: { type NormalizedWriteArgs = { path: unknown; content: string; - hasContentArgument: boolean; - mode: "rewrite"; }; function normalizeWriteArgs(args: unknown): NormalizedWriteArgs { @@ -332,19 +316,12 @@ export function createFsTools(params: { args && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}; - assertKnownArguments("Write", record, ["path", "content", "mode"]); - const mode = record.mode; - if (mode !== undefined && mode !== null && mode !== "" && mode !== "rewrite") { - throw new Error("Write.mode is deprecated. Omit it, or pass rewrite for compatibility."); - } if ("content" in record && typeof record.content !== "string") { throw new Error("Write.content must be a string."); } return { path: record.path, content: typeof record.content === "string" ? record.content : "", - hasContentArgument: "content" in record, - mode: "rewrite", }; } @@ -369,27 +346,6 @@ export function createFsTools(params: { }); } - function completeWriteToolCallForPreflight( - toolCall: ToolCall, - normalized?: { path?: string; content?: string }, - ): ToolCall { - const args = - toolCall.arguments && typeof toolCall.arguments === "object" ? toolCall.arguments : {}; - return { - ...toolCall, - arguments: { - ...args, - ...(typeof normalized?.path === "string" ? { path: normalized.path } : {}), - content: - typeof normalized?.content === "string" - ? normalized.content - : typeof args.content === "string" - ? args.content - : "", - }, - }; - } - const toolRead: Tool = { name: "Read", description: @@ -527,23 +483,30 @@ export function createFsTools(params: { }), }; - const toolWrite: Tool = { + const toolWrite: Tool & { prepareArguments?: (args: unknown) => unknown } = { name: "Write", description: - "Create a new text file or fully overwrite an existing workspace or enabled Skill text file. Pass only `path` and `content`; do not set `mode`. For new files, `path` must include the intended filename, for example `notes/todo.txt`; Write creates missing parent directories but does not choose filenames from directory paths. Existing files must have been fully Read first so the tool can reject stale rewrites. Use Edit for small changes.", + "Create a new text file or fully overwrite an existing workspace or enabled Skill text file. `path` must include the intended filename, for example `notes/todo.txt`; Write creates missing parent directories but does not choose filenames from directory paths. Overwriting an existing file replaces its entire content and is checked against the file's current on-disk state automatically. Use Edit for small changes.", + // `path` is declared before `content` on purpose: models tend to emit + // arguments in schema order, and a path that streams first keeps live + // previews and transport recovery well-formed for large contents. parameters: strictToolParameters({ path: Type.String({ description: "Required file path. Prefer the workspace-relative or skill:// form returned by other tools; absolute and ~/... forms are auto-normalized. Must resolve inside the workspace or an enabled Skill.", }), content: Type.String({ description: "Entire text content to write" }), - mode: Type.Optional( - Type.Union([Type.Literal("rewrite"), Type.Literal("")], { - description: - "Deprecated compatibility field. Omit this argument; empty string and rewrite are accepted as rewrite.", - }), - ), }), + // Legacy tolerance: replayed histories teach some models to send the + // retired `mode` field; strip it before schema validation instead of + // failing the call. + prepareArguments: (args) => { + if (args && typeof args === "object" && !Array.isArray(args) && "mode" in args) { + const { mode: _legacyMode, ...rest } = args as Record; + return rest; + } + return args; + }, }; const toolEdit: Tool = { @@ -704,6 +667,7 @@ export function createFsTools(params: { const allowedArgumentsByToolName: Record = { Read: ["path", "start_line", "limit", "page_start", "page_limit", "cell_start", "cell_limit"], Image: ["path", "paths", "url", "urls", "base64", "base64s", "mimeType", "source", "sources"], + // "mode" is legacy tolerance: no longer in the schema, silently ignored. Write: ["path", "content", "mode"], Edit: ["path", "old_string", "new_string", "expected_replacements", "replace_all"], Delete: ["path"], @@ -1029,28 +993,33 @@ export function createFsTools(params: { }; } - function buildEditFullReadLimitMessage(resolved: ResolvedPath, totalLines: number) { + function buildFullReadLimitMessage(toolName: string, resolved: ResolvedPath, totalLines: number) { const target = formatResolvedTarget(resolved); - return `Edit requires a full-file Read first: ${target} has ${totalLines} lines, which exceeds the automatic full-read limit (${AUTO_EDIT_FULL_READ_MAX_LINES}). Call Read with path="${target}" and limit=${totalLines}, then retry Edit with the same path.`; + return `${toolName} requires a full-file Read first: ${target} has ${totalLines} lines, which exceeds the automatic full-read limit (${AUTO_FULL_READ_MAX_LINES}). Call Read with path="${target}" and limit=${totalLines}, then retry ${toolName} with the same path.`; } - async function primeFullTextSnapshotForEdit(params: { + async function primeFullTextSnapshot(params: { + toolName: "Edit" | "Write"; resolved: ResolvedPath; path: string; signal?: AbortSignal; + status?: PathStatusCommandResponse; }) { - const status = await readPathStatus("Edit", params.resolved, params.path); + const status = + params.status ?? (await readPathStatus(params.toolName, params.resolved, params.path)); const key = statePathKey(params.resolved, status.fileId); const existingSnapshot = fileState.getLatestFullText(key); if (existingSnapshot) { - return { snapshot: existingSnapshot, autoRead: false }; + return { snapshot: existingSnapshot, autoRead: false, status }; } const latest = fileState.getLatest(key); - let readLimit = AUTO_EDIT_FULL_READ_MAX_LINES; + let readLimit = AUTO_FULL_READ_MAX_LINES; if (latest?.kind === "text" && latest.totalLines > 0) { - if (latest.totalLines > AUTO_EDIT_FULL_READ_MAX_LINES) { - throw new Error(buildEditFullReadLimitMessage(params.resolved, latest.totalLines)); + if (latest.totalLines > AUTO_FULL_READ_MAX_LINES) { + throw new Error( + buildFullReadLimitMessage(params.toolName, params.resolved, latest.totalLines), + ); } readLimit = latest.totalLines; } @@ -1065,15 +1034,17 @@ export function createFsTools(params: { const snapshot = fileState.getLatestFullText(key); if (snapshot) { - return { snapshot, autoRead: true }; + return { snapshot, autoRead: true, status }; } const latestAfterRead = fileState.getLatest(key); if (latestAfterRead?.kind === "text" && latestAfterRead.isPartialView) { - throw new Error(buildEditFullReadLimitMessage(params.resolved, latestAfterRead.totalLines)); + throw new Error( + buildFullReadLimitMessage(params.toolName, params.resolved, latestAfterRead.totalLines), + ); } - throw new Error(buildRequiresFullReadText("Edit", params.resolved)); + throw new Error(buildRequiresFullReadText(params.toolName, params.resolved)); } function normalizeRequiredImageSource(input: unknown, label: string) { @@ -1398,26 +1369,38 @@ export function createFsTools(params: { const content = writeArgs.content; const status = await readPathStatus("Write", resolved, path); - const key = statePathKey(resolved, status.fileId); - const latest = fileState.getLatest(key); - if (latest?.kind === "text" && latest.isPartialView) { - throw new Error(buildRequiresFullReadText("Write", resolved, latest.totalLines)); + if (status.exists && status.kind === "dir") { + throw new Error(buildWriteDirectoryText(resolved)); } - const fullSnapshot = fileState.getLatestFullText(key); + // Overwriting an existing file needs a full-text snapshot as the staleness + // baseline; auto-read it when the model has not Read the file itself. + const primed = status.exists + ? await primeFullTextSnapshot({ toolName: "Write", resolved, path, signal, status }) + : null; - const res = await invokeFsToolCommand({ - toolName: "Write", - resolved, - command: "fs_write_text", - args: { - workdir: resolved.root, - path, - content, - mode: "rewrite", - expected_mtime_ms: fullSnapshot?.mtimeMs, - expected_content_hash: fullSnapshot?.contentHash, - }, - }); + let res: WriteCommandResponse; + try { + res = await invokeFsToolCommand({ + toolName: "Write", + resolved, + command: "fs_write_text", + args: { + workdir: resolved.root, + path, + content, + mode: "rewrite", + expected_mtime_ms: primed?.snapshot.mtimeMs, + expected_content_hash: primed?.snapshot.contentHash, + }, + }); + } catch (error) { + if (primed?.autoRead) { + // The auto-read snapshot was never shown to the model; drop it so a + // follow-up Read returns real content instead of an "unchanged" stub. + fileState.clear(statePathKey(resolved, status.fileId)); + } + throw error; + } const details: WriteResultDetails = { kind: "write", @@ -1441,96 +1424,17 @@ export function createFsTools(params: { content: [ { type: "text", - text: details.existedBefore - ? `File updated successfully at: ${formatResolvedTarget(resolved)}` - : `File created successfully at: ${formatResolvedTarget(resolved)}`, + text: + (details.existedBefore + ? `File updated successfully at: ${formatResolvedTarget(resolved)}` + : `File created successfully at: ${formatResolvedTarget(resolved)}`) + + (primed?.autoRead ? "\nautoRead=full" : ""), }, ], details, }; } - async function preflightWrite( - toolCall: ToolCall, - signal?: AbortSignal, - ): Promise { - if (signal?.aborted) return null; - - const writeArgs = normalizeWriteArgs(toolCall.arguments); - const rawPath = typeof writeArgs.path === "string" ? writeArgs.path : ""; - if (!rawPath.trim()) return null; - - const { resolved, path } = await resolveWriteTarget(writeArgs); - if (!path) { - return { - toolCall: completeWriteToolCallForPreflight(toolCall, { - content: writeArgs.content, - }), - toolResult: buildToolErrorResult( - toolCall, - annotateRepeatedFailure(toolCall, "Write.path must identify a file"), - ), - }; - } - - const preflightPathKey = `${resolved.root}\0${path}`; - if (writePreflightSafePathByToolCallId.get(toolCall.id) === preflightPathKey) { - return null; - } - - const status = await readPathStatus("Write", resolved, path); - if (!status.exists) { - writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); - return null; - } - - const key = statePathKey(resolved, status.fileId); - const latest = fileState.getLatest(key); - if (latest?.kind === "text" && latest.isPartialView) { - return { - toolCall: completeWriteToolCallForPreflight(toolCall, { - path: resolved.displayPath, - content: writeArgs.content, - }), - toolResult: buildToolErrorResult( - toolCall, - annotateRepeatedFailure( - toolCall, - buildRequiresFullReadText("Write", resolved, latest.totalLines), - ), - ), - }; - } - - if (fileState.getLatestFullText(key)) { - writePreflightSafePathByToolCallId.set(toolCall.id, preflightPathKey); - return null; - } - - const completedToolCall = completeWriteToolCallForPreflight(toolCall, { - path: resolved.displayPath, - content: writeArgs.content, - }); - if (status.kind === "dir") { - if (!writeArgs.hasContentArgument) return null; - return { - toolCall: completedToolCall, - toolResult: buildToolErrorResult( - toolCall, - annotateRepeatedFailure(toolCall, buildWriteDirectoryText(resolved)), - ), - }; - } - - return { - toolCall: completedToolCall, - toolResult: buildToolErrorResult( - toolCall, - annotateRepeatedFailure(toolCall, buildRequiresFullReadText("Write", resolved)), - ), - }; - } - async function execEdit(args: any, signal?: AbortSignal): Promise> { if (signal?.aborted) throw new Error("Cancelled"); @@ -1551,27 +1455,39 @@ export function createFsTools(params: { throw new Error("Edit.old_string must be a non-empty string"); } - const { snapshot, autoRead } = await primeFullTextSnapshotForEdit({ + const primed = await primeFullTextSnapshot({ + toolName: "Edit", resolved, path, signal, }); + const { snapshot, autoRead } = primed; - const res = await invokeFsToolCommand({ - toolName: "Edit", - resolved, - command: "fs_edit_text", - args: { - workdir: resolved.root, - path, - old_string, - new_string, - expected_replacements, - replace_all, - expected_mtime_ms: snapshot.mtimeMs, - expected_content_hash: snapshot.contentHash, - }, - }); + let res: EditCommandResponse; + try { + res = await invokeFsToolCommand({ + toolName: "Edit", + resolved, + command: "fs_edit_text", + args: { + workdir: resolved.root, + path, + old_string, + new_string, + expected_replacements, + replace_all, + expected_mtime_ms: snapshot.mtimeMs, + expected_content_hash: snapshot.contentHash, + }, + }); + } catch (error) { + if (autoRead) { + // The auto-read snapshot was never shown to the model; drop it so a + // follow-up Read returns real content instead of an "unchanged" stub. + fileState.clear(statePathKey(resolved, primed.status.fileId)); + } + throw error; + } const details: EditResultDetails = { kind: "edit", @@ -1940,34 +1856,10 @@ export function createFsTools(params: { } } - async function preflightToolCall( - toolCall: ToolCall, - signal?: AbortSignal, - ): Promise { - try { - switch (toolCall.name) { - case "Write": - return await preflightWrite(toolCall, signal); - default: - return null; - } - } catch (err) { - return { - toolCall: - toolCall.name === "Write" ? completeWriteToolCallForPreflight(toolCall) : toolCall, - toolResult: buildToolErrorResult( - toolCall, - annotateRepeatedFailure(toolCall, asErrorMessage(err)), - ), - }; - } - } - return { groupId: "fs", tools, executeToolCall, - preflightToolCall, metadataByName: createBuiltinMetadataMap([ [ "Read", diff --git a/crates/agent-gui/src/lib/tools/pathUtils.ts b/crates/agent-gui/src/lib/tools/pathUtils.ts index a3e85b22..a3cdda59 100644 --- a/crates/agent-gui/src/lib/tools/pathUtils.ts +++ b/crates/agent-gui/src/lib/tools/pathUtils.ts @@ -302,11 +302,7 @@ export class ToolPathResolver { if (!skillsRootDir) { throw new Error(`${options.label} points to a Skill path, but Skills are not enabled`); } - const sanitized = sanitizeRelativePath( - relativePath ?? "", - options.label, - options.required === true, - ); + const sanitized = sanitizeRelativePath(relativePath ?? "", options.label, false); if (!sanitized && isSkillAccessPolicyRestrictive(this.skillAccessPolicy)) { throw new Error( buildSkillAccessDeniedMessage({ @@ -315,6 +311,11 @@ export class ToolPathResolver { }), ); } + if (!sanitized && options.required === true) { + throw new Error( + `${options.label} must include the skill name and a file path after skill://, for example "skill:///SKILL.md". "skill://" alone does not identify a file.`, + ); + } const operation = operationForIntent(options.intent, options.label); if (sanitized) { assertSkillPathAllowedByPolicy(this.skillAccessPolicy, sanitized, operation); diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 461a551f..4422f742 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -780,8 +780,6 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP tools: combinedTools, subagentScheduler, executeToolCall: combinedExecutor, - preflightToolCall: (toolCall, signal) => - builtinRegistry.preflightToolCall(toolCall, signal), onTurnStart: (round) => { activeAgentRound = round; streamedAgentText = ""; diff --git a/crates/agent-gui/test/chat/agent-runner.test.mjs b/crates/agent-gui/test/chat/agent-runner.test.mjs index 2f1e266b..eb46fb97 100644 --- a/crates/agent-gui/test/chat/agent-runner.test.mjs +++ b/crates/agent-gui/test/chat/agent-runner.test.mjs @@ -1130,30 +1130,106 @@ test("runAssistantWithTools emits streaming tool call argument deltas before fin ); }); -test("runAssistantWithTools stops streaming Write content when preflight returns an error", async () => { - const finalWriteCall = createToolCall("call_00_preflight_write", "Write", { - path: "test6/gobang.html", - content: "\nSHOULD_NOT_STREAM\n", +function createRawFragmentToolCallStream(finalToolCall, fragments, options = {}) { + const assistant = createAssistant([finalToolCall], "toolUse", options.extra ?? {}); + const partialFor = (bufferedArguments) => ({ + ...assistant, + content: [{ ...finalToolCall, arguments: bufferedArguments }], }); + const events = [ + { type: "start", partial: { ...assistant, content: [] } }, + { type: "toolcall_start", contentIndex: 0, partial: partialFor({}) }, + ...fragments.map((fragment) => ({ + type: "toolcall_delta", + contentIndex: 0, + delta: fragment, + partial: partialFor(finalToolCall.arguments), + })), + ...(options.omitToolCallEnd + ? [] + : [ + { + type: "toolcall_end", + contentIndex: 0, + toolCall: finalToolCall, + partial: { ...assistant, content: [finalToolCall] }, + }, + ]), + { type: "done", reason: "toolUse", message: assistant }, + ]; + return createQueuedStream(events, assistant); +} + +test("runAssistantWithTools executes a content-first streaming Write untouched (009 regression)", async () => { + // The historical bug: a mid-stream preflight aborted the model while the + // path argument was still streaming, freezing "test2" as the final path. + const completeArguments = { + content: "# New File via Workspace-Relative Path\nCreated at test2/new-write-test.md\n", + path: "test2/new-write-test.md", + }; + const finalWriteCall = createToolCall("call_00_content_first_write", "Write", completeArguments); + const rawJson = JSON.stringify(completeArguments); + const cut = rawJson.indexOf("test2") + "test2".length; resetFakeStreams( - createToolCallDeltaStream(finalWriteCall, [ - createToolCall(finalWriteCall.id, "Write", {}), - createToolCall(finalWriteCall.id, "Write", { path: "test6/gobang.html" }), - createToolCall(finalWriteCall.id, "Write", { - path: "test6/gobang.html", - content: "\nSHOULD_NOT_STREAM", - }), + createRawFragmentToolCallStream(finalWriteCall, [rawJson.slice(0, cut), rawJson.slice(cut)]), + createTextAssistant("after content-first write"), + ); + const writeTool = { + name: "Write", + description: "Write files", + parameters: { type: "object", properties: {} }, + }; + const toolResults = []; + const { params, executedToolCalls } = createBaseParams({ + tools: [writeTool], + context: { + systemPrompt: "Base system prompt", + messages: [{ role: "user", content: "Start", timestamp: 1 }], + tools: [writeTool], + }, + onToolResult: (toolCall, toolResult) => { + toolResults.push({ toolCall, toolResult }); + }, + }); + + const result = await runAssistantWithTools(params); + + assert.equal(executedToolCalls.length, 1); + assert.deepEqual(executedToolCalls[0].arguments, completeArguments); + assert.equal(toolResults.length, 1); + assert.equal(Boolean(toolResults[0].toolResult.isError), false); + const assistantToolCall = result.emittedMessages[0].content.find( + (block) => block.type === "toolCall", + ); + assert.equal(assistantToolCall.arguments.path, "test2/new-write-test.md"); + assert.deepEqual( + result.emittedMessages.map((message) => message.role), + ["assistant", "toolResult", "assistant"], + ); +}); + +test("runAssistantWithTools refuses a Write whose argument stream was truncated", async () => { + // Simulates pi-ai finalizing a half-streamed buffer: the raw fragments stop + // mid-path and the end-event arguments equal the lenient repair of that + // same truncated buffer. + const truncatedBuffer = '{"content": "# Temp File\\n", "path": "test2'; + const truncatedWriteCall = createToolCall("call_00_truncated_write", "Write", { + content: "# Temp File\n", + path: "test2", + }); + resetFakeStreams( + createRawFragmentToolCallStream(truncatedWriteCall, [ + truncatedBuffer.slice(0, 20), + truncatedBuffer.slice(20), ]), - createTextAssistant("after read-first reminder"), + createTextAssistant("after truncated write"), ); const writeTool = { name: "Write", description: "Write files", parameters: { type: "object", properties: {} }, }; - const deltas = []; const toolResults = []; - const preflightCalls = []; const { params, executedToolCalls } = createBaseParams({ tools: [writeTool], context: { @@ -1161,77 +1237,199 @@ test("runAssistantWithTools stops streaming Write content when preflight returns messages: [{ role: "user", content: "Start", timestamp: 1 }], tools: [writeTool], }, - async preflightToolCall(toolCall) { - preflightCalls.push({ - id: toolCall.id, - name: toolCall.name, - arguments: { ...(toolCall.arguments ?? {}) }, - }); - if (toolCall.name !== "Write" || typeof toolCall.arguments?.path !== "string") { - return null; - } - const completedToolCall = { - ...toolCall, - arguments: { - ...toolCall.arguments, - content: - typeof toolCall.arguments.content === "string" ? toolCall.arguments.content : "", + onToolResult: (toolCall, toolResult) => { + toolResults.push({ toolCall, toolResult }); + }, + }); + + await runAssistantWithTools(params); + + assert.equal(executedToolCalls.length, 0); + assert.equal(toolResults.length, 1); + assert.equal(toolResults[0].toolResult.isError, true); + assert.match(toolResults[0].toolResult.content[0].text, /truncated in transit/); + assert.match(toolResults[0].toolResult.content[0].text, /re-issue the complete Write call/); +}); + +test("runAssistantWithTools keeps a truncated Agent call out of its siblings' parallel batch", async () => { + const agentA = { + type: "toolCall", + id: "call-agent-batch-a", + name: "Agent", + arguments: { id: "a", prompt: "Ask A" }, + }; + const truncatedBuffer = '{"id": "b", "prompt": "Ask B with a long detailed'; + const agentB = { + type: "toolCall", + id: "call-agent-batch-b", + name: "Agent", + arguments: { id: "b", prompt: "Ask B with a long detailed" }, + }; + const assistant = createAssistant([agentA, agentB], "toolUse"); + resetFakeStreams( + createQueuedStream( + [ + { type: "start", partial: { ...assistant, content: [] } }, + { type: "toolcall_start", contentIndex: 0, partial: assistant }, + { + type: "toolcall_delta", + contentIndex: 0, + delta: JSON.stringify(agentA.arguments), + partial: assistant, }, - }; - return { - toolCall: completedToolCall, - toolResult: { - role: "toolResult", - toolCallId: completedToolCall.id, - toolName: completedToolCall.name, - content: [ - { - type: "text", - text: "Write requires a full-file Read first for existing files: test6/gobang.html.", - }, - ], - details: {}, - isError: true, - timestamp: Date.now(), + { type: "toolcall_end", contentIndex: 0, toolCall: agentA, partial: assistant }, + { type: "toolcall_start", contentIndex: 1, partial: assistant }, + { + type: "toolcall_delta", + contentIndex: 1, + delta: truncatedBuffer.slice(0, 24), + partial: assistant, }, - }; - }, - onToolCallDelta: (toolCall) => { - deltas.push({ - id: toolCall.id, - name: toolCall.name, - arguments: { ...(toolCall.arguments ?? {}) }, - }); + { + type: "toolcall_delta", + contentIndex: 1, + delta: truncatedBuffer.slice(24), + partial: assistant, + }, + { type: "toolcall_end", contentIndex: 1, toolCall: agentB, partial: assistant }, + { type: "done", reason: "toolUse", message: assistant }, + ], + assistant, + ), + createTextAssistant("after batch"), + ); + const agentTool = { + name: "Agent", + description: "Delegate", + parameters: { type: "object", properties: {} }, + }; + const toolResults = []; + const { params, executedToolCalls } = createBaseParams({ + tools: [agentTool], + context: { + systemPrompt: "Base system prompt", + messages: [{ role: "user", content: "Start", timestamp: 1 }], + tools: [agentTool], }, onToolResult: (toolCall, toolResult) => { toolResults.push({ toolCall, toolResult }); }, }); - const result = await runAssistantWithTools(params); + await runAssistantWithTools(params); + // Only the intact call may execute — the truncated sibling must not ride + // into execution on the batch. assert.deepEqual( - deltas.map((delta) => delta.arguments), - [{}, { path: "test6/gobang.html" }], + executedToolCalls.map((call) => call.id), + ["call-agent-batch-a"], ); - assert.equal( - preflightCalls.some((call) => String(call.arguments.content ?? "").includes("SHOULD_NOT_STREAM")), - false, + const refused = toolResults.find((entry) => entry.toolCall.id === "call-agent-batch-b"); + assert.ok(refused); + assert.equal(refused.toolResult.isError, true); + assert.match(refused.toolResult.content[0].text, /truncated in transit/); +}); + +test("runAssistantWithTools rewrites schema-validation errors for truncated calls into the transport teaching", async () => { + // Truncation cut the stream before `content` started, so the repaired + // arguments also fail Write's real schema. pi-agent-core validates before + // beforeToolCall, so the refusal hook never runs — the rewrite pass must + // still deliver the truthful teaching to the model on the next turn. + const truncatedBuffer = '{"path": "test2'; + const truncatedWriteCall = createToolCall("call_00_schema_truncated", "Write", { + path: "test2", + }); + resetFakeStreams( + createRawFragmentToolCallStream(truncatedWriteCall, [ + truncatedBuffer.slice(0, 9), + truncatedBuffer.slice(9), + ]), + createTextAssistant("after schema-truncated write"), ); + const writeTool = { + name: "Write", + description: "Write files", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" }, + }, + required: ["path", "content"], + additionalProperties: false, + }, + }; + const toolResults = []; + const { params, executedToolCalls } = createBaseParams({ + tools: [writeTool], + context: { + systemPrompt: "Base system prompt", + messages: [{ role: "user", content: "Start", timestamp: 1 }], + tools: [writeTool], + }, + onToolResult: (toolCall, toolResult) => { + toolResults.push({ toolCall, toolResult }); + }, + }); + + await runAssistantWithTools(params); + assert.equal(executedToolCalls.length, 0); + // Prove the exercised path: schema validation rejected the call before the + // beforeToolCall refusal hook could run. assert.equal(toolResults.length, 1); - assert.equal(toolResults[0].toolResult.isError, true); - assert.match(toolResults[0].toolResult.content[0].text, /full-file Read first/); - const earlyAssistantToolCall = result.emittedMessages[0].content.find( - (block) => block.type === "toolCall", + assert.match(toolResults[0].toolResult.content[0].text, /Validation failed/); + // The context sent to the model on the follow-up turn must carry the + // transport teaching, not a schema error blaming the corrupted arguments. + const followUpContext = observedStreamContexts.at(-1); + const followUpToolResult = followUpContext.messages.find( + (message) => message.role === "toolResult" && message.toolCallId === truncatedWriteCall.id, ); - assert.equal(earlyAssistantToolCall.arguments.path, "test6/gobang.html"); - assert.equal(earlyAssistantToolCall.arguments.content, ""); - assert.equal(JSON.stringify(result.emittedMessages[0]).includes("SHOULD_NOT_STREAM"), false); - assert.deepEqual( - result.emittedMessages.map((message) => message.role), - ["assistant", "toolResult", "assistant"], + assert.ok(followUpToolResult); + const followUpText = followUpToolResult.content + .map((block) => (typeof block === "string" ? block : (block.text ?? ""))) + .join("\n"); + assert.match(followUpText, /truncated in transit/); + assert.doesNotMatch(followUpText, /Validation failed/); +}); + +test("runAssistantWithTools refuses a tool call salvaged without a toolcall_end", async () => { + // Simulates the DSML wrapper's recoverable-stream-end salvage: the done + // message carries a toolCall block whose argument stream never finished. + const danglingWriteCall = createToolCall("call_00_dangling_write", "Write", { + path: "/", + content: "", + }); + resetFakeStreams( + createRawFragmentToolCallStream(danglingWriteCall, ['{"path": "/'], { + omitToolCallEnd: true, + }), + createTextAssistant("after dangling write"), ); + const writeTool = { + name: "Write", + description: "Write files", + parameters: { type: "object", properties: {} }, + }; + const toolResults = []; + const { params, executedToolCalls } = createBaseParams({ + tools: [writeTool], + context: { + systemPrompt: "Base system prompt", + messages: [{ role: "user", content: "Start", timestamp: 1 }], + tools: [writeTool], + }, + onToolResult: (toolCall, toolResult) => { + toolResults.push({ toolCall, toolResult }); + }, + }); + + await runAssistantWithTools(params); + + assert.equal(executedToolCalls.length, 0); + assert.equal(toolResults.length, 1); + assert.equal(toolResults[0].toolResult.isError, true); + assert.match(toolResults[0].toolResult.content[0].text, /truncated in transit/); }); test("runAssistantWithTools strips bare tool_name text without duplicate execution", async () => { diff --git a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs index 98a62ba2..a3a494b0 100644 --- a/crates/agent-gui/test/chat/markdown-image-policy.test.mjs +++ b/crates/agent-gui/test/chat/markdown-image-policy.test.mjs @@ -231,7 +231,7 @@ test("agent tool rules steer new files to concrete Write paths", () => { ]); assert.match(suffix, /New files: call Write with a file path that includes the filename and the full content/); assert.match(suffix, /parent directories are created automatically/); - assert.match(suffix, /Do not set `mode`/); + assert.match(suffix, /Write and Edit check the file's current on-disk state automatically/); assert.match(suffix, /path must include the intended filename, not just a directory/); assert.match(suffix, /write \/ create files via heredocs, `tee`, `touch`, `cp`, or `mkdir`/); }); diff --git a/crates/agent-gui/test/chat/tool-call-argument-guard.test.mjs b/crates/agent-gui/test/chat/tool-call-argument-guard.test.mjs new file mode 100644 index 00000000..22e52b0e --- /dev/null +++ b/crates/agent-gui/test/chat/tool-call-argument-guard.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const loader = createTsModuleLoader({ rootDir }); +const { wrapStreamWithToolCallArgumentGuard } = loader.loadModule( + "src/lib/chat/runner/toolCallArgumentGuard.ts", +); + +const piAiJsonParse = await import( + new URL("../../node_modules/@earendil-works/pi-ai/dist/utils/json-parse.js", import.meta.url) + .href +); + +function toolCall(id, args) { + return { type: "toolCall", id, name: "Write", arguments: args }; +} + +function assistantWith(content) { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "anthropic", + model: "test-model", + usage: {}, + stopReason: "toolUse", + timestamp: 1, + }; +} + +function streamOf(events, finalMessage) { + return { + async *[Symbol.asyncIterator]() { + for (const event of events) yield event; + }, + async result() { + return finalMessage; + }, + }; +} + +async function collectFlags(events, finalMessage) { + const flags = []; + const wrapped = wrapStreamWithToolCallArgumentGuard(streamOf(events, finalMessage), (call, reason) => + flags.push({ id: call.id, reason }), + ); + const seen = []; + for await (const event of wrapped) seen.push(event); + return { flags, seen }; +} + +function toolCallEvents(call, fragments, { omitEnd = false } = {}) { + const message = assistantWith([call]); + return { + events: [ + { type: "start", partial: assistantWith([]) }, + { type: "toolcall_start", contentIndex: 0, partial: message }, + ...fragments.map((fragment) => ({ + type: "toolcall_delta", + contentIndex: 0, + delta: fragment, + partial: message, + })), + ...(omitEnd ? [] : [{ type: "toolcall_end", contentIndex: 0, toolCall: call, partial: message }]), + { type: "done", reason: "toolUse", message }, + ], + message, + }; +} + +test("complete raw argument JSON is not flagged", async () => { + const args = { path: "test2/new-write-test.md", content: "hello" }; + const raw = JSON.stringify(args); + const call = toolCall("call-complete", args); + const { events } = toolCallEvents(call, [raw.slice(0, 18), raw.slice(18)]); + const { flags, seen } = await collectFlags(events); + assert.deepEqual(flags, []); + assert.equal(seen.length, events.length); +}); + +test("truncated buffer whose lenient repair equals the final arguments is flagged", async () => { + const truncated = '{"content": "# Temp\\n", "path": "test2'; + const call = toolCall("call-truncated", piAiJsonParse.parseStreamingJson(truncated)); + assert.deepEqual(call.arguments, { content: "# Temp\n", path: "test2" }); + const { events } = toolCallEvents(call, [truncated.slice(0, 21), truncated.slice(21)]); + const { flags } = await collectFlags(events); + assert.equal(flags.length, 1); + assert.equal(flags[0].id, "call-truncated"); + assert.match(flags[0].reason, /ended before it was complete/); +}); + +test("single complete-JSON delta (DSML/Google shape) is not flagged", async () => { + const args = { path: "skill://demo/SKILL.md", content: "body" }; + const call = toolCall("call-dsml", args); + const { events } = toolCallEvents(call, [JSON.stringify(args)]); + const { flags } = await collectFlags(events); + assert.deepEqual(flags, []); +}); + +test("cumulative-snapshot deltas with an independent complete end are not flagged", async () => { + const args = { path: "report.html", content: "" }; + const call = toolCall("call-snapshots", args); + const { events } = toolCallEvents(call, [ + JSON.stringify({}), + JSON.stringify({ path: "report.html" }), + JSON.stringify(args), + ]); + const { flags } = await collectFlags(events); + assert.deepEqual(flags, []); +}); + +test("no deltas at all (arguments delivered whole on the end event) is not flagged", async () => { + const call = toolCall("call-no-deltas", { path: "a.txt", content: "x" }); + const { events } = toolCallEvents(call, []); + const { flags } = await collectFlags(events); + assert.deepEqual(flags, []); +}); + +test("a toolCall block in done without a toolcall_end is flagged (salvage shape)", async () => { + const call = toolCall("call-dangling", { path: "/", content: "" }); + const { events } = toolCallEvents(call, ['{"path": "/'], { omitEnd: true }); + const { flags } = await collectFlags(events); + assert.equal(flags.length, 1); + assert.equal(flags[0].id, "call-dangling"); + assert.match(flags[0].reason, /stream ended before this tool call finished/); +}); + +test("duplicated identical complete-JSON deltas are not flagged", async () => { + const args = { path: "a.md" }; + const call = toolCall("call-duplicated", args); + const { events } = toolCallEvents(call, [JSON.stringify(args), JSON.stringify(args)]); + const { flags } = await collectFlags(events); + assert.deepEqual(flags, []); +}); + +test("repeated empty-object deltas for a zero-arg call are not flagged", async () => { + const call = toolCall("call-zero-arg", {}); + const { events } = toolCallEvents(call, ["{}", "{}"]); + const { flags } = await collectFlags(events); + assert.deepEqual(flags, []); +}); + +test("each incomplete call is reported once even when done repeats it", async () => { + const truncated = '{"path": "test2'; + const call = toolCall("call-once", piAiJsonParse.parseStreamingJson(truncated)); + const message = assistantWith([call]); + const events = [ + { type: "toolcall_start", contentIndex: 0, partial: message }, + { type: "toolcall_delta", contentIndex: 0, delta: truncated, partial: message }, + { type: "toolcall_end", contentIndex: 0, toolCall: call, partial: message }, + { type: "done", reason: "toolUse", message }, + ]; + const { flags } = await collectFlags(events); + assert.equal(flags.length, 1); +}); diff --git a/crates/agent-gui/test/helpers/load-ts-module.mjs b/crates/agent-gui/test/helpers/load-ts-module.mjs index 1fcf62ff..35feb744 100644 --- a/crates/agent-gui/test/helpers/load-ts-module.mjs +++ b/crates/agent-gui/test/helpers/load-ts-module.mjs @@ -7,6 +7,22 @@ import ts from "typescript"; const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]; +// Real json-parse/validation implementations from pi-ai (imported by file URL +// to bypass the package exports map) so argument-integrity and schema-check +// code paths behave exactly like runtime. +const piAiJsonParse = await import( + new URL( + "../../node_modules/@earendil-works/pi-ai/dist/utils/json-parse.js", + import.meta.url, + ).href +); +const piAiValidation = await import( + new URL( + "../../node_modules/@earendil-works/pi-ai/dist/utils/validation.js", + import.meta.url, + ).href +); + function createDefaultMocks() { const typeboxMock = { Type: { @@ -63,9 +79,10 @@ function createDefaultMocks() { streamSimple() { throw new Error("streamSimple mock was not expected to be called"); }, - validateToolArguments(_tool, toolCall) { - return toolCall?.arguments ?? {}; - }, + validateToolArguments: piAiValidation.validateToolArguments, + parseJsonWithRepair: piAiJsonParse.parseJsonWithRepair, + parseStreamingJson: piAiJsonParse.parseStreamingJson, + repairJson: piAiJsonParse.repairJson, EventStream: class EventStream { constructor() { throw new Error("EventStream mock was not expected to be constructed"); diff --git a/crates/agent-gui/test/providers/deepseek-dsml-stream.test.mjs b/crates/agent-gui/test/providers/deepseek-dsml-stream.test.mjs index b519f30b..fdeac4d4 100644 --- a/crates/agent-gui/test/providers/deepseek-dsml-stream.test.mjs +++ b/crates/agent-gui/test/providers/deepseek-dsml-stream.test.mjs @@ -830,6 +830,45 @@ test("DeepSeek DSML stream wrapper keeps empty premature message_stop streams as assert.match(final.errorMessage, /Anthropic stream ended before message_stop/); }); +test("DeepSeek DSML stream wrapper salvages a half-streamed native tool call without a toolcall_end", async () => { + // Contract the tool-call argument guard relies on: a native tool call cut + // off by a recoverable stream end is committed into `done` but never gets a + // `toolcall_end` event, so downstream integrity checks can refuse it. + const truncatedCall = createToolCall("call_00_cut", "Write", { path: "test2" }); + const assistant = createAssistantWithContent([truncatedCall]); + const wrapped = wrapDeepSeekDsmlToolCallStream({ + async *[Symbol.asyncIterator]() { + yield { type: "start", partial: { ...assistant, content: [] } }; + yield { type: "toolcall_start", contentIndex: 0, partial: assistant }; + yield { + type: "toolcall_delta", + contentIndex: 0, + delta: '{"path": "test2', + partial: assistant, + }; + throw new Error("Anthropic stream ended before message_stop"); + }, + async result() { + return assistant; + }, + }); + + const events = []; + for await (const event of wrapped) { + events.push(event); + } + + assert.equal(events.at(-1)?.type, "done"); + assert.equal( + events.some((event) => event.type === "toolcall_end"), + false, + ); + const doneMessage = events.at(-1).message; + const salvagedToolCall = doneMessage.content.find((block) => block.type === "toolCall"); + assert.ok(salvagedToolCall); + assert.equal(salvagedToolCall.id, "call_00_cut"); +}); + test("streamAssistantMessage replies to recovered DeepSeek DSML tool calls before continuing", async () => { const streamQueue = [ createSourceStream([ diff --git a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs index 6dda16b4..20c148bf 100644 --- a/crates/agent-gui/test/tools/path-and-system-tools.test.mjs +++ b/crates/agent-gui/test/tools/path-and-system-tools.test.mjs @@ -253,6 +253,34 @@ test("ToolPathResolver resolves enabled Skill paths and gates external paths by ); }); +test("ToolPathResolver teaches the skill:// shape when the skill path is empty", async () => { + const resolver = new pathUtils.ToolPathResolver({ + workdir: "/workspace/project", + skillsRootEnabled: true, + skillsRootDir: "/Users/me/.liveagent/skills", + }); + + await assert.rejects( + () => + resolver.resolvePath("skill://", { + label: "Write.path", + intent: "write", + required: true, + }), + /Write\.path must include the skill name and a file path after skill:\/\/.*skill:\/\/\/SKILL\.md/, + ); + + // Listing the skills root (required: false) still resolves. + const skillsRoot = await resolver.resolvePath("skill://", { + label: "List.path", + intent: "read", + required: false, + }); + assert.equal(skillsRoot.scope, "skill"); + assert.equal(skillsRoot.relativePath, undefined); + assert.equal(skillsRoot.root, "/Users/me/.liveagent/skills"); +}); + test("ToolPathResolver prefers the skill scope when the skills root nests inside the workspace", async () => { const resolver = new pathUtils.ToolPathResolver({ workdir: "/workspace/project", @@ -628,7 +656,7 @@ test("file tools allow direct mutations inside enabled Skills when mutation is g ]); }); -test("Write schema accepts legacy empty mode without exposing it as required behavior", async () => { +test("Write strips legacy mode before schema validation and omits it from the schema", async () => { const fsLoader = createTsModuleLoader(); const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); @@ -639,39 +667,68 @@ test("Write schema accepts legacy empty mode without exposing it as required beh const writeTool = bundle.tools.find((tool) => tool.name === "Write"); assert.ok(writeTool); - assert.match(writeTool.description, /Pass only `path` and `content`; do not set `mode`/); + assert.doesNotMatch(writeTool.description, /mode/); + assert.deepEqual(Object.keys(writeTool.parameters.properties), ["path", "content"]); + + const prepared = writeTool.prepareArguments({ + path: "test8/gomoku.html", + mode: "", + content: "", + }); const args = validateToolArguments(writeTool, { type: "toolCall", id: "legacy-empty-mode", name: "Write", - arguments: { - path: "test8/gomoku.html", - mode: "", - content: "", - }, + arguments: prepared, }); assert.deepEqual(args, { path: "test8/gomoku.html", - mode: "", content: "", }); }); -test("Write preflight blocks existing files before content streaming but allows new files", async () => { +test("Write auto-primes a full text snapshot before overwriting an unread file", async () => { const invocations = []; const fsLoader = createTsModuleLoader({ mocks: { "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); - assert.equal(command, "fs_path_status"); + if (command === "fs_path_status") { + return { + path: args.path, + exists: args.path === "existing.html", + kind: args.path === "existing.html" ? "file" : null, + sizeBytes: args.path === "existing.html" ? 128 : null, + mtimeMs: args.path === "existing.html" ? 44 : null, + fileId: null, + }; + } + if (command === "fs_read_text") { + assert.equal(args.path, "existing.html"); + assert.equal(args.limit, 5000); + return { + kind: "text", + path: "existing.html", + content: "1\told\n", + truncated: false, + startLine: 1, + numLines: 1, + totalLines: 1, + isPartialView: false, + mtimeMs: 44, + contentHash: "before-hash", + }; + } + assert.equal(command, "fs_write_text"); return { path: args.path, - exists: args.path === "existing.html", - kind: args.path === "existing.html" ? "file" : null, - sizeBytes: args.path === "existing.html" ? 128 : null, - mtimeMs: args.path === "existing.html" ? 44 : null, + existedBefore: args.path === "existing.html", + bytesWritten: args.content.length, + mtimeMs: 45, + contentHash: "after-hash", + totalLines: 1, }; }, }, @@ -684,46 +741,135 @@ test("Write preflight blocks existing files before content streaming but allows fileState: fileToolState.createFileToolState(), }); - const blocked = await bundle.preflightToolCall({ + const overwritten = await bundle.executeToolCall({ type: "toolCall", - id: "stream-write-existing", + id: "write-existing-unread", name: "Write", arguments: { path: "existing.html", + content: "new\n", }, }); - const allowed = await bundle.preflightToolCall({ + + assert.equal(overwritten.isError, false); + assert.match(overwritten.content[0].text, /File updated successfully at: existing\.html/); + assert.match(overwritten.content[0].text, /autoRead=full/); + assert.deepEqual( + invocations.map((call) => call.command), + ["fs_path_status", "fs_read_text", "fs_write_text"], + ); + const writeInvocation = invocations.at(-1); + assert.equal(writeInvocation.args.expected_mtime_ms, 44); + assert.equal(writeInvocation.args.expected_content_hash, "before-hash"); + + invocations.length = 0; + const created = await bundle.executeToolCall({ type: "toolCall", - id: "stream-write-new", + id: "write-new-file", name: "Write", arguments: { path: "new.html", + content: "fresh\n", }, }); - assert.equal(blocked.toolResult.isError, true); - assert.match(blocked.toolResult.content[0].text, /full-file Read first/); - assert.equal(blocked.toolCall.arguments.content, ""); - assert.equal(allowed, null); - assert.deepEqual(invocations, [ - { - command: "fs_path_status", - args: { - workdir: "/workspace", - path: "existing.html", - }, - }, - { - command: "fs_path_status", - args: { - workdir: "/workspace", - path: "new.html", + assert.equal(created.isError, false); + assert.doesNotMatch(created.content[0].text, /autoRead/); + assert.deepEqual( + invocations.map((call) => call.command), + ["fs_path_status", "fs_write_text"], + ); + assert.equal(invocations.at(-1).args.expected_mtime_ms, undefined); + assert.equal(invocations.at(-1).args.expected_content_hash, undefined); +}); + +test("Write drops the auto-primed snapshot when the backend write fails", async () => { + const fileContent = "1\tsecret-on-disk\n"; + let failWrites = true; + const fsLoader = createTsModuleLoader({ + mocks: { + "@tauri-apps/api/core": { + async invoke(command, args) { + if (command === "fs_path_status") { + return { + path: args.path, + exists: true, + kind: "file", + sizeBytes: 30, + mtimeMs: 44, + fileId: null, + }; + } + if (command === "fs_read_text") { + return { + kind: "text", + path: args.path, + content: fileContent, + truncated: false, + startLine: 1, + numLines: 1, + totalLines: 1, + isPartialView: false, + mtimeMs: 44, + contentHash: "disk-hash", + }; + } + assert.equal(command, "fs_write_text"); + if (failWrites) { + throw { code: "io", message: "Permission denied", path: args.path }; + } + return { + path: args.path, + existedBefore: true, + bytesWritten: args.content.length, + mtimeMs: 45, + contentHash: "after-hash", + totalLines: 1, + }; + }, }, }, - ]); + }); + const fsTools = fsLoader.loadModule("src/lib/tools/fsTools.ts"); + const fileToolState = fsLoader.loadModule("src/lib/tools/fileToolState.ts"); + const bundle = fsTools.createFsTools({ + workdir: "/workspace", + fileState: fileToolState.createFileToolState(), + }); + + const failed = await bundle.executeToolCall({ + type: "toolCall", + id: "write-fails-after-prime", + name: "Write", + arguments: { path: "locked.html", content: "new\n" }, + }); + assert.equal(failed.isError, true); + + // The auto-primed snapshot must not survive the failed write: a follow-up + // Read has to return the real content, never an "unchanged" stub for + // content the model has never seen. + const read = await bundle.executeToolCall({ + type: "toolCall", + id: "read-after-failed-write", + name: "Read", + arguments: { path: "locked.html" }, + }); + assert.equal(read.isError, false); + assert.doesNotMatch(read.content[0].text, /unchanged since the previous Read/); + assert.match(read.content[0].text, /secret-on-disk/); + + failWrites = false; + const retried = await bundle.executeToolCall({ + type: "toolCall", + id: "write-retry-after-read", + name: "Write", + arguments: { path: "locked.html", content: "new\n" }, + }); + assert.equal(retried.isError, false); + assert.doesNotMatch(retried.content[0].text, /autoRead/); }); -test("Write preflight gives generic filename guidance for directory paths", async () => { +test("Write rejects directory paths with filename guidance before touching the backend", async () => { const invocations = []; const fsLoader = createTsModuleLoader({ mocks: { @@ -752,11 +898,10 @@ test("Write preflight gives generic filename guidance for directory paths", asyn const writeTool = bundle.tools.find((tool) => tool.name === "Write"); assert.match(writeTool.description, /notes\/todo\.txt/); assert.match(writeTool.description, /does not choose filenames from directory paths/); - assert.match(writeTool.description, /do not set `mode`/); - const blocked = await bundle.preflightToolCall({ + const blocked = await bundle.executeToolCall({ type: "toolCall", - id: "stream-write-directory", + id: "write-directory", name: "Write", arguments: { path: "output", @@ -764,11 +909,10 @@ test("Write preflight gives generic filename guidance for directory paths", asyn }, }); - assert.equal(blocked.toolResult.isError, true); - assert.match(blocked.toolResult.content[0].text, /directory, not a file: output/); - assert.match(blocked.toolResult.content[0].text, /path="output\/notes\.md"/); - assert.match(blocked.toolResult.content[0].text, /no separate create-directory step/); - assert.equal(blocked.toolCall.arguments.content, ""); + assert.equal(blocked.isError, true); + assert.match(blocked.content[0].text, /directory, not a file: output/); + assert.match(blocked.content[0].text, /path="output\/notes\.md"/); + assert.match(blocked.content[0].text, /no separate create-directory step/); assert.deepEqual(invocations, [ { command: "fs_path_status", @@ -787,23 +931,14 @@ test("Write does not infer filenames from content when path is a directory", asy "@tauri-apps/api/core": { async invoke(command, args) { invocations.push({ command, args }); - if (command === "fs_path_status") { - return { - path: args.path, - exists: true, - kind: "dir", - sizeBytes: 96, - mtimeMs: 55, - fileId: null, - }; - } - assert.equal(command, "fs_write_text"); - assert.equal(args.path, "test8"); - throw { - code: "not_a_file", - message: "Cannot write to a directory path", - path: "test8", - workdir: "/workspace", + assert.equal(command, "fs_path_status"); + return { + path: args.path, + exists: true, + kind: "dir", + sizeBytes: 96, + mtimeMs: 55, + fileId: null, }; }, }, @@ -839,17 +974,6 @@ test("Write does not infer filenames from content when path is a directory", asy path: "test8", }, }, - { - command: "fs_write_text", - args: { - workdir: "/workspace", - path: "test8", - content: '{"ok":true}\n', - mode: "rewrite", - expected_mtime_ms: undefined, - expected_content_hash: undefined, - }, - }, ]); }); @@ -967,20 +1091,19 @@ test("Write replays the Gomoku failure sequence with generic directory recovery type: "toolCall", id: "gomoku-empty-mode", name: "Write", - arguments: { + arguments: writeTool.prepareArguments({ path: "test8/gomoku.html", mode: "", content: "", - }, + }), }), { path: "test8/gomoku.html", - mode: "", content: "", }, ); - const directoryBlocked = await bundle.preflightToolCall({ + const directoryBlocked = await bundle.executeToolCall({ type: "toolCall", id: "gomoku-directory-empty", name: "Write", @@ -990,10 +1113,10 @@ test("Write replays the Gomoku failure sequence with generic directory recovery }, }); - assert.equal(directoryBlocked.toolResult.isError, true); - assert.match(directoryBlocked.toolResult.content[0].text, /directory, not a file: test8/); - assert.match(directoryBlocked.toolResult.content[0].text, /path="test8\/notes\.md"/); - assert.doesNotMatch(directoryBlocked.toolResult.content[0].text, /mode constant|index\.html/); + assert.equal(directoryBlocked.isError, true); + assert.match(directoryBlocked.content[0].text, /directory, not a file: test8/); + assert.match(directoryBlocked.content[0].text, /path="test8\/notes\.md"/); + assert.doesNotMatch(directoryBlocked.content[0].text, /mode constant|index\.html/); const recovered = await bundle.executeToolCall({ type: "toolCall", @@ -2800,7 +2923,7 @@ test("repeated identical failing calls escalate with a loop-breaking notice", as }); const callWriteToDirectory = (id) => - bundle.preflightToolCall({ + bundle.executeToolCall({ type: "toolCall", id, name: "Write", @@ -2808,22 +2931,21 @@ test("repeated identical failing calls escalate with a loop-breaking notice", as }); const first = await callWriteToDirectory("loop-1"); - assert.equal(first.toolResult.isError, true); - assert.doesNotMatch(first.toolResult.content[0].text, /times in a row/); + assert.equal(first.isError, true); + assert.doesNotMatch(first.content[0].text, /times in a row/); const second = await callWriteToDirectory("loop-2"); - assert.match(second.toolResult.content[0].text, /failed 2 times in a row/); - assert.match(second.toolResult.content[0].text, /Do not retry with the same arguments/); + assert.match(second.content[0].text, /failed 2 times in a row/); + assert.match(second.content[0].text, /Do not retry with the same arguments/); const third = await callWriteToDirectory("loop-3"); - assert.match(third.toolResult.content[0].text, /failed 3 times in a row/); + assert.match(third.content[0].text, /failed 3 times in a row/); - // Re-evaluating the same physical tool call (streaming preflight) must not - // inflate the counter. + // Re-running the same physical tool call id must not inflate the counter. const replay = await callWriteToDirectory("loop-3"); - assert.match(replay.toolResult.content[0].text, /failed 3 times in a row/); + assert.match(replay.content[0].text, /failed 3 times in a row/); - // A different failing call resets the consecutive counter. + // A different failing call with the same shape keeps escalating. const different = await callWriteToDirectory("loop-4"); - assert.match(different.toolResult.content[0].text, /failed 4 times in a row/); + assert.match(different.content[0].text, /failed 4 times in a row/); }); From ce5d74378cf35f2cba7bfd9cc6e7ce9530592e22 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Fri, 3 Jul 2026 23:28:53 +0800 Subject: [PATCH 30/64] fix(chat): stabilize chat run reconciliation --- .../internal/proto/v1/gateway.pb.go | 1102 ++++++++--------- .../server/websocket_chat_handlers.go | 14 + .../server/websocket_history_handlers.go | 14 +- .../internal/server/websocket_payloads.go | 16 + .../internal/server/websocket_routes.go | 1 + .../internal/server/websocket_routes_test.go | 1 + .../internal/session/conversation_stream.go | 129 +- .../conversation_stream_reconcile_test.go | 204 +++ .../session/conversation_stream_test.go | 16 +- .../internal/session/manager_dispatch.go | 2 +- crates/agent-gateway/proto/v1/gateway.proto | 33 +- .../agent-gateway/web/src/app/GatewayApp.tsx | 71 +- .../web/src/lib/chat/stream/activityStore.ts | 61 +- .../lib/chat/stream/chatCommandPipeline.ts | 6 + .../chat/stream/conversationStreamClient.ts | 12 + .../lib/chat/stream/useConversationChat.ts | 20 +- .../lib/chat/transcript/transcriptStore.ts | 24 +- .../web/src/lib/gatewaySocket.ts | 45 + .../web/test/activity-store.test.mjs | 113 +- .../web/test/transcript-store.test.mjs | 82 ++ .../src-tauri/src/services/chat_run_ledger.rs | 546 ++++++++ .../src-tauri/src/services/gateway.rs | 571 +++++++-- .../agent-gui/src-tauri/src/services/mod.rs | 1 + crates/agent-gui/src/pages/ChatPage.tsx | 22 + docs/architecture/gateway.md | 4 +- 25 files changed, 2295 insertions(+), 815 deletions(-) create mode 100644 crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go create mode 100644 crates/agent-gui/src-tauri/src/services/chat_run_ledger.rs diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index cadd47d4..d05f8fab 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -390,7 +390,6 @@ type GatewayEnvelope struct { // *GatewayEnvelope_SftpRequest // *GatewayEnvelope_SettingsResetSshKnownHost // *GatewayEnvelope_ChatQueue - // *GatewayEnvelope_ChatEventReplay // *GatewayEnvelope_TunnelState // *GatewayEnvelope_TunnelMutation // *GatewayEnvelope_TunnelFrame @@ -801,15 +800,6 @@ func (x *GatewayEnvelope) GetChatQueue() *ChatQueueRequest { return nil } -func (x *GatewayEnvelope) GetChatEventReplay() *ChatEventReplayRequest { - if x != nil { - if x, ok := x.Payload.(*GatewayEnvelope_ChatEventReplay); ok { - return x.ChatEventReplay - } - } - return nil -} - func (x *GatewayEnvelope) GetTunnelState() *TunnelStateSnapshot { if x != nil { if x, ok := x.Payload.(*GatewayEnvelope_TunnelState); ok { @@ -997,10 +987,6 @@ type GatewayEnvelope_ChatQueue struct { ChatQueue *ChatQueueRequest `protobuf:"bytes,73,opt,name=chat_queue,json=chatQueue,proto3,oneof"` } -type GatewayEnvelope_ChatEventReplay struct { - ChatEventReplay *ChatEventReplayRequest `protobuf:"bytes,74,opt,name=chat_event_replay,json=chatEventReplay,proto3,oneof"` -} - type GatewayEnvelope_TunnelState struct { TunnelState *TunnelStateSnapshot `protobuf:"bytes,80,opt,name=tunnel_state,json=tunnelState,proto3,oneof"` } @@ -1091,8 +1077,6 @@ func (*GatewayEnvelope_SettingsResetSshKnownHost) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_ChatQueue) isGatewayEnvelope_Payload() {} -func (*GatewayEnvelope_ChatEventReplay) isGatewayEnvelope_Payload() {} - func (*GatewayEnvelope_TunnelState) isGatewayEnvelope_Payload() {} func (*GatewayEnvelope_TunnelMutation) isGatewayEnvelope_Payload() {} @@ -1152,7 +1136,6 @@ type AgentEnvelope struct { // *AgentEnvelope_RuntimeStatus // *AgentEnvelope_SettingsResetSshKnownHostResp // *AgentEnvelope_ChatRuntimeSnapshot - // *AgentEnvelope_ChatEventReplayResp // *AgentEnvelope_TunnelDesired // *AgentEnvelope_TunnelMutationResult // *AgentEnvelope_TunnelFrame @@ -1637,15 +1620,6 @@ func (x *AgentEnvelope) GetChatRuntimeSnapshot() *ChatRuntimeSnapshot { return nil } -func (x *AgentEnvelope) GetChatEventReplayResp() *ChatEventReplayResponse { - if x != nil { - if x, ok := x.Payload.(*AgentEnvelope_ChatEventReplayResp); ok { - return x.ChatEventReplayResp - } - } - return nil -} - func (x *AgentEnvelope) GetTunnelDesired() *TunnelDesiredState { if x != nil { if x, ok := x.Payload.(*AgentEnvelope_TunnelDesired); ok { @@ -1883,10 +1857,6 @@ type AgentEnvelope_ChatRuntimeSnapshot struct { ChatRuntimeSnapshot *ChatRuntimeSnapshot `protobuf:"bytes,77,opt,name=chat_runtime_snapshot,json=chatRuntimeSnapshot,proto3,oneof"` } -type AgentEnvelope_ChatEventReplayResp struct { - ChatEventReplayResp *ChatEventReplayResponse `protobuf:"bytes,78,opt,name=chat_event_replay_resp,json=chatEventReplayResp,proto3,oneof"` -} - type AgentEnvelope_TunnelDesired struct { TunnelDesired *TunnelDesiredState `protobuf:"bytes,80,opt,name=tunnel_desired,json=tunnelDesired,proto3,oneof"` } @@ -2001,8 +1971,6 @@ func (*AgentEnvelope_SettingsResetSshKnownHostResp) isAgentEnvelope_Payload() {} func (*AgentEnvelope_ChatRuntimeSnapshot) isAgentEnvelope_Payload() {} -func (*AgentEnvelope_ChatEventReplayResp) isAgentEnvelope_Payload() {} - func (*AgentEnvelope_TunnelDesired) isAgentEnvelope_Payload() {} func (*AgentEnvelope_TunnelMutationResult) isAgentEnvelope_Payload() {} @@ -5988,6 +5956,8 @@ type RuntimeStatusEvent struct { Visible bool `protobuf:"varint,3,opt,name=visible,proto3" json:"visible,omitempty"` ActiveRunCount uint32 `protobuf:"varint,4,opt,name=active_run_count,json=activeRunCount,proto3" json:"active_run_count,omitempty"` Timestamp int64 `protobuf:"varint,5,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + ActiveRuns []*ChatRunReport `protobuf:"bytes,6,rep,name=active_runs,json=activeRuns,proto3" json:"active_runs,omitempty"` + FinishedRuns []*ChatRunReport `protobuf:"bytes,7,rep,name=finished_runs,json=finishedRuns,proto3" json:"finished_runs,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -6057,91 +6027,47 @@ func (x *RuntimeStatusEvent) GetTimestamp() int64 { return 0 } -type ChatEventReplayRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` - ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` - AfterSeq int64 `protobuf:"varint,3,opt,name=after_seq,json=afterSeq,proto3" json:"after_seq,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChatEventReplayRequest) Reset() { - *x = ChatEventReplayRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[53] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChatEventReplayRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ChatEventReplayRequest) ProtoMessage() {} - -func (x *ChatEventReplayRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[53] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ChatEventReplayRequest.ProtoReflect.Descriptor instead. -func (*ChatEventReplayRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} -} - -func (x *ChatEventReplayRequest) GetRunId() string { +func (x *RuntimeStatusEvent) GetActiveRuns() []*ChatRunReport { if x != nil { - return x.RunId - } - return "" -} - -func (x *ChatEventReplayRequest) GetConversationId() string { - if x != nil { - return x.ConversationId + return x.ActiveRuns } - return "" + return nil } -func (x *ChatEventReplayRequest) GetAfterSeq() int64 { +func (x *RuntimeStatusEvent) GetFinishedRuns() []*ChatRunReport { if x != nil { - return x.AfterSeq + return x.FinishedRuns } - return 0 + return nil } -type ChatEventReplayResponse struct { +type ChatRunReport struct { state protoimpl.MessageState `protogen:"open.v1"` RunId string `protobuf:"bytes,1,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"` ConversationId string `protobuf:"bytes,2,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` - Events []*ChatReplayEvent `protobuf:"bytes,3,rep,name=events,proto3" json:"events,omitempty"` - Complete bool `protobuf:"varint,4,opt,name=complete,proto3" json:"complete,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + ErrorCode string `protobuf:"bytes,4,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` + Message string `protobuf:"bytes,5,opt,name=message,proto3" json:"message,omitempty"` + UpdatedAt int64 `protobuf:"varint,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ChatEventReplayResponse) Reset() { - *x = ChatEventReplayResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[54] +func (x *ChatRunReport) Reset() { + *x = ChatRunReport{} + mi := &file_proto_v1_gateway_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ChatEventReplayResponse) String() string { +func (x *ChatRunReport) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ChatEventReplayResponse) ProtoMessage() {} +func (*ChatRunReport) ProtoMessage() {} -func (x *ChatEventReplayResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[54] +func (x *ChatRunReport) ProtoReflect() protoreflect.Message { + mi := &file_proto_v1_gateway_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6152,91 +6078,53 @@ func (x *ChatEventReplayResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ChatEventReplayResponse.ProtoReflect.Descriptor instead. -func (*ChatEventReplayResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} +// Deprecated: Use ChatRunReport.ProtoReflect.Descriptor instead. +func (*ChatRunReport) Descriptor() ([]byte, []int) { + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{53} } -func (x *ChatEventReplayResponse) GetRunId() string { +func (x *ChatRunReport) GetRunId() string { if x != nil { return x.RunId } return "" } -func (x *ChatEventReplayResponse) GetConversationId() string { +func (x *ChatRunReport) GetConversationId() string { if x != nil { return x.ConversationId } return "" } -func (x *ChatEventReplayResponse) GetEvents() []*ChatReplayEvent { +func (x *ChatRunReport) GetState() string { if x != nil { - return x.Events + return x.State } - return nil + return "" } -func (x *ChatEventReplayResponse) GetComplete() bool { +func (x *ChatRunReport) GetErrorCode() string { if x != nil { - return x.Complete + return x.ErrorCode } - return false -} - -type ChatReplayEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Seq int64 `protobuf:"varint,1,opt,name=seq,proto3" json:"seq,omitempty"` - EventJson string `protobuf:"bytes,2,opt,name=event_json,json=eventJson,proto3" json:"event_json,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *ChatReplayEvent) Reset() { - *x = ChatReplayEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[55] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *ChatReplayEvent) String() string { - return protoimpl.X.MessageStringOf(x) + return "" } -func (*ChatReplayEvent) ProtoMessage() {} - -func (x *ChatReplayEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[55] +func (x *ChatRunReport) GetMessage() string { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Message } - return mi.MessageOf(x) -} - -// Deprecated: Use ChatReplayEvent.ProtoReflect.Descriptor instead. -func (*ChatReplayEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} + return "" } -func (x *ChatReplayEvent) GetSeq() int64 { +func (x *ChatRunReport) GetUpdatedAt() int64 { if x != nil { - return x.Seq + return x.UpdatedAt } return 0 } -func (x *ChatReplayEvent) GetEventJson() string { - if x != nil { - return x.EventJson - } - return "" -} - type CronManageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Action string `protobuf:"bytes,1,opt,name=action,proto3" json:"action,omitempty"` @@ -6248,7 +6136,7 @@ type CronManageRequest struct { func (x *CronManageRequest) Reset() { *x = CronManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6260,7 +6148,7 @@ func (x *CronManageRequest) String() string { func (*CronManageRequest) ProtoMessage() {} func (x *CronManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[56] + mi := &file_proto_v1_gateway_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6273,7 +6161,7 @@ func (x *CronManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageRequest.ProtoReflect.Descriptor instead. func (*CronManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{54} } func (x *CronManageRequest) GetAction() string { @@ -6307,7 +6195,7 @@ type CronManageResponse struct { func (x *CronManageResponse) Reset() { *x = CronManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6319,7 +6207,7 @@ func (x *CronManageResponse) String() string { func (*CronManageResponse) ProtoMessage() {} func (x *CronManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[57] + mi := &file_proto_v1_gateway_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6332,7 +6220,7 @@ func (x *CronManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CronManageResponse.ProtoReflect.Descriptor instead. func (*CronManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{55} } func (x *CronManageResponse) GetAction() string { @@ -6361,7 +6249,7 @@ type HistoryListRequest struct { func (x *HistoryListRequest) Reset() { *x = HistoryListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6373,7 +6261,7 @@ func (x *HistoryListRequest) String() string { func (*HistoryListRequest) ProtoMessage() {} func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[58] + mi := &file_proto_v1_gateway_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6386,7 +6274,7 @@ func (x *HistoryListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListRequest.ProtoReflect.Descriptor instead. func (*HistoryListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{56} } func (x *HistoryListRequest) GetPage() int32 { @@ -6427,7 +6315,7 @@ type HistoryListResponse struct { func (x *HistoryListResponse) Reset() { *x = HistoryListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6439,7 +6327,7 @@ func (x *HistoryListResponse) String() string { func (*HistoryListResponse) ProtoMessage() {} func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[59] + mi := &file_proto_v1_gateway_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6452,7 +6340,7 @@ func (x *HistoryListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryListResponse.ProtoReflect.Descriptor instead. func (*HistoryListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{57} } func (x *HistoryListResponse) GetConversations() []*ConversationSummary { @@ -6489,7 +6377,7 @@ type ConversationSummary struct { func (x *ConversationSummary) Reset() { *x = ConversationSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6501,7 +6389,7 @@ func (x *ConversationSummary) String() string { func (*ConversationSummary) ProtoMessage() {} func (x *ConversationSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[60] + mi := &file_proto_v1_gateway_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6514,7 +6402,7 @@ func (x *ConversationSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use ConversationSummary.ProtoReflect.Descriptor instead. func (*ConversationSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{58} } func (x *ConversationSummary) GetId() string { @@ -6611,7 +6499,7 @@ type HistoryGetRequest struct { func (x *HistoryGetRequest) Reset() { *x = HistoryGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6623,7 +6511,7 @@ func (x *HistoryGetRequest) String() string { func (*HistoryGetRequest) ProtoMessage() {} func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[61] + mi := &file_proto_v1_gateway_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6636,7 +6524,7 @@ func (x *HistoryGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetRequest.ProtoReflect.Descriptor instead. func (*HistoryGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{59} } func (x *HistoryGetRequest) GetConversationId() string { @@ -6667,7 +6555,7 @@ type HistoryGetResponse struct { func (x *HistoryGetResponse) Reset() { *x = HistoryGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6679,7 +6567,7 @@ func (x *HistoryGetResponse) String() string { func (*HistoryGetResponse) ProtoMessage() {} func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[62] + mi := &file_proto_v1_gateway_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6692,7 +6580,7 @@ func (x *HistoryGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryGetResponse.ProtoReflect.Descriptor instead. func (*HistoryGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{60} } func (x *HistoryGetResponse) GetConversationId() string { @@ -6748,7 +6636,7 @@ type HistoryPrefixRequest struct { func (x *HistoryPrefixRequest) Reset() { *x = HistoryPrefixRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6760,7 +6648,7 @@ func (x *HistoryPrefixRequest) String() string { func (*HistoryPrefixRequest) ProtoMessage() {} func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[63] + mi := &file_proto_v1_gateway_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6773,7 +6661,7 @@ func (x *HistoryPrefixRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixRequest.ProtoReflect.Descriptor instead. func (*HistoryPrefixRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{61} } func (x *HistoryPrefixRequest) GetConversationId() string { @@ -6811,7 +6699,7 @@ type HistoryPrefixResponse struct { func (x *HistoryPrefixResponse) Reset() { *x = HistoryPrefixResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6823,7 +6711,7 @@ func (x *HistoryPrefixResponse) String() string { func (*HistoryPrefixResponse) ProtoMessage() {} func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[64] + mi := &file_proto_v1_gateway_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6836,7 +6724,7 @@ func (x *HistoryPrefixResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPrefixResponse.ProtoReflect.Descriptor instead. func (*HistoryPrefixResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{62} } func (x *HistoryPrefixResponse) GetConversationId() string { @@ -6891,7 +6779,7 @@ type HistoryRenameRequest struct { func (x *HistoryRenameRequest) Reset() { *x = HistoryRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6903,7 +6791,7 @@ func (x *HistoryRenameRequest) String() string { func (*HistoryRenameRequest) ProtoMessage() {} func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[65] + mi := &file_proto_v1_gateway_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6916,7 +6804,7 @@ func (x *HistoryRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameRequest.ProtoReflect.Descriptor instead. func (*HistoryRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{63} } func (x *HistoryRenameRequest) GetConversationId() string { @@ -6942,7 +6830,7 @@ type HistoryRenameResponse struct { func (x *HistoryRenameResponse) Reset() { *x = HistoryRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6954,7 +6842,7 @@ func (x *HistoryRenameResponse) String() string { func (*HistoryRenameResponse) ProtoMessage() {} func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[66] + mi := &file_proto_v1_gateway_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6967,7 +6855,7 @@ func (x *HistoryRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryRenameResponse.ProtoReflect.Descriptor instead. func (*HistoryRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{64} } func (x *HistoryRenameResponse) GetConversation() *ConversationSummary { @@ -6987,7 +6875,7 @@ type HistoryPinRequest struct { func (x *HistoryPinRequest) Reset() { *x = HistoryPinRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6999,7 +6887,7 @@ func (x *HistoryPinRequest) String() string { func (*HistoryPinRequest) ProtoMessage() {} func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[67] + mi := &file_proto_v1_gateway_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7012,7 +6900,7 @@ func (x *HistoryPinRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinRequest.ProtoReflect.Descriptor instead. func (*HistoryPinRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{65} } func (x *HistoryPinRequest) GetConversationId() string { @@ -7038,7 +6926,7 @@ type HistoryPinResponse struct { func (x *HistoryPinResponse) Reset() { *x = HistoryPinResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7050,7 +6938,7 @@ func (x *HistoryPinResponse) String() string { func (*HistoryPinResponse) ProtoMessage() {} func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[68] + mi := &file_proto_v1_gateway_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7063,7 +6951,7 @@ func (x *HistoryPinResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryPinResponse.ProtoReflect.Descriptor instead. func (*HistoryPinResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{66} } func (x *HistoryPinResponse) GetConversation() *ConversationSummary { @@ -7087,7 +6975,7 @@ type HistoryShareStatus struct { func (x *HistoryShareStatus) Reset() { *x = HistoryShareStatus{} - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7099,7 +6987,7 @@ func (x *HistoryShareStatus) String() string { func (*HistoryShareStatus) ProtoMessage() {} func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[69] + mi := &file_proto_v1_gateway_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7112,7 +7000,7 @@ func (x *HistoryShareStatus) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareStatus.ProtoReflect.Descriptor instead. func (*HistoryShareStatus) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{67} } func (x *HistoryShareStatus) GetConversationId() string { @@ -7166,7 +7054,7 @@ type HistoryShareGetRequest struct { func (x *HistoryShareGetRequest) Reset() { *x = HistoryShareGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[68] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7178,7 +7066,7 @@ func (x *HistoryShareGetRequest) String() string { func (*HistoryShareGetRequest) ProtoMessage() {} func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[70] + mi := &file_proto_v1_gateway_proto_msgTypes[68] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7191,7 +7079,7 @@ func (x *HistoryShareGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{68} } func (x *HistoryShareGetRequest) GetConversationId() string { @@ -7210,7 +7098,7 @@ type HistoryShareGetResponse struct { func (x *HistoryShareGetResponse) Reset() { *x = HistoryShareGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[69] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7222,7 +7110,7 @@ func (x *HistoryShareGetResponse) String() string { func (*HistoryShareGetResponse) ProtoMessage() {} func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[71] + mi := &file_proto_v1_gateway_proto_msgTypes[69] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7235,7 +7123,7 @@ func (x *HistoryShareGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareGetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{69} } func (x *HistoryShareGetResponse) GetShare() *HistoryShareStatus { @@ -7256,7 +7144,7 @@ type HistoryShareSetRequest struct { func (x *HistoryShareSetRequest) Reset() { *x = HistoryShareSetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7268,7 +7156,7 @@ func (x *HistoryShareSetRequest) String() string { func (*HistoryShareSetRequest) ProtoMessage() {} func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[72] + mi := &file_proto_v1_gateway_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7281,7 +7169,7 @@ func (x *HistoryShareSetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetRequest.ProtoReflect.Descriptor instead. func (*HistoryShareSetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{70} } func (x *HistoryShareSetRequest) GetConversationId() string { @@ -7314,7 +7202,7 @@ type HistoryShareSetResponse struct { func (x *HistoryShareSetResponse) Reset() { *x = HistoryShareSetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7326,7 +7214,7 @@ func (x *HistoryShareSetResponse) String() string { func (*HistoryShareSetResponse) ProtoMessage() {} func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[73] + mi := &file_proto_v1_gateway_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7339,7 +7227,7 @@ func (x *HistoryShareSetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareSetResponse.ProtoReflect.Descriptor instead. func (*HistoryShareSetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{71} } func (x *HistoryShareSetResponse) GetShare() *HistoryShareStatus { @@ -7358,7 +7246,7 @@ type HistoryShareResolveRequest struct { func (x *HistoryShareResolveRequest) Reset() { *x = HistoryShareResolveRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7370,7 +7258,7 @@ func (x *HistoryShareResolveRequest) String() string { func (*HistoryShareResolveRequest) ProtoMessage() {} func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[74] + mi := &file_proto_v1_gateway_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7383,7 +7271,7 @@ func (x *HistoryShareResolveRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveRequest.ProtoReflect.Descriptor instead. func (*HistoryShareResolveRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{72} } func (x *HistoryShareResolveRequest) GetToken() string { @@ -7406,7 +7294,7 @@ type HistoryShareResolveResponse struct { func (x *HistoryShareResolveResponse) Reset() { *x = HistoryShareResolveResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7418,7 +7306,7 @@ func (x *HistoryShareResolveResponse) String() string { func (*HistoryShareResolveResponse) ProtoMessage() {} func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[75] + mi := &file_proto_v1_gateway_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7431,7 +7319,7 @@ func (x *HistoryShareResolveResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryShareResolveResponse.ProtoReflect.Descriptor instead. func (*HistoryShareResolveResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{73} } func (x *HistoryShareResolveResponse) GetConversationId() string { @@ -7477,7 +7365,7 @@ type HistoryWorkdirsRequest struct { func (x *HistoryWorkdirsRequest) Reset() { *x = HistoryWorkdirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7489,7 +7377,7 @@ func (x *HistoryWorkdirsRequest) String() string { func (*HistoryWorkdirsRequest) ProtoMessage() {} func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[76] + mi := &file_proto_v1_gateway_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7502,7 +7390,7 @@ func (x *HistoryWorkdirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsRequest.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{74} } type HistoryWorkdirSummary struct { @@ -7516,7 +7404,7 @@ type HistoryWorkdirSummary struct { func (x *HistoryWorkdirSummary) Reset() { *x = HistoryWorkdirSummary{} - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7528,7 +7416,7 @@ func (x *HistoryWorkdirSummary) String() string { func (*HistoryWorkdirSummary) ProtoMessage() {} func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[77] + mi := &file_proto_v1_gateway_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7541,7 +7429,7 @@ func (x *HistoryWorkdirSummary) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirSummary.ProtoReflect.Descriptor instead. func (*HistoryWorkdirSummary) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{75} } func (x *HistoryWorkdirSummary) GetPath() string { @@ -7574,7 +7462,7 @@ type HistoryWorkdirsResponse struct { func (x *HistoryWorkdirsResponse) Reset() { *x = HistoryWorkdirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7586,7 +7474,7 @@ func (x *HistoryWorkdirsResponse) String() string { func (*HistoryWorkdirsResponse) ProtoMessage() {} func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[78] + mi := &file_proto_v1_gateway_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7599,7 +7487,7 @@ func (x *HistoryWorkdirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryWorkdirsResponse.ProtoReflect.Descriptor instead. func (*HistoryWorkdirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{76} } func (x *HistoryWorkdirsResponse) GetWorkdirs() []*HistoryWorkdirSummary { @@ -7618,7 +7506,7 @@ type HistoryDeleteRequest struct { func (x *HistoryDeleteRequest) Reset() { *x = HistoryDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7630,7 +7518,7 @@ func (x *HistoryDeleteRequest) String() string { func (*HistoryDeleteRequest) ProtoMessage() {} func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[79] + mi := &file_proto_v1_gateway_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7643,7 +7531,7 @@ func (x *HistoryDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteRequest.ProtoReflect.Descriptor instead. func (*HistoryDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{77} } func (x *HistoryDeleteRequest) GetConversationId() string { @@ -7661,7 +7549,7 @@ type HistoryDeleteResponse struct { func (x *HistoryDeleteResponse) Reset() { *x = HistoryDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7673,7 +7561,7 @@ func (x *HistoryDeleteResponse) String() string { func (*HistoryDeleteResponse) ProtoMessage() {} func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[80] + mi := &file_proto_v1_gateway_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7686,7 +7574,7 @@ func (x *HistoryDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use HistoryDeleteResponse.ProtoReflect.Descriptor instead. func (*HistoryDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{78} } type HistorySyncEvent struct { @@ -7700,7 +7588,7 @@ type HistorySyncEvent struct { func (x *HistorySyncEvent) Reset() { *x = HistorySyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7712,7 +7600,7 @@ func (x *HistorySyncEvent) String() string { func (*HistorySyncEvent) ProtoMessage() {} func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[81] + mi := &file_proto_v1_gateway_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7725,7 +7613,7 @@ func (x *HistorySyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use HistorySyncEvent.ProtoReflect.Descriptor instead. func (*HistorySyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{79} } func (x *HistorySyncEvent) GetKind() string { @@ -7757,7 +7645,7 @@ type ProviderListRequest struct { func (x *ProviderListRequest) Reset() { *x = ProviderListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7769,7 +7657,7 @@ func (x *ProviderListRequest) String() string { func (*ProviderListRequest) ProtoMessage() {} func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[82] + mi := &file_proto_v1_gateway_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7782,7 +7670,7 @@ func (x *ProviderListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListRequest.ProtoReflect.Descriptor instead. func (*ProviderListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{80} } type ProviderListResponse struct { @@ -7794,7 +7682,7 @@ type ProviderListResponse struct { func (x *ProviderListResponse) Reset() { *x = ProviderListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7806,7 +7694,7 @@ func (x *ProviderListResponse) String() string { func (*ProviderListResponse) ProtoMessage() {} func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[83] + mi := &file_proto_v1_gateway_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7819,7 +7707,7 @@ func (x *ProviderListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ProviderListResponse.ProtoReflect.Descriptor instead. func (*ProviderListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{81} } func (x *ProviderListResponse) GetProvidersJson() string { @@ -7837,7 +7725,7 @@ type SettingsGetRequest struct { func (x *SettingsGetRequest) Reset() { *x = SettingsGetRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7849,7 +7737,7 @@ func (x *SettingsGetRequest) String() string { func (*SettingsGetRequest) ProtoMessage() {} func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[84] + mi := &file_proto_v1_gateway_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7862,7 +7750,7 @@ func (x *SettingsGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetRequest.ProtoReflect.Descriptor instead. func (*SettingsGetRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{82} } type SettingsGetResponse struct { @@ -7874,7 +7762,7 @@ type SettingsGetResponse struct { func (x *SettingsGetResponse) Reset() { *x = SettingsGetResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7886,7 +7774,7 @@ func (x *SettingsGetResponse) String() string { func (*SettingsGetResponse) ProtoMessage() {} func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[85] + mi := &file_proto_v1_gateway_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7899,7 +7787,7 @@ func (x *SettingsGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsGetResponse.ProtoReflect.Descriptor instead. func (*SettingsGetResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{83} } func (x *SettingsGetResponse) GetSettingsJson() string { @@ -7918,7 +7806,7 @@ type SettingsUpdateRequest struct { func (x *SettingsUpdateRequest) Reset() { *x = SettingsUpdateRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7930,7 +7818,7 @@ func (x *SettingsUpdateRequest) String() string { func (*SettingsUpdateRequest) ProtoMessage() {} func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[86] + mi := &file_proto_v1_gateway_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7943,7 +7831,7 @@ func (x *SettingsUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateRequest.ProtoReflect.Descriptor instead. func (*SettingsUpdateRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{84} } func (x *SettingsUpdateRequest) GetSettingsJson() string { @@ -7963,7 +7851,7 @@ type SettingsUpdateResponse struct { func (x *SettingsUpdateResponse) Reset() { *x = SettingsUpdateResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7975,7 +7863,7 @@ func (x *SettingsUpdateResponse) String() string { func (*SettingsUpdateResponse) ProtoMessage() {} func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[87] + mi := &file_proto_v1_gateway_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7988,7 +7876,7 @@ func (x *SettingsUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsUpdateResponse.ProtoReflect.Descriptor instead. func (*SettingsUpdateResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{85} } func (x *SettingsUpdateResponse) GetAccepted() bool { @@ -8015,7 +7903,7 @@ type SettingsResetSshKnownHostRequest struct { func (x *SettingsResetSshKnownHostRequest) Reset() { *x = SettingsResetSshKnownHostRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8027,7 +7915,7 @@ func (x *SettingsResetSshKnownHostRequest) String() string { func (*SettingsResetSshKnownHostRequest) ProtoMessage() {} func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[88] + mi := &file_proto_v1_gateway_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8040,7 +7928,7 @@ func (x *SettingsResetSshKnownHostRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsResetSshKnownHostRequest.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{86} } func (x *SettingsResetSshKnownHostRequest) GetHost() string { @@ -8066,7 +7954,7 @@ type SettingsResetSshKnownHostResponse struct { func (x *SettingsResetSshKnownHostResponse) Reset() { *x = SettingsResetSshKnownHostResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8078,7 +7966,7 @@ func (x *SettingsResetSshKnownHostResponse) String() string { func (*SettingsResetSshKnownHostResponse) ProtoMessage() {} func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[89] + mi := &file_proto_v1_gateway_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8091,7 +7979,7 @@ func (x *SettingsResetSshKnownHostResponse) ProtoReflect() protoreflect.Message // Deprecated: Use SettingsResetSshKnownHostResponse.ProtoReflect.Descriptor instead. func (*SettingsResetSshKnownHostResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{87} } func (x *SettingsResetSshKnownHostResponse) GetDeleted() uint32 { @@ -8110,7 +7998,7 @@ type SettingsSyncEvent struct { func (x *SettingsSyncEvent) Reset() { *x = SettingsSyncEvent{} - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8122,7 +8010,7 @@ func (x *SettingsSyncEvent) String() string { func (*SettingsSyncEvent) ProtoMessage() {} func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[90] + mi := &file_proto_v1_gateway_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8135,7 +8023,7 @@ func (x *SettingsSyncEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SettingsSyncEvent.ProtoReflect.Descriptor instead. func (*SettingsSyncEvent) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{88} } func (x *SettingsSyncEvent) GetSettingsJson() string { @@ -8153,7 +8041,7 @@ type SkillFilesListRequest struct { func (x *SkillFilesListRequest) Reset() { *x = SkillFilesListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8165,7 +8053,7 @@ func (x *SkillFilesListRequest) String() string { func (*SkillFilesListRequest) ProtoMessage() {} func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[91] + mi := &file_proto_v1_gateway_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8178,7 +8066,7 @@ func (x *SkillFilesListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListRequest.ProtoReflect.Descriptor instead. func (*SkillFilesListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{89} } type SkillFilesListResponse struct { @@ -8192,7 +8080,7 @@ type SkillFilesListResponse struct { func (x *SkillFilesListResponse) Reset() { *x = SkillFilesListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8204,7 +8092,7 @@ func (x *SkillFilesListResponse) String() string { func (*SkillFilesListResponse) ProtoMessage() {} func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[92] + mi := &file_proto_v1_gateway_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8217,7 +8105,7 @@ func (x *SkillFilesListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillFilesListResponse.ProtoReflect.Descriptor instead. func (*SkillFilesListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{90} } func (x *SkillFilesListResponse) GetRootDir() string { @@ -8250,7 +8138,7 @@ type SkillMetadataReadRequest struct { func (x *SkillMetadataReadRequest) Reset() { *x = SkillMetadataReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8262,7 +8150,7 @@ func (x *SkillMetadataReadRequest) String() string { func (*SkillMetadataReadRequest) ProtoMessage() {} func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[93] + mi := &file_proto_v1_gateway_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8275,7 +8163,7 @@ func (x *SkillMetadataReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadRequest.ProtoReflect.Descriptor instead. func (*SkillMetadataReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{91} } func (x *SkillMetadataReadRequest) GetPath() string { @@ -8295,7 +8183,7 @@ type SkillMetadataReadResponse struct { func (x *SkillMetadataReadResponse) Reset() { *x = SkillMetadataReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8307,7 +8195,7 @@ func (x *SkillMetadataReadResponse) String() string { func (*SkillMetadataReadResponse) ProtoMessage() {} func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[94] + mi := &file_proto_v1_gateway_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8320,7 +8208,7 @@ func (x *SkillMetadataReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillMetadataReadResponse.ProtoReflect.Descriptor instead. func (*SkillMetadataReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{92} } func (x *SkillMetadataReadResponse) GetName() string { @@ -8348,7 +8236,7 @@ type SkillTextReadRequest struct { func (x *SkillTextReadRequest) Reset() { *x = SkillTextReadRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8360,7 +8248,7 @@ func (x *SkillTextReadRequest) String() string { func (*SkillTextReadRequest) ProtoMessage() {} func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[95] + mi := &file_proto_v1_gateway_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8373,7 +8261,7 @@ func (x *SkillTextReadRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadRequest.ProtoReflect.Descriptor instead. func (*SkillTextReadRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{93} } func (x *SkillTextReadRequest) GetPath() string { @@ -8407,7 +8295,7 @@ type SkillTextReadResponse struct { func (x *SkillTextReadResponse) Reset() { *x = SkillTextReadResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8419,7 +8307,7 @@ func (x *SkillTextReadResponse) String() string { func (*SkillTextReadResponse) ProtoMessage() {} func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[96] + mi := &file_proto_v1_gateway_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8432,7 +8320,7 @@ func (x *SkillTextReadResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillTextReadResponse.ProtoReflect.Descriptor instead. func (*SkillTextReadResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{94} } func (x *SkillTextReadResponse) GetContent() string { @@ -8458,7 +8346,7 @@ type SkillManageRequest struct { func (x *SkillManageRequest) Reset() { *x = SkillManageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[95] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8470,7 +8358,7 @@ func (x *SkillManageRequest) String() string { func (*SkillManageRequest) ProtoMessage() {} func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[97] + mi := &file_proto_v1_gateway_proto_msgTypes[95] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8483,7 +8371,7 @@ func (x *SkillManageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageRequest.ProtoReflect.Descriptor instead. func (*SkillManageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{95} } func (x *SkillManageRequest) GetPayloadJson() string { @@ -8502,7 +8390,7 @@ type SkillManageResponse struct { func (x *SkillManageResponse) Reset() { *x = SkillManageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8514,7 +8402,7 @@ func (x *SkillManageResponse) String() string { func (*SkillManageResponse) ProtoMessage() {} func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[98] + mi := &file_proto_v1_gateway_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8527,7 +8415,7 @@ func (x *SkillManageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SkillManageResponse.ProtoReflect.Descriptor instead. func (*SkillManageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{96} } func (x *SkillManageResponse) GetResultJson() string { @@ -8548,7 +8436,7 @@ type FileMentionListRequest struct { func (x *FileMentionListRequest) Reset() { *x = FileMentionListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8560,7 +8448,7 @@ func (x *FileMentionListRequest) String() string { func (*FileMentionListRequest) ProtoMessage() {} func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[99] + mi := &file_proto_v1_gateway_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8573,7 +8461,7 @@ func (x *FileMentionListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListRequest.ProtoReflect.Descriptor instead. func (*FileMentionListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{97} } func (x *FileMentionListRequest) GetWorkdir() string { @@ -8607,7 +8495,7 @@ type FileMentionEntry struct { func (x *FileMentionEntry) Reset() { *x = FileMentionEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8619,7 +8507,7 @@ func (x *FileMentionEntry) String() string { func (*FileMentionEntry) ProtoMessage() {} func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[100] + mi := &file_proto_v1_gateway_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8632,7 +8520,7 @@ func (x *FileMentionEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionEntry.ProtoReflect.Descriptor instead. func (*FileMentionEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{98} } func (x *FileMentionEntry) GetPath() string { @@ -8659,7 +8547,7 @@ type FileMentionListResponse struct { func (x *FileMentionListResponse) Reset() { *x = FileMentionListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8671,7 +8559,7 @@ func (x *FileMentionListResponse) String() string { func (*FileMentionListResponse) ProtoMessage() {} func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[101] + mi := &file_proto_v1_gateway_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8684,7 +8572,7 @@ func (x *FileMentionListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FileMentionListResponse.ProtoReflect.Descriptor instead. func (*FileMentionListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{99} } func (x *FileMentionListResponse) GetEntries() []*FileMentionEntry { @@ -8713,7 +8601,7 @@ type FsRoot struct { func (x *FsRoot) Reset() { *x = FsRoot{} - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8725,7 +8613,7 @@ func (x *FsRoot) String() string { func (*FsRoot) ProtoMessage() {} func (x *FsRoot) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[102] + mi := &file_proto_v1_gateway_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8738,7 +8626,7 @@ func (x *FsRoot) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRoot.ProtoReflect.Descriptor instead. func (*FsRoot) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{100} } func (x *FsRoot) GetId() string { @@ -8777,7 +8665,7 @@ type FsRootsRequest struct { func (x *FsRootsRequest) Reset() { *x = FsRootsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8789,7 +8677,7 @@ func (x *FsRootsRequest) String() string { func (*FsRootsRequest) ProtoMessage() {} func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[103] + mi := &file_proto_v1_gateway_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8802,7 +8690,7 @@ func (x *FsRootsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsRequest.ProtoReflect.Descriptor instead. func (*FsRootsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{101} } type FsRootsResponse struct { @@ -8814,7 +8702,7 @@ type FsRootsResponse struct { func (x *FsRootsResponse) Reset() { *x = FsRootsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8826,7 +8714,7 @@ func (x *FsRootsResponse) String() string { func (*FsRootsResponse) ProtoMessage() {} func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[104] + mi := &file_proto_v1_gateway_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8839,7 +8727,7 @@ func (x *FsRootsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRootsResponse.ProtoReflect.Descriptor instead. func (*FsRootsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{102} } func (x *FsRootsResponse) GetRoots() []*FsRoot { @@ -8859,7 +8747,7 @@ type FsListDirsRequest struct { func (x *FsListDirsRequest) Reset() { *x = FsListDirsRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8871,7 +8759,7 @@ func (x *FsListDirsRequest) String() string { func (*FsListDirsRequest) ProtoMessage() {} func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[105] + mi := &file_proto_v1_gateway_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8884,7 +8772,7 @@ func (x *FsListDirsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsRequest.ProtoReflect.Descriptor instead. func (*FsListDirsRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{103} } func (x *FsListDirsRequest) GetPath() string { @@ -8911,7 +8799,7 @@ type FsDirEntry struct { func (x *FsDirEntry) Reset() { *x = FsDirEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8923,7 +8811,7 @@ func (x *FsDirEntry) String() string { func (*FsDirEntry) ProtoMessage() {} func (x *FsDirEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[106] + mi := &file_proto_v1_gateway_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8936,7 +8824,7 @@ func (x *FsDirEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDirEntry.ProtoReflect.Descriptor instead. func (*FsDirEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{104} } func (x *FsDirEntry) GetPath() string { @@ -8964,7 +8852,7 @@ type FsListDirsResponse struct { func (x *FsListDirsResponse) Reset() { *x = FsListDirsResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -8976,7 +8864,7 @@ func (x *FsListDirsResponse) String() string { func (*FsListDirsResponse) ProtoMessage() {} func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[107] + mi := &file_proto_v1_gateway_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -8989,7 +8877,7 @@ func (x *FsListDirsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListDirsResponse.ProtoReflect.Descriptor instead. func (*FsListDirsResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{105} } func (x *FsListDirsResponse) GetPath() string { @@ -9023,7 +8911,7 @@ type FsCreateProjectFolderRequest struct { func (x *FsCreateProjectFolderRequest) Reset() { *x = FsCreateProjectFolderRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9035,7 +8923,7 @@ func (x *FsCreateProjectFolderRequest) String() string { func (*FsCreateProjectFolderRequest) ProtoMessage() {} func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[108] + mi := &file_proto_v1_gateway_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9048,7 +8936,7 @@ func (x *FsCreateProjectFolderRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderRequest.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{106} } func (x *FsCreateProjectFolderRequest) GetParent() string { @@ -9074,7 +8962,7 @@ type FsCreateProjectFolderResponse struct { func (x *FsCreateProjectFolderResponse) Reset() { *x = FsCreateProjectFolderResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9086,7 +8974,7 @@ func (x *FsCreateProjectFolderResponse) String() string { func (*FsCreateProjectFolderResponse) ProtoMessage() {} func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[109] + mi := &file_proto_v1_gateway_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9099,7 +8987,7 @@ func (x *FsCreateProjectFolderResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateProjectFolderResponse.ProtoReflect.Descriptor instead. func (*FsCreateProjectFolderResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{107} } func (x *FsCreateProjectFolderResponse) GetPath() string { @@ -9122,7 +9010,7 @@ type FsListRequest struct { func (x *FsListRequest) Reset() { *x = FsListRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9134,7 +9022,7 @@ func (x *FsListRequest) String() string { func (*FsListRequest) ProtoMessage() {} func (x *FsListRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[110] + mi := &file_proto_v1_gateway_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9147,7 +9035,7 @@ func (x *FsListRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListRequest.ProtoReflect.Descriptor instead. func (*FsListRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{108} } func (x *FsListRequest) GetWorkdir() string { @@ -9195,7 +9083,7 @@ type FsListEntry struct { func (x *FsListEntry) Reset() { *x = FsListEntry{} - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9207,7 +9095,7 @@ func (x *FsListEntry) String() string { func (*FsListEntry) ProtoMessage() {} func (x *FsListEntry) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[111] + mi := &file_proto_v1_gateway_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9220,7 +9108,7 @@ func (x *FsListEntry) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListEntry.ProtoReflect.Descriptor instead. func (*FsListEntry) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{109} } func (x *FsListEntry) GetPath() string { @@ -9253,7 +9141,7 @@ type FsListResponse struct { func (x *FsListResponse) Reset() { *x = FsListResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9265,7 +9153,7 @@ func (x *FsListResponse) String() string { func (*FsListResponse) ProtoMessage() {} func (x *FsListResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[112] + mi := &file_proto_v1_gateway_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9278,7 +9166,7 @@ func (x *FsListResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsListResponse.ProtoReflect.Descriptor instead. func (*FsListResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{110} } func (x *FsListResponse) GetPath() string { @@ -9347,7 +9235,7 @@ type FsReadEditableTextRequest struct { func (x *FsReadEditableTextRequest) Reset() { *x = FsReadEditableTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9359,7 +9247,7 @@ func (x *FsReadEditableTextRequest) String() string { func (*FsReadEditableTextRequest) ProtoMessage() {} func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[113] + mi := &file_proto_v1_gateway_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9372,7 +9260,7 @@ func (x *FsReadEditableTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextRequest.ProtoReflect.Descriptor instead. func (*FsReadEditableTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{111} } func (x *FsReadEditableTextRequest) GetWorkdir() string { @@ -9403,7 +9291,7 @@ type FsReadEditableTextResponse struct { func (x *FsReadEditableTextResponse) Reset() { *x = FsReadEditableTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9415,7 +9303,7 @@ func (x *FsReadEditableTextResponse) String() string { func (*FsReadEditableTextResponse) ProtoMessage() {} func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[114] + mi := &file_proto_v1_gateway_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9428,7 +9316,7 @@ func (x *FsReadEditableTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadEditableTextResponse.ProtoReflect.Descriptor instead. func (*FsReadEditableTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{112} } func (x *FsReadEditableTextResponse) GetPath() string { @@ -9483,7 +9371,7 @@ type FsReadWorkspaceImageRequest struct { func (x *FsReadWorkspaceImageRequest) Reset() { *x = FsReadWorkspaceImageRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[113] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9495,7 +9383,7 @@ func (x *FsReadWorkspaceImageRequest) String() string { func (*FsReadWorkspaceImageRequest) ProtoMessage() {} func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[115] + mi := &file_proto_v1_gateway_proto_msgTypes[113] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9508,7 +9396,7 @@ func (x *FsReadWorkspaceImageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageRequest.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{113} } func (x *FsReadWorkspaceImageRequest) GetWorkdir() string { @@ -9539,7 +9427,7 @@ type FsReadWorkspaceImageResponse struct { func (x *FsReadWorkspaceImageResponse) Reset() { *x = FsReadWorkspaceImageResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[114] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9551,7 +9439,7 @@ func (x *FsReadWorkspaceImageResponse) String() string { func (*FsReadWorkspaceImageResponse) ProtoMessage() {} func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[116] + mi := &file_proto_v1_gateway_proto_msgTypes[114] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9564,7 +9452,7 @@ func (x *FsReadWorkspaceImageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsReadWorkspaceImageResponse.ProtoReflect.Descriptor instead. func (*FsReadWorkspaceImageResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{114} } func (x *FsReadWorkspaceImageResponse) GetPath() string { @@ -9625,7 +9513,7 @@ type FsWriteTextRequest struct { func (x *FsWriteTextRequest) Reset() { *x = FsWriteTextRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[115] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9637,7 +9525,7 @@ func (x *FsWriteTextRequest) String() string { func (*FsWriteTextRequest) ProtoMessage() {} func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[117] + mi := &file_proto_v1_gateway_proto_msgTypes[115] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9650,7 +9538,7 @@ func (x *FsWriteTextRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextRequest.ProtoReflect.Descriptor instead. func (*FsWriteTextRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{115} } func (x *FsWriteTextRequest) GetWorkdir() string { @@ -9724,7 +9612,7 @@ type FsWriteTextResponse struct { func (x *FsWriteTextResponse) Reset() { *x = FsWriteTextResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[116] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9736,7 +9624,7 @@ func (x *FsWriteTextResponse) String() string { func (*FsWriteTextResponse) ProtoMessage() {} func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[118] + mi := &file_proto_v1_gateway_proto_msgTypes[116] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9749,7 +9637,7 @@ func (x *FsWriteTextResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsWriteTextResponse.ProtoReflect.Descriptor instead. func (*FsWriteTextResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{116} } func (x *FsWriteTextResponse) GetPath() string { @@ -9811,7 +9699,7 @@ type FsCreateDirRequest struct { func (x *FsCreateDirRequest) Reset() { *x = FsCreateDirRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[117] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9823,7 +9711,7 @@ func (x *FsCreateDirRequest) String() string { func (*FsCreateDirRequest) ProtoMessage() {} func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[119] + mi := &file_proto_v1_gateway_proto_msgTypes[117] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9836,7 +9724,7 @@ func (x *FsCreateDirRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirRequest.ProtoReflect.Descriptor instead. func (*FsCreateDirRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{117} } func (x *FsCreateDirRequest) GetWorkdir() string { @@ -9863,7 +9751,7 @@ type FsCreateDirResponse struct { func (x *FsCreateDirResponse) Reset() { *x = FsCreateDirResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[120] + mi := &file_proto_v1_gateway_proto_msgTypes[118] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9875,7 +9763,7 @@ func (x *FsCreateDirResponse) String() string { func (*FsCreateDirResponse) ProtoMessage() {} func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[120] + mi := &file_proto_v1_gateway_proto_msgTypes[118] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9888,7 +9776,7 @@ func (x *FsCreateDirResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsCreateDirResponse.ProtoReflect.Descriptor instead. func (*FsCreateDirResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{118} } func (x *FsCreateDirResponse) GetPath() string { @@ -9916,7 +9804,7 @@ type FsRenameRequest struct { func (x *FsRenameRequest) Reset() { *x = FsRenameRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[121] + mi := &file_proto_v1_gateway_proto_msgTypes[119] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9928,7 +9816,7 @@ func (x *FsRenameRequest) String() string { func (*FsRenameRequest) ProtoMessage() {} func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[121] + mi := &file_proto_v1_gateway_proto_msgTypes[119] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -9941,7 +9829,7 @@ func (x *FsRenameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameRequest.ProtoReflect.Descriptor instead. func (*FsRenameRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{119} } func (x *FsRenameRequest) GetWorkdir() string { @@ -9976,7 +9864,7 @@ type FsRenameResponse struct { func (x *FsRenameResponse) Reset() { *x = FsRenameResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[122] + mi := &file_proto_v1_gateway_proto_msgTypes[120] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -9988,7 +9876,7 @@ func (x *FsRenameResponse) String() string { func (*FsRenameResponse) ProtoMessage() {} func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[122] + mi := &file_proto_v1_gateway_proto_msgTypes[120] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10001,7 +9889,7 @@ func (x *FsRenameResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsRenameResponse.ProtoReflect.Descriptor instead. func (*FsRenameResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{120} } func (x *FsRenameResponse) GetFromPath() string { @@ -10035,7 +9923,7 @@ type FsDeleteRequest struct { func (x *FsDeleteRequest) Reset() { *x = FsDeleteRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[123] + mi := &file_proto_v1_gateway_proto_msgTypes[121] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10047,7 +9935,7 @@ func (x *FsDeleteRequest) String() string { func (*FsDeleteRequest) ProtoMessage() {} func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[123] + mi := &file_proto_v1_gateway_proto_msgTypes[121] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10060,7 +9948,7 @@ func (x *FsDeleteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteRequest.ProtoReflect.Descriptor instead. func (*FsDeleteRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{123} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{121} } func (x *FsDeleteRequest) GetWorkdir() string { @@ -10087,7 +9975,7 @@ type FsDeleteResponse struct { func (x *FsDeleteResponse) Reset() { *x = FsDeleteResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[124] + mi := &file_proto_v1_gateway_proto_msgTypes[122] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10099,7 +9987,7 @@ func (x *FsDeleteResponse) String() string { func (*FsDeleteResponse) ProtoMessage() {} func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[124] + mi := &file_proto_v1_gateway_proto_msgTypes[122] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10112,7 +10000,7 @@ func (x *FsDeleteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FsDeleteResponse.ProtoReflect.Descriptor instead. func (*FsDeleteResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{124} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{122} } func (x *FsDeleteResponse) GetPath() string { @@ -10138,7 +10026,7 @@ type PingRequest struct { func (x *PingRequest) Reset() { *x = PingRequest{} - mi := &file_proto_v1_gateway_proto_msgTypes[125] + mi := &file_proto_v1_gateway_proto_msgTypes[123] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10150,7 +10038,7 @@ func (x *PingRequest) String() string { func (*PingRequest) ProtoMessage() {} func (x *PingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[125] + mi := &file_proto_v1_gateway_proto_msgTypes[123] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10163,7 +10051,7 @@ func (x *PingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PingRequest.ProtoReflect.Descriptor instead. func (*PingRequest) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{125} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{123} } func (x *PingRequest) GetTimestamp() int64 { @@ -10182,7 +10070,7 @@ type PongResponse struct { func (x *PongResponse) Reset() { *x = PongResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[126] + mi := &file_proto_v1_gateway_proto_msgTypes[124] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10194,7 +10082,7 @@ func (x *PongResponse) String() string { func (*PongResponse) ProtoMessage() {} func (x *PongResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[126] + mi := &file_proto_v1_gateway_proto_msgTypes[124] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10207,7 +10095,7 @@ func (x *PongResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PongResponse.ProtoReflect.Descriptor instead. func (*PongResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{126} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{124} } func (x *PongResponse) GetTimestamp() int64 { @@ -10227,7 +10115,7 @@ type ErrorResponse struct { func (x *ErrorResponse) Reset() { *x = ErrorResponse{} - mi := &file_proto_v1_gateway_proto_msgTypes[127] + mi := &file_proto_v1_gateway_proto_msgTypes[125] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -10239,7 +10127,7 @@ func (x *ErrorResponse) String() string { func (*ErrorResponse) ProtoMessage() {} func (x *ErrorResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_v1_gateway_proto_msgTypes[127] + mi := &file_proto_v1_gateway_proto_msgTypes[125] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -10252,7 +10140,7 @@ func (x *ErrorResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ErrorResponse.ProtoReflect.Descriptor instead. func (*ErrorResponse) Descriptor() ([]byte, []int) { - return file_proto_v1_gateway_proto_rawDescGZIP(), []int{127} + return file_proto_v1_gateway_proto_rawDescGZIP(), []int{125} } func (x *ErrorResponse) GetCode() int32 { @@ -10282,7 +10170,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "session_id\x18\x03 \x01(\tR\tsessionId\"\xa2\x1d\n" + + "session_id\x18\x03 \x01(\tR\tsessionId\"\xcc\x1c\n" + "\x0fGatewayEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -10332,12 +10220,11 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\fsftp_request\x18@ \x01(\v2!.liveagent.gateway.v1.SftpRequestH\x00R\vsftpRequest\x12z\n" + "\x1dsettings_reset_ssh_known_host\x18H \x01(\v26.liveagent.gateway.v1.SettingsResetSshKnownHostRequestH\x00R\x19settingsResetSshKnownHost\x12G\n" + "\n" + - "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueue\x12Z\n" + - "\x11chat_event_replay\x18J \x01(\v2,.liveagent.gateway.v1.ChatEventReplayRequestH\x00R\x0fchatEventReplay\x12N\n" + + "chat_queue\x18I \x01(\v2&.liveagent.gateway.v1.ChatQueueRequestH\x00R\tchatQueue\x12N\n" + "\ftunnel_state\x18P \x01(\v2).liveagent.gateway.v1.TunnelStateSnapshotH\x00R\vtunnelState\x12O\n" + "\x0ftunnel_mutation\x18Q \x01(\v2$.liveagent.gateway.v1.TunnelMutationH\x00R\x0etunnelMutation\x12F\n" + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrameB\t\n" + - "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10F\"\xaa&\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10FJ\x04\bJ\x10K\"\xca%\n" + "\rAgentEnvelope\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1c\n" + @@ -10392,14 +10279,13 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\fchat_control\x18F \x01(\v2&.liveagent.gateway.v1.ChatControlEventH\x00R\vchatControl\x12Q\n" + "\x0eruntime_status\x18G \x01(\v2(.liveagent.gateway.v1.RuntimeStatusEventH\x00R\rruntimeStatus\x12\x84\x01\n" + "\"settings_reset_ssh_known_host_resp\x18H \x01(\v27.liveagent.gateway.v1.SettingsResetSshKnownHostResponseH\x00R\x1dsettingsResetSshKnownHostResp\x12_\n" + - "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12d\n" + - "\x16chat_event_replay_resp\x18N \x01(\v2-.liveagent.gateway.v1.ChatEventReplayResponseH\x00R\x13chatEventReplayResp\x12Q\n" + + "\x15chat_runtime_snapshot\x18M \x01(\v2).liveagent.gateway.v1.ChatRuntimeSnapshotH\x00R\x13chatRuntimeSnapshot\x12Q\n" + "\x0etunnel_desired\x18P \x01(\v2(.liveagent.gateway.v1.TunnelDesiredStateH\x00R\rtunnelDesired\x12b\n" + "\x16tunnel_mutation_result\x18Q \x01(\v2*.liveagent.gateway.v1.TunnelMutationResultH\x00R\x14tunnelMutationResult\x12F\n" + "\ftunnel_frame\x18R \x01(\v2!.liveagent.gateway.v1.TunnelFrameH\x00R\vtunnelFrame\x12Y\n" + "\x13tunnel_probe_report\x18S \x01(\v2'.liveagent.gateway.v1.TunnelProbeReportH\x00R\x11tunnelProbeReport\x12;\n" + "\x05error\x18c \x01(\v2#.liveagent.gateway.v1.ErrorResponseH\x00R\x05errorB\t\n" + - "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10F\"|\n" + + "\apayloadJ\x04\bC\x10DJ\x04\bD\x10EJ\x04\bE\x10FJ\x04\bN\x10O\"|\n" + "\x11ChatSelectedModel\x12,\n" + "\x12custom_provider_id\x18\x01 \x01(\tR\x10customProviderId\x12\x14\n" + "\x05model\x18\x02 \x01(\tR\x05model\x12#\n" + @@ -10800,26 +10686,25 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\vtool_status\x18\n" + " \x01(\tR\n" + "toolStatus\x129\n" + - "\x19tool_status_is_compaction\x18\v \x01(\bR\x16toolStatusIsCompaction\"\xa9\x01\n" + + "\x19tool_status_is_compaction\x18\v \x01(\bR\x16toolStatusIsCompaction\"\xb9\x02\n" + "\x12RuntimeStatusEvent\x12\x1b\n" + "\tworker_id\x18\x01 \x01(\tR\bworkerId\x12\x14\n" + "\x05state\x18\x02 \x01(\tR\x05state\x12\x18\n" + "\avisible\x18\x03 \x01(\bR\avisible\x12(\n" + "\x10active_run_count\x18\x04 \x01(\rR\x0eactiveRunCount\x12\x1c\n" + - "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\"u\n" + - "\x16ChatEventReplayRequest\x12\x15\n" + + "\ttimestamp\x18\x05 \x01(\x03R\ttimestamp\x12D\n" + + "\vactive_runs\x18\x06 \x03(\v2#.liveagent.gateway.v1.ChatRunReportR\n" + + "activeRuns\x12H\n" + + "\rfinished_runs\x18\a \x03(\v2#.liveagent.gateway.v1.ChatRunReportR\ffinishedRuns\"\xbd\x01\n" + + "\rChatRunReport\x12\x15\n" + "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + - "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12\x1b\n" + - "\tafter_seq\x18\x03 \x01(\x03R\bafterSeq\"\xb4\x01\n" + - "\x17ChatEventReplayResponse\x12\x15\n" + - "\x06run_id\x18\x01 \x01(\tR\x05runId\x12'\n" + - "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12=\n" + - "\x06events\x18\x03 \x03(\v2%.liveagent.gateway.v1.ChatReplayEventR\x06events\x12\x1a\n" + - "\bcomplete\x18\x04 \x01(\bR\bcomplete\"B\n" + - "\x0fChatReplayEvent\x12\x10\n" + - "\x03seq\x18\x01 \x01(\x03R\x03seq\x12\x1d\n" + + "\x0fconversation_id\x18\x02 \x01(\tR\x0econversationId\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12\x1d\n" + + "\n" + + "error_code\x18\x04 \x01(\tR\terrorCode\x12\x18\n" + + "\amessage\x18\x05 \x01(\tR\amessage\x12\x1d\n" + "\n" + - "event_json\x18\x02 \x01(\tR\teventJson\"a\n" + + "updated_at\x18\x06 \x01(\x03R\tupdatedAt\"a\n" + "\x11CronManageRequest\x12\x16\n" + "\x06action\x18\x01 \x01(\tR\x06action\x12\x17\n" + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x1b\n" + @@ -11133,7 +11018,7 @@ func file_proto_v1_gateway_proto_rawDescGZIP() []byte { } var file_proto_v1_gateway_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 128) +var file_proto_v1_gateway_proto_msgTypes = make([]protoimpl.MessageInfo, 126) var file_proto_v1_gateway_proto_goTypes = []any{ (TunnelFrameKind)(0), // 0: liveagent.gateway.v1.TunnelFrameKind (TunnelWsMessageType)(0), // 1: liveagent.gateway.v1.TunnelWsMessageType @@ -11191,238 +11076,235 @@ var file_proto_v1_gateway_proto_goTypes = []any{ (*ChatControlEvent)(nil), // 53: liveagent.gateway.v1.ChatControlEvent (*ChatRuntimeSnapshot)(nil), // 54: liveagent.gateway.v1.ChatRuntimeSnapshot (*RuntimeStatusEvent)(nil), // 55: liveagent.gateway.v1.RuntimeStatusEvent - (*ChatEventReplayRequest)(nil), // 56: liveagent.gateway.v1.ChatEventReplayRequest - (*ChatEventReplayResponse)(nil), // 57: liveagent.gateway.v1.ChatEventReplayResponse - (*ChatReplayEvent)(nil), // 58: liveagent.gateway.v1.ChatReplayEvent - (*CronManageRequest)(nil), // 59: liveagent.gateway.v1.CronManageRequest - (*CronManageResponse)(nil), // 60: liveagent.gateway.v1.CronManageResponse - (*HistoryListRequest)(nil), // 61: liveagent.gateway.v1.HistoryListRequest - (*HistoryListResponse)(nil), // 62: liveagent.gateway.v1.HistoryListResponse - (*ConversationSummary)(nil), // 63: liveagent.gateway.v1.ConversationSummary - (*HistoryGetRequest)(nil), // 64: liveagent.gateway.v1.HistoryGetRequest - (*HistoryGetResponse)(nil), // 65: liveagent.gateway.v1.HistoryGetResponse - (*HistoryPrefixRequest)(nil), // 66: liveagent.gateway.v1.HistoryPrefixRequest - (*HistoryPrefixResponse)(nil), // 67: liveagent.gateway.v1.HistoryPrefixResponse - (*HistoryRenameRequest)(nil), // 68: liveagent.gateway.v1.HistoryRenameRequest - (*HistoryRenameResponse)(nil), // 69: liveagent.gateway.v1.HistoryRenameResponse - (*HistoryPinRequest)(nil), // 70: liveagent.gateway.v1.HistoryPinRequest - (*HistoryPinResponse)(nil), // 71: liveagent.gateway.v1.HistoryPinResponse - (*HistoryShareStatus)(nil), // 72: liveagent.gateway.v1.HistoryShareStatus - (*HistoryShareGetRequest)(nil), // 73: liveagent.gateway.v1.HistoryShareGetRequest - (*HistoryShareGetResponse)(nil), // 74: liveagent.gateway.v1.HistoryShareGetResponse - (*HistoryShareSetRequest)(nil), // 75: liveagent.gateway.v1.HistoryShareSetRequest - (*HistoryShareSetResponse)(nil), // 76: liveagent.gateway.v1.HistoryShareSetResponse - (*HistoryShareResolveRequest)(nil), // 77: liveagent.gateway.v1.HistoryShareResolveRequest - (*HistoryShareResolveResponse)(nil), // 78: liveagent.gateway.v1.HistoryShareResolveResponse - (*HistoryWorkdirsRequest)(nil), // 79: liveagent.gateway.v1.HistoryWorkdirsRequest - (*HistoryWorkdirSummary)(nil), // 80: liveagent.gateway.v1.HistoryWorkdirSummary - (*HistoryWorkdirsResponse)(nil), // 81: liveagent.gateway.v1.HistoryWorkdirsResponse - (*HistoryDeleteRequest)(nil), // 82: liveagent.gateway.v1.HistoryDeleteRequest - (*HistoryDeleteResponse)(nil), // 83: liveagent.gateway.v1.HistoryDeleteResponse - (*HistorySyncEvent)(nil), // 84: liveagent.gateway.v1.HistorySyncEvent - (*ProviderListRequest)(nil), // 85: liveagent.gateway.v1.ProviderListRequest - (*ProviderListResponse)(nil), // 86: liveagent.gateway.v1.ProviderListResponse - (*SettingsGetRequest)(nil), // 87: liveagent.gateway.v1.SettingsGetRequest - (*SettingsGetResponse)(nil), // 88: liveagent.gateway.v1.SettingsGetResponse - (*SettingsUpdateRequest)(nil), // 89: liveagent.gateway.v1.SettingsUpdateRequest - (*SettingsUpdateResponse)(nil), // 90: liveagent.gateway.v1.SettingsUpdateResponse - (*SettingsResetSshKnownHostRequest)(nil), // 91: liveagent.gateway.v1.SettingsResetSshKnownHostRequest - (*SettingsResetSshKnownHostResponse)(nil), // 92: liveagent.gateway.v1.SettingsResetSshKnownHostResponse - (*SettingsSyncEvent)(nil), // 93: liveagent.gateway.v1.SettingsSyncEvent - (*SkillFilesListRequest)(nil), // 94: liveagent.gateway.v1.SkillFilesListRequest - (*SkillFilesListResponse)(nil), // 95: liveagent.gateway.v1.SkillFilesListResponse - (*SkillMetadataReadRequest)(nil), // 96: liveagent.gateway.v1.SkillMetadataReadRequest - (*SkillMetadataReadResponse)(nil), // 97: liveagent.gateway.v1.SkillMetadataReadResponse - (*SkillTextReadRequest)(nil), // 98: liveagent.gateway.v1.SkillTextReadRequest - (*SkillTextReadResponse)(nil), // 99: liveagent.gateway.v1.SkillTextReadResponse - (*SkillManageRequest)(nil), // 100: liveagent.gateway.v1.SkillManageRequest - (*SkillManageResponse)(nil), // 101: liveagent.gateway.v1.SkillManageResponse - (*FileMentionListRequest)(nil), // 102: liveagent.gateway.v1.FileMentionListRequest - (*FileMentionEntry)(nil), // 103: liveagent.gateway.v1.FileMentionEntry - (*FileMentionListResponse)(nil), // 104: liveagent.gateway.v1.FileMentionListResponse - (*FsRoot)(nil), // 105: liveagent.gateway.v1.FsRoot - (*FsRootsRequest)(nil), // 106: liveagent.gateway.v1.FsRootsRequest - (*FsRootsResponse)(nil), // 107: liveagent.gateway.v1.FsRootsResponse - (*FsListDirsRequest)(nil), // 108: liveagent.gateway.v1.FsListDirsRequest - (*FsDirEntry)(nil), // 109: liveagent.gateway.v1.FsDirEntry - (*FsListDirsResponse)(nil), // 110: liveagent.gateway.v1.FsListDirsResponse - (*FsCreateProjectFolderRequest)(nil), // 111: liveagent.gateway.v1.FsCreateProjectFolderRequest - (*FsCreateProjectFolderResponse)(nil), // 112: liveagent.gateway.v1.FsCreateProjectFolderResponse - (*FsListRequest)(nil), // 113: liveagent.gateway.v1.FsListRequest - (*FsListEntry)(nil), // 114: liveagent.gateway.v1.FsListEntry - (*FsListResponse)(nil), // 115: liveagent.gateway.v1.FsListResponse - (*FsReadEditableTextRequest)(nil), // 116: liveagent.gateway.v1.FsReadEditableTextRequest - (*FsReadEditableTextResponse)(nil), // 117: liveagent.gateway.v1.FsReadEditableTextResponse - (*FsReadWorkspaceImageRequest)(nil), // 118: liveagent.gateway.v1.FsReadWorkspaceImageRequest - (*FsReadWorkspaceImageResponse)(nil), // 119: liveagent.gateway.v1.FsReadWorkspaceImageResponse - (*FsWriteTextRequest)(nil), // 120: liveagent.gateway.v1.FsWriteTextRequest - (*FsWriteTextResponse)(nil), // 121: liveagent.gateway.v1.FsWriteTextResponse - (*FsCreateDirRequest)(nil), // 122: liveagent.gateway.v1.FsCreateDirRequest - (*FsCreateDirResponse)(nil), // 123: liveagent.gateway.v1.FsCreateDirResponse - (*FsRenameRequest)(nil), // 124: liveagent.gateway.v1.FsRenameRequest - (*FsRenameResponse)(nil), // 125: liveagent.gateway.v1.FsRenameResponse - (*FsDeleteRequest)(nil), // 126: liveagent.gateway.v1.FsDeleteRequest - (*FsDeleteResponse)(nil), // 127: liveagent.gateway.v1.FsDeleteResponse - (*PingRequest)(nil), // 128: liveagent.gateway.v1.PingRequest - (*PongResponse)(nil), // 129: liveagent.gateway.v1.PongResponse - (*ErrorResponse)(nil), // 130: liveagent.gateway.v1.ErrorResponse + (*ChatRunReport)(nil), // 56: liveagent.gateway.v1.ChatRunReport + (*CronManageRequest)(nil), // 57: liveagent.gateway.v1.CronManageRequest + (*CronManageResponse)(nil), // 58: liveagent.gateway.v1.CronManageResponse + (*HistoryListRequest)(nil), // 59: liveagent.gateway.v1.HistoryListRequest + (*HistoryListResponse)(nil), // 60: liveagent.gateway.v1.HistoryListResponse + (*ConversationSummary)(nil), // 61: liveagent.gateway.v1.ConversationSummary + (*HistoryGetRequest)(nil), // 62: liveagent.gateway.v1.HistoryGetRequest + (*HistoryGetResponse)(nil), // 63: liveagent.gateway.v1.HistoryGetResponse + (*HistoryPrefixRequest)(nil), // 64: liveagent.gateway.v1.HistoryPrefixRequest + (*HistoryPrefixResponse)(nil), // 65: liveagent.gateway.v1.HistoryPrefixResponse + (*HistoryRenameRequest)(nil), // 66: liveagent.gateway.v1.HistoryRenameRequest + (*HistoryRenameResponse)(nil), // 67: liveagent.gateway.v1.HistoryRenameResponse + (*HistoryPinRequest)(nil), // 68: liveagent.gateway.v1.HistoryPinRequest + (*HistoryPinResponse)(nil), // 69: liveagent.gateway.v1.HistoryPinResponse + (*HistoryShareStatus)(nil), // 70: liveagent.gateway.v1.HistoryShareStatus + (*HistoryShareGetRequest)(nil), // 71: liveagent.gateway.v1.HistoryShareGetRequest + (*HistoryShareGetResponse)(nil), // 72: liveagent.gateway.v1.HistoryShareGetResponse + (*HistoryShareSetRequest)(nil), // 73: liveagent.gateway.v1.HistoryShareSetRequest + (*HistoryShareSetResponse)(nil), // 74: liveagent.gateway.v1.HistoryShareSetResponse + (*HistoryShareResolveRequest)(nil), // 75: liveagent.gateway.v1.HistoryShareResolveRequest + (*HistoryShareResolveResponse)(nil), // 76: liveagent.gateway.v1.HistoryShareResolveResponse + (*HistoryWorkdirsRequest)(nil), // 77: liveagent.gateway.v1.HistoryWorkdirsRequest + (*HistoryWorkdirSummary)(nil), // 78: liveagent.gateway.v1.HistoryWorkdirSummary + (*HistoryWorkdirsResponse)(nil), // 79: liveagent.gateway.v1.HistoryWorkdirsResponse + (*HistoryDeleteRequest)(nil), // 80: liveagent.gateway.v1.HistoryDeleteRequest + (*HistoryDeleteResponse)(nil), // 81: liveagent.gateway.v1.HistoryDeleteResponse + (*HistorySyncEvent)(nil), // 82: liveagent.gateway.v1.HistorySyncEvent + (*ProviderListRequest)(nil), // 83: liveagent.gateway.v1.ProviderListRequest + (*ProviderListResponse)(nil), // 84: liveagent.gateway.v1.ProviderListResponse + (*SettingsGetRequest)(nil), // 85: liveagent.gateway.v1.SettingsGetRequest + (*SettingsGetResponse)(nil), // 86: liveagent.gateway.v1.SettingsGetResponse + (*SettingsUpdateRequest)(nil), // 87: liveagent.gateway.v1.SettingsUpdateRequest + (*SettingsUpdateResponse)(nil), // 88: liveagent.gateway.v1.SettingsUpdateResponse + (*SettingsResetSshKnownHostRequest)(nil), // 89: liveagent.gateway.v1.SettingsResetSshKnownHostRequest + (*SettingsResetSshKnownHostResponse)(nil), // 90: liveagent.gateway.v1.SettingsResetSshKnownHostResponse + (*SettingsSyncEvent)(nil), // 91: liveagent.gateway.v1.SettingsSyncEvent + (*SkillFilesListRequest)(nil), // 92: liveagent.gateway.v1.SkillFilesListRequest + (*SkillFilesListResponse)(nil), // 93: liveagent.gateway.v1.SkillFilesListResponse + (*SkillMetadataReadRequest)(nil), // 94: liveagent.gateway.v1.SkillMetadataReadRequest + (*SkillMetadataReadResponse)(nil), // 95: liveagent.gateway.v1.SkillMetadataReadResponse + (*SkillTextReadRequest)(nil), // 96: liveagent.gateway.v1.SkillTextReadRequest + (*SkillTextReadResponse)(nil), // 97: liveagent.gateway.v1.SkillTextReadResponse + (*SkillManageRequest)(nil), // 98: liveagent.gateway.v1.SkillManageRequest + (*SkillManageResponse)(nil), // 99: liveagent.gateway.v1.SkillManageResponse + (*FileMentionListRequest)(nil), // 100: liveagent.gateway.v1.FileMentionListRequest + (*FileMentionEntry)(nil), // 101: liveagent.gateway.v1.FileMentionEntry + (*FileMentionListResponse)(nil), // 102: liveagent.gateway.v1.FileMentionListResponse + (*FsRoot)(nil), // 103: liveagent.gateway.v1.FsRoot + (*FsRootsRequest)(nil), // 104: liveagent.gateway.v1.FsRootsRequest + (*FsRootsResponse)(nil), // 105: liveagent.gateway.v1.FsRootsResponse + (*FsListDirsRequest)(nil), // 106: liveagent.gateway.v1.FsListDirsRequest + (*FsDirEntry)(nil), // 107: liveagent.gateway.v1.FsDirEntry + (*FsListDirsResponse)(nil), // 108: liveagent.gateway.v1.FsListDirsResponse + (*FsCreateProjectFolderRequest)(nil), // 109: liveagent.gateway.v1.FsCreateProjectFolderRequest + (*FsCreateProjectFolderResponse)(nil), // 110: liveagent.gateway.v1.FsCreateProjectFolderResponse + (*FsListRequest)(nil), // 111: liveagent.gateway.v1.FsListRequest + (*FsListEntry)(nil), // 112: liveagent.gateway.v1.FsListEntry + (*FsListResponse)(nil), // 113: liveagent.gateway.v1.FsListResponse + (*FsReadEditableTextRequest)(nil), // 114: liveagent.gateway.v1.FsReadEditableTextRequest + (*FsReadEditableTextResponse)(nil), // 115: liveagent.gateway.v1.FsReadEditableTextResponse + (*FsReadWorkspaceImageRequest)(nil), // 116: liveagent.gateway.v1.FsReadWorkspaceImageRequest + (*FsReadWorkspaceImageResponse)(nil), // 117: liveagent.gateway.v1.FsReadWorkspaceImageResponse + (*FsWriteTextRequest)(nil), // 118: liveagent.gateway.v1.FsWriteTextRequest + (*FsWriteTextResponse)(nil), // 119: liveagent.gateway.v1.FsWriteTextResponse + (*FsCreateDirRequest)(nil), // 120: liveagent.gateway.v1.FsCreateDirRequest + (*FsCreateDirResponse)(nil), // 121: liveagent.gateway.v1.FsCreateDirResponse + (*FsRenameRequest)(nil), // 122: liveagent.gateway.v1.FsRenameRequest + (*FsRenameResponse)(nil), // 123: liveagent.gateway.v1.FsRenameResponse + (*FsDeleteRequest)(nil), // 124: liveagent.gateway.v1.FsDeleteRequest + (*FsDeleteResponse)(nil), // 125: liveagent.gateway.v1.FsDeleteResponse + (*PingRequest)(nil), // 126: liveagent.gateway.v1.PingRequest + (*PongResponse)(nil), // 127: liveagent.gateway.v1.PongResponse + (*ErrorResponse)(nil), // 128: liveagent.gateway.v1.ErrorResponse } var file_proto_v1_gateway_proto_depIdxs = []int32{ 48, // 0: liveagent.gateway.v1.GatewayEnvelope.chat_command:type_name -> liveagent.gateway.v1.ChatCommandRequest - 59, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest - 61, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest - 64, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest - 68, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest - 82, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest - 66, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest - 70, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest - 73, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest - 75, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest - 77, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest - 79, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest - 85, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest - 87, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest - 89, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest - 94, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest - 96, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest - 98, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest - 102, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest + 57, // 1: liveagent.gateway.v1.GatewayEnvelope.cron_manage:type_name -> liveagent.gateway.v1.CronManageRequest + 59, // 2: liveagent.gateway.v1.GatewayEnvelope.history_list:type_name -> liveagent.gateway.v1.HistoryListRequest + 62, // 3: liveagent.gateway.v1.GatewayEnvelope.history_get:type_name -> liveagent.gateway.v1.HistoryGetRequest + 66, // 4: liveagent.gateway.v1.GatewayEnvelope.history_rename:type_name -> liveagent.gateway.v1.HistoryRenameRequest + 80, // 5: liveagent.gateway.v1.GatewayEnvelope.history_delete:type_name -> liveagent.gateway.v1.HistoryDeleteRequest + 64, // 6: liveagent.gateway.v1.GatewayEnvelope.history_prefix:type_name -> liveagent.gateway.v1.HistoryPrefixRequest + 68, // 7: liveagent.gateway.v1.GatewayEnvelope.history_pin:type_name -> liveagent.gateway.v1.HistoryPinRequest + 71, // 8: liveagent.gateway.v1.GatewayEnvelope.history_share_get:type_name -> liveagent.gateway.v1.HistoryShareGetRequest + 73, // 9: liveagent.gateway.v1.GatewayEnvelope.history_share_set:type_name -> liveagent.gateway.v1.HistoryShareSetRequest + 75, // 10: liveagent.gateway.v1.GatewayEnvelope.history_share_resolve:type_name -> liveagent.gateway.v1.HistoryShareResolveRequest + 77, // 11: liveagent.gateway.v1.GatewayEnvelope.history_workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirsRequest + 83, // 12: liveagent.gateway.v1.GatewayEnvelope.provider_list:type_name -> liveagent.gateway.v1.ProviderListRequest + 85, // 13: liveagent.gateway.v1.GatewayEnvelope.settings_get:type_name -> liveagent.gateway.v1.SettingsGetRequest + 87, // 14: liveagent.gateway.v1.GatewayEnvelope.settings_update:type_name -> liveagent.gateway.v1.SettingsUpdateRequest + 92, // 15: liveagent.gateway.v1.GatewayEnvelope.skill_files_list:type_name -> liveagent.gateway.v1.SkillFilesListRequest + 94, // 16: liveagent.gateway.v1.GatewayEnvelope.skill_metadata_read:type_name -> liveagent.gateway.v1.SkillMetadataReadRequest + 96, // 17: liveagent.gateway.v1.GatewayEnvelope.skill_text_read:type_name -> liveagent.gateway.v1.SkillTextReadRequest + 100, // 18: liveagent.gateway.v1.GatewayEnvelope.file_mention_list:type_name -> liveagent.gateway.v1.FileMentionListRequest 11, // 19: liveagent.gateway.v1.GatewayEnvelope.upload_readable_files:type_name -> liveagent.gateway.v1.UploadReadableFilesRequest - 106, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest - 108, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest - 128, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest + 104, // 20: liveagent.gateway.v1.GatewayEnvelope.fs_roots:type_name -> liveagent.gateway.v1.FsRootsRequest + 106, // 21: liveagent.gateway.v1.GatewayEnvelope.fs_list_dirs:type_name -> liveagent.gateway.v1.FsListDirsRequest + 126, // 22: liveagent.gateway.v1.GatewayEnvelope.ping:type_name -> liveagent.gateway.v1.PingRequest 13, // 23: liveagent.gateway.v1.GatewayEnvelope.uploaded_image_preview:type_name -> liveagent.gateway.v1.UploadedImagePreviewRequest 26, // 24: liveagent.gateway.v1.GatewayEnvelope.memory_manage:type_name -> liveagent.gateway.v1.MemoryManageRequest - 100, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest - 111, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest + 98, // 25: liveagent.gateway.v1.GatewayEnvelope.skill_manage:type_name -> liveagent.gateway.v1.SkillManageRequest + 109, // 26: liveagent.gateway.v1.GatewayEnvelope.fs_create_project_folder:type_name -> liveagent.gateway.v1.FsCreateProjectFolderRequest 28, // 27: liveagent.gateway.v1.GatewayEnvelope.terminal_request:type_name -> liveagent.gateway.v1.TerminalRequest - 113, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest - 120, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest - 122, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest - 124, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest - 126, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest + 111, // 28: liveagent.gateway.v1.GatewayEnvelope.fs_list:type_name -> liveagent.gateway.v1.FsListRequest + 118, // 29: liveagent.gateway.v1.GatewayEnvelope.fs_write_text:type_name -> liveagent.gateway.v1.FsWriteTextRequest + 120, // 30: liveagent.gateway.v1.GatewayEnvelope.fs_create_dir:type_name -> liveagent.gateway.v1.FsCreateDirRequest + 122, // 31: liveagent.gateway.v1.GatewayEnvelope.fs_rename:type_name -> liveagent.gateway.v1.FsRenameRequest + 124, // 32: liveagent.gateway.v1.GatewayEnvelope.fs_delete:type_name -> liveagent.gateway.v1.FsDeleteRequest 43, // 33: liveagent.gateway.v1.GatewayEnvelope.git_request:type_name -> liveagent.gateway.v1.GitRequest - 116, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest - 118, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest + 114, // 34: liveagent.gateway.v1.GatewayEnvelope.fs_read_editable_text:type_name -> liveagent.gateway.v1.FsReadEditableTextRequest + 116, // 35: liveagent.gateway.v1.GatewayEnvelope.fs_read_workspace_image:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageRequest 31, // 36: liveagent.gateway.v1.GatewayEnvelope.sftp_request:type_name -> liveagent.gateway.v1.SftpRequest - 91, // 37: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest + 89, // 37: liveagent.gateway.v1.GatewayEnvelope.settings_reset_ssh_known_host:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostRequest 49, // 38: liveagent.gateway.v1.GatewayEnvelope.chat_queue:type_name -> liveagent.gateway.v1.ChatQueueRequest - 56, // 39: liveagent.gateway.v1.GatewayEnvelope.chat_event_replay:type_name -> liveagent.gateway.v1.ChatEventReplayRequest - 19, // 40: liveagent.gateway.v1.GatewayEnvelope.tunnel_state:type_name -> liveagent.gateway.v1.TunnelStateSnapshot - 20, // 41: liveagent.gateway.v1.GatewayEnvelope.tunnel_mutation:type_name -> liveagent.gateway.v1.TunnelMutation - 25, // 42: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 52, // 43: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent - 60, // 44: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse - 62, // 45: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse - 65, // 46: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse - 69, // 47: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse - 83, // 48: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse - 84, // 49: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent - 67, // 50: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse - 71, // 51: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse - 74, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse - 76, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse - 78, // 54: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse - 81, // 55: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse - 86, // 56: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse - 88, // 57: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse - 90, // 58: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse - 93, // 59: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent - 95, // 60: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse - 97, // 61: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse - 99, // 62: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse - 104, // 63: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse - 12, // 64: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse - 107, // 65: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse - 129, // 66: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse - 110, // 67: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse - 14, // 68: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse - 27, // 69: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse - 101, // 70: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse - 112, // 71: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse - 40, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse - 41, // 73: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent - 115, // 74: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse - 121, // 75: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse - 123, // 76: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse - 125, // 77: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse - 127, // 78: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse - 44, // 79: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse - 117, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse - 119, // 81: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse - 34, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse - 35, // 83: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent - 50, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse - 51, // 85: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent - 53, // 86: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent - 55, // 87: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent - 92, // 88: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse - 54, // 89: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot - 57, // 90: liveagent.gateway.v1.AgentEnvelope.chat_event_replay_resp:type_name -> liveagent.gateway.v1.ChatEventReplayResponse - 16, // 91: liveagent.gateway.v1.AgentEnvelope.tunnel_desired:type_name -> liveagent.gateway.v1.TunnelDesiredState - 21, // 92: liveagent.gateway.v1.AgentEnvelope.tunnel_mutation_result:type_name -> liveagent.gateway.v1.TunnelMutationResult - 25, // 93: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame - 23, // 94: liveagent.gateway.v1.AgentEnvelope.tunnel_probe_report:type_name -> liveagent.gateway.v1.TunnelProbeReport - 130, // 95: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse - 10, // 96: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile - 9, // 97: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 15, // 98: liveagent.gateway.v1.TunnelDesiredState.tunnels:type_name -> liveagent.gateway.v1.TunnelSpec - 17, // 99: liveagent.gateway.v1.TunnelStatus.local:type_name -> liveagent.gateway.v1.TunnelHealth - 18, // 100: liveagent.gateway.v1.TunnelStateSnapshot.tunnels:type_name -> liveagent.gateway.v1.TunnelStatus - 17, // 101: liveagent.gateway.v1.TunnelStateSnapshot.relay:type_name -> liveagent.gateway.v1.TunnelHealth - 17, // 102: liveagent.gateway.v1.TunnelProbeResult.local:type_name -> liveagent.gateway.v1.TunnelHealth - 22, // 103: liveagent.gateway.v1.TunnelProbeReport.results:type_name -> liveagent.gateway.v1.TunnelProbeResult - 0, // 104: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind - 24, // 105: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader - 1, // 106: liveagent.gateway.v1.TunnelFrame.ws_message_type:type_name -> liveagent.gateway.v1.TunnelWsMessageType - 30, // 107: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata - 32, // 108: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry - 32, // 109: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry - 33, // 110: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 33, // 111: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer - 38, // 112: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab - 29, // 113: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession - 29, // 114: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession - 37, // 115: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption - 36, // 116: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt - 39, // 117: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 29, // 118: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession - 39, // 119: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot - 29, // 120: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession - 7, // 121: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel - 9, // 122: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile - 8, // 123: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls - 45, // 124: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest - 46, // 125: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 47, // 126: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest - 2, // 127: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType - 58, // 128: liveagent.gateway.v1.ChatEventReplayResponse.events:type_name -> liveagent.gateway.v1.ChatReplayEvent - 63, // 129: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary - 63, // 130: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 46, // 131: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef - 63, // 132: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 63, // 133: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 63, // 134: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 72, // 135: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 72, // 136: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus - 63, // 137: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 80, // 138: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary - 63, // 139: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary - 103, // 140: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry - 105, // 141: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot - 109, // 142: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry - 114, // 143: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 6, // 144: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 42, // 145: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame - 3, // 146: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 5, // 147: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 42, // 148: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame - 4, // 149: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 147, // [147:150] is the sub-list for method output_type - 144, // [144:147] is the sub-list for method input_type - 144, // [144:144] is the sub-list for extension type_name - 144, // [144:144] is the sub-list for extension extendee - 0, // [0:144] is the sub-list for field type_name + 19, // 39: liveagent.gateway.v1.GatewayEnvelope.tunnel_state:type_name -> liveagent.gateway.v1.TunnelStateSnapshot + 20, // 40: liveagent.gateway.v1.GatewayEnvelope.tunnel_mutation:type_name -> liveagent.gateway.v1.TunnelMutation + 25, // 41: liveagent.gateway.v1.GatewayEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 52, // 42: liveagent.gateway.v1.AgentEnvelope.chat_event:type_name -> liveagent.gateway.v1.ChatEvent + 58, // 43: liveagent.gateway.v1.AgentEnvelope.cron_manage_resp:type_name -> liveagent.gateway.v1.CronManageResponse + 60, // 44: liveagent.gateway.v1.AgentEnvelope.history_list_resp:type_name -> liveagent.gateway.v1.HistoryListResponse + 63, // 45: liveagent.gateway.v1.AgentEnvelope.history_get_resp:type_name -> liveagent.gateway.v1.HistoryGetResponse + 67, // 46: liveagent.gateway.v1.AgentEnvelope.history_rename_resp:type_name -> liveagent.gateway.v1.HistoryRenameResponse + 81, // 47: liveagent.gateway.v1.AgentEnvelope.history_delete_resp:type_name -> liveagent.gateway.v1.HistoryDeleteResponse + 82, // 48: liveagent.gateway.v1.AgentEnvelope.history_sync:type_name -> liveagent.gateway.v1.HistorySyncEvent + 65, // 49: liveagent.gateway.v1.AgentEnvelope.history_prefix_resp:type_name -> liveagent.gateway.v1.HistoryPrefixResponse + 69, // 50: liveagent.gateway.v1.AgentEnvelope.history_pin_resp:type_name -> liveagent.gateway.v1.HistoryPinResponse + 72, // 51: liveagent.gateway.v1.AgentEnvelope.history_share_get_resp:type_name -> liveagent.gateway.v1.HistoryShareGetResponse + 74, // 52: liveagent.gateway.v1.AgentEnvelope.history_share_set_resp:type_name -> liveagent.gateway.v1.HistoryShareSetResponse + 76, // 53: liveagent.gateway.v1.AgentEnvelope.history_share_resolve_resp:type_name -> liveagent.gateway.v1.HistoryShareResolveResponse + 79, // 54: liveagent.gateway.v1.AgentEnvelope.history_workdirs_resp:type_name -> liveagent.gateway.v1.HistoryWorkdirsResponse + 84, // 55: liveagent.gateway.v1.AgentEnvelope.provider_list_resp:type_name -> liveagent.gateway.v1.ProviderListResponse + 86, // 56: liveagent.gateway.v1.AgentEnvelope.settings_get_resp:type_name -> liveagent.gateway.v1.SettingsGetResponse + 88, // 57: liveagent.gateway.v1.AgentEnvelope.settings_update_resp:type_name -> liveagent.gateway.v1.SettingsUpdateResponse + 91, // 58: liveagent.gateway.v1.AgentEnvelope.settings_sync:type_name -> liveagent.gateway.v1.SettingsSyncEvent + 93, // 59: liveagent.gateway.v1.AgentEnvelope.skill_files_list_resp:type_name -> liveagent.gateway.v1.SkillFilesListResponse + 95, // 60: liveagent.gateway.v1.AgentEnvelope.skill_metadata_read_resp:type_name -> liveagent.gateway.v1.SkillMetadataReadResponse + 97, // 61: liveagent.gateway.v1.AgentEnvelope.skill_text_read_resp:type_name -> liveagent.gateway.v1.SkillTextReadResponse + 102, // 62: liveagent.gateway.v1.AgentEnvelope.file_mention_list_resp:type_name -> liveagent.gateway.v1.FileMentionListResponse + 12, // 63: liveagent.gateway.v1.AgentEnvelope.upload_readable_files_resp:type_name -> liveagent.gateway.v1.UploadReadableFilesResponse + 105, // 64: liveagent.gateway.v1.AgentEnvelope.fs_roots_resp:type_name -> liveagent.gateway.v1.FsRootsResponse + 127, // 65: liveagent.gateway.v1.AgentEnvelope.pong:type_name -> liveagent.gateway.v1.PongResponse + 108, // 66: liveagent.gateway.v1.AgentEnvelope.fs_list_dirs_resp:type_name -> liveagent.gateway.v1.FsListDirsResponse + 14, // 67: liveagent.gateway.v1.AgentEnvelope.uploaded_image_preview_resp:type_name -> liveagent.gateway.v1.UploadedImagePreviewResponse + 27, // 68: liveagent.gateway.v1.AgentEnvelope.memory_manage_resp:type_name -> liveagent.gateway.v1.MemoryManageResponse + 99, // 69: liveagent.gateway.v1.AgentEnvelope.skill_manage_resp:type_name -> liveagent.gateway.v1.SkillManageResponse + 110, // 70: liveagent.gateway.v1.AgentEnvelope.fs_create_project_folder_resp:type_name -> liveagent.gateway.v1.FsCreateProjectFolderResponse + 40, // 71: liveagent.gateway.v1.AgentEnvelope.terminal_response:type_name -> liveagent.gateway.v1.TerminalResponse + 41, // 72: liveagent.gateway.v1.AgentEnvelope.terminal_event:type_name -> liveagent.gateway.v1.TerminalEvent + 113, // 73: liveagent.gateway.v1.AgentEnvelope.fs_list_resp:type_name -> liveagent.gateway.v1.FsListResponse + 119, // 74: liveagent.gateway.v1.AgentEnvelope.fs_write_text_resp:type_name -> liveagent.gateway.v1.FsWriteTextResponse + 121, // 75: liveagent.gateway.v1.AgentEnvelope.fs_create_dir_resp:type_name -> liveagent.gateway.v1.FsCreateDirResponse + 123, // 76: liveagent.gateway.v1.AgentEnvelope.fs_rename_resp:type_name -> liveagent.gateway.v1.FsRenameResponse + 125, // 77: liveagent.gateway.v1.AgentEnvelope.fs_delete_resp:type_name -> liveagent.gateway.v1.FsDeleteResponse + 44, // 78: liveagent.gateway.v1.AgentEnvelope.git_response:type_name -> liveagent.gateway.v1.GitResponse + 115, // 79: liveagent.gateway.v1.AgentEnvelope.fs_read_editable_text_resp:type_name -> liveagent.gateway.v1.FsReadEditableTextResponse + 117, // 80: liveagent.gateway.v1.AgentEnvelope.fs_read_workspace_image_resp:type_name -> liveagent.gateway.v1.FsReadWorkspaceImageResponse + 34, // 81: liveagent.gateway.v1.AgentEnvelope.sftp_response:type_name -> liveagent.gateway.v1.SftpResponse + 35, // 82: liveagent.gateway.v1.AgentEnvelope.sftp_event:type_name -> liveagent.gateway.v1.SftpEvent + 50, // 83: liveagent.gateway.v1.AgentEnvelope.chat_queue_resp:type_name -> liveagent.gateway.v1.ChatQueueResponse + 51, // 84: liveagent.gateway.v1.AgentEnvelope.chat_queue_event:type_name -> liveagent.gateway.v1.ChatQueueEvent + 53, // 85: liveagent.gateway.v1.AgentEnvelope.chat_control:type_name -> liveagent.gateway.v1.ChatControlEvent + 55, // 86: liveagent.gateway.v1.AgentEnvelope.runtime_status:type_name -> liveagent.gateway.v1.RuntimeStatusEvent + 90, // 87: liveagent.gateway.v1.AgentEnvelope.settings_reset_ssh_known_host_resp:type_name -> liveagent.gateway.v1.SettingsResetSshKnownHostResponse + 54, // 88: liveagent.gateway.v1.AgentEnvelope.chat_runtime_snapshot:type_name -> liveagent.gateway.v1.ChatRuntimeSnapshot + 16, // 89: liveagent.gateway.v1.AgentEnvelope.tunnel_desired:type_name -> liveagent.gateway.v1.TunnelDesiredState + 21, // 90: liveagent.gateway.v1.AgentEnvelope.tunnel_mutation_result:type_name -> liveagent.gateway.v1.TunnelMutationResult + 25, // 91: liveagent.gateway.v1.AgentEnvelope.tunnel_frame:type_name -> liveagent.gateway.v1.TunnelFrame + 23, // 92: liveagent.gateway.v1.AgentEnvelope.tunnel_probe_report:type_name -> liveagent.gateway.v1.TunnelProbeReport + 128, // 93: liveagent.gateway.v1.AgentEnvelope.error:type_name -> liveagent.gateway.v1.ErrorResponse + 10, // 94: liveagent.gateway.v1.UploadReadableFilesRequest.files:type_name -> liveagent.gateway.v1.UploadReadableFile + 9, // 95: liveagent.gateway.v1.UploadReadableFilesResponse.files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 15, // 96: liveagent.gateway.v1.TunnelDesiredState.tunnels:type_name -> liveagent.gateway.v1.TunnelSpec + 17, // 97: liveagent.gateway.v1.TunnelStatus.local:type_name -> liveagent.gateway.v1.TunnelHealth + 18, // 98: liveagent.gateway.v1.TunnelStateSnapshot.tunnels:type_name -> liveagent.gateway.v1.TunnelStatus + 17, // 99: liveagent.gateway.v1.TunnelStateSnapshot.relay:type_name -> liveagent.gateway.v1.TunnelHealth + 17, // 100: liveagent.gateway.v1.TunnelProbeResult.local:type_name -> liveagent.gateway.v1.TunnelHealth + 22, // 101: liveagent.gateway.v1.TunnelProbeReport.results:type_name -> liveagent.gateway.v1.TunnelProbeResult + 0, // 102: liveagent.gateway.v1.TunnelFrame.kind:type_name -> liveagent.gateway.v1.TunnelFrameKind + 24, // 103: liveagent.gateway.v1.TunnelFrame.headers:type_name -> liveagent.gateway.v1.TunnelHeader + 1, // 104: liveagent.gateway.v1.TunnelFrame.ws_message_type:type_name -> liveagent.gateway.v1.TunnelWsMessageType + 30, // 105: liveagent.gateway.v1.TerminalSession.ssh:type_name -> liveagent.gateway.v1.TerminalSshMetadata + 32, // 106: liveagent.gateway.v1.SftpResponse.entries:type_name -> liveagent.gateway.v1.SftpEntry + 32, // 107: liveagent.gateway.v1.SftpResponse.entry:type_name -> liveagent.gateway.v1.SftpEntry + 33, // 108: liveagent.gateway.v1.SftpResponse.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 33, // 109: liveagent.gateway.v1.SftpEvent.transfer:type_name -> liveagent.gateway.v1.SftpTransfer + 38, // 110: liveagent.gateway.v1.TerminalSshTabsSnapshot.tabs:type_name -> liveagent.gateway.v1.TerminalSshTab + 29, // 111: liveagent.gateway.v1.TerminalResponse.sessions:type_name -> liveagent.gateway.v1.TerminalSession + 29, // 112: liveagent.gateway.v1.TerminalResponse.session:type_name -> liveagent.gateway.v1.TerminalSession + 37, // 113: liveagent.gateway.v1.TerminalResponse.shell_options:type_name -> liveagent.gateway.v1.TerminalShellOption + 36, // 114: liveagent.gateway.v1.TerminalResponse.ssh_prompt:type_name -> liveagent.gateway.v1.TerminalSshPrompt + 39, // 115: liveagent.gateway.v1.TerminalResponse.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 29, // 116: liveagent.gateway.v1.TerminalEvent.session:type_name -> liveagent.gateway.v1.TerminalSession + 39, // 117: liveagent.gateway.v1.TerminalEvent.ssh_tabs:type_name -> liveagent.gateway.v1.TerminalSshTabsSnapshot + 29, // 118: liveagent.gateway.v1.TerminalStreamFrame.session:type_name -> liveagent.gateway.v1.TerminalSession + 7, // 119: liveagent.gateway.v1.ChatRequest.selected_model:type_name -> liveagent.gateway.v1.ChatSelectedModel + 9, // 120: liveagent.gateway.v1.ChatRequest.uploaded_files:type_name -> liveagent.gateway.v1.ChatUploadedFile + 8, // 121: liveagent.gateway.v1.ChatRequest.runtime_controls:type_name -> liveagent.gateway.v1.ChatRuntimeControls + 45, // 122: liveagent.gateway.v1.ChatCommandRequest.request:type_name -> liveagent.gateway.v1.ChatRequest + 46, // 123: liveagent.gateway.v1.ChatCommandRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 47, // 124: liveagent.gateway.v1.ChatCommandRequest.cancel:type_name -> liveagent.gateway.v1.CancelChatRequest + 2, // 125: liveagent.gateway.v1.ChatEvent.type:type_name -> liveagent.gateway.v1.ChatEvent.ChatEventType + 56, // 126: liveagent.gateway.v1.RuntimeStatusEvent.active_runs:type_name -> liveagent.gateway.v1.ChatRunReport + 56, // 127: liveagent.gateway.v1.RuntimeStatusEvent.finished_runs:type_name -> liveagent.gateway.v1.ChatRunReport + 61, // 128: liveagent.gateway.v1.HistoryListResponse.conversations:type_name -> liveagent.gateway.v1.ConversationSummary + 61, // 129: liveagent.gateway.v1.HistoryGetResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 46, // 130: liveagent.gateway.v1.HistoryPrefixRequest.base_message_ref:type_name -> liveagent.gateway.v1.ChatMessageRef + 61, // 131: liveagent.gateway.v1.HistoryPrefixResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 61, // 132: liveagent.gateway.v1.HistoryRenameResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 61, // 133: liveagent.gateway.v1.HistoryPinResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 70, // 134: liveagent.gateway.v1.HistoryShareGetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 70, // 135: liveagent.gateway.v1.HistoryShareSetResponse.share:type_name -> liveagent.gateway.v1.HistoryShareStatus + 61, // 136: liveagent.gateway.v1.HistoryShareResolveResponse.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 78, // 137: liveagent.gateway.v1.HistoryWorkdirsResponse.workdirs:type_name -> liveagent.gateway.v1.HistoryWorkdirSummary + 61, // 138: liveagent.gateway.v1.HistorySyncEvent.conversation:type_name -> liveagent.gateway.v1.ConversationSummary + 101, // 139: liveagent.gateway.v1.FileMentionListResponse.entries:type_name -> liveagent.gateway.v1.FileMentionEntry + 103, // 140: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot + 107, // 141: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry + 112, // 142: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry + 6, // 143: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope + 42, // 144: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame + 3, // 145: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest + 5, // 146: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope + 42, // 147: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame + 4, // 148: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse + 146, // [146:149] is the sub-list for method output_type + 143, // [143:146] is the sub-list for method input_type + 143, // [143:143] is the sub-list for extension type_name + 143, // [143:143] is the sub-list for extension extendee + 0, // [0:143] is the sub-list for field type_name } func init() { file_proto_v1_gateway_proto_init() } @@ -11470,7 +11352,6 @@ func file_proto_v1_gateway_proto_init() { (*GatewayEnvelope_SftpRequest)(nil), (*GatewayEnvelope_SettingsResetSshKnownHost)(nil), (*GatewayEnvelope_ChatQueue)(nil), - (*GatewayEnvelope_ChatEventReplay)(nil), (*GatewayEnvelope_TunnelState)(nil), (*GatewayEnvelope_TunnelMutation)(nil), (*GatewayEnvelope_TunnelFrame)(nil), @@ -11523,7 +11404,6 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_RuntimeStatus)(nil), (*AgentEnvelope_SettingsResetSshKnownHostResp)(nil), (*AgentEnvelope_ChatRuntimeSnapshot)(nil), - (*AgentEnvelope_ChatEventReplayResp)(nil), (*AgentEnvelope_TunnelDesired)(nil), (*AgentEnvelope_TunnelMutationResult)(nil), (*AgentEnvelope_TunnelFrame)(nil), @@ -11531,14 +11411,14 @@ func file_proto_v1_gateway_proto_init() { (*AgentEnvelope_Error)(nil), } file_proto_v1_gateway_proto_msgTypes[17].OneofWrappers = []any{} - file_proto_v1_gateway_proto_msgTypes[72].OneofWrappers = []any{} + file_proto_v1_gateway_proto_msgTypes[70].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_v1_gateway_proto_rawDesc), len(file_proto_v1_gateway_proto_rawDesc)), NumEnums: 3, - NumMessages: 128, + NumMessages: 126, NumExtensions: 0, NumServices: 1, }, diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go index a0b9a0a5..f7097f68 100644 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_chat_handlers.go @@ -103,6 +103,20 @@ func (c *websocketConnection) forwardConversationEvents( } } +// handleChatActivities answers from gateway state only — no agent round-trip +// — so clients can reconcile running conversations while the desktop is +// offline. +func (c *websocketConnection) handleChatActivities(req websocketRequest) { + var body struct{} + if err := decodeWebSocketPayload(req.Payload, &body); err != nil { + _ = c.writeError(req.ID, "invalid chat.activities payload") + return + } + _ = c.writeResponse(req.ID, map[string]any{ + "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), + }) +} + func (c *websocketConnection) handleChatUnsubscribe(req websocketRequest) { var payload struct { ConversationID string `json:"conversation_id"` diff --git a/crates/agent-gateway/internal/server/websocket_history_handlers.go b/crates/agent-gateway/internal/server/websocket_history_handlers.go index a2694592..052f3f02 100644 --- a/crates/agent-gateway/internal/server/websocket_history_handlers.go +++ b/crates/agent-gateway/internal/server/websocket_history_handlers.go @@ -64,22 +64,10 @@ func (c *websocketConnection) handleHistoryList(req websocketRequest) { conversations = append(conversations, websocketConversationSummaryPayload(conversation)) } - activities := c.sm.ActiveConversationActivities() - runningConversations := make([]map[string]any, 0, len(activities)) - for _, activity := range activities { - runningConversations = append(runningConversations, map[string]any{ - "conversation_id": activity.ConversationID, - "run_id": activity.RunID, - "state": activity.State, - "cwd": activity.Workdir, - "updated_at": activity.UpdatedAt.UnixMilli(), - }) - } - _ = c.writeResponse(req.ID, map[string]any{ "conversations": conversations, "total_count": resp.GetTotalCount(), - "running_conversations": runningConversations, + "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), }) } diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go index 982431ca..a42281c9 100644 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ b/crates/agent-gateway/internal/server/websocket_payloads.go @@ -141,6 +141,22 @@ func websocketChatActivityPayload(event session.ConversationActivityEvent) map[s return payload } +// websocketRunningConversationsPayload is the wire shape for running-run +// summaries, shared by history.list and chat.activities. +func websocketRunningConversationsPayload(activities []session.RunActivity) []map[string]any { + runningConversations := make([]map[string]any, 0, len(activities)) + for _, activity := range activities { + runningConversations = append(runningConversations, map[string]any{ + "conversation_id": activity.ConversationID, + "run_id": activity.RunID, + "state": activity.State, + "cwd": activity.Workdir, + "updated_at": activity.UpdatedAt.UnixMilli(), + }) + } + return runningConversations +} + func websocketRunActivityPayload(activity *session.RunActivity) map[string]any { if activity == nil { return nil diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go index 7e9a070a..1fcbbef2 100644 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ b/crates/agent-gateway/internal/server/websocket_routes.go @@ -87,6 +87,7 @@ var websocketRequestHandlers = map[string]websocketRequestHandler{ "provider.models": (*websocketConnection).handleProviderModels, "chat.subscribe": (*websocketConnection).handleChatSubscribe, "chat.unsubscribe": (*websocketConnection).handleChatUnsubscribe, + "chat.activities": (*websocketConnection).handleChatActivities, "chat.command": (*websocketConnection).handleChatCommand, "chat.cancel": (*websocketConnection).handleChatCancel, "chat_queue.get": (*websocketConnection).handleChatQueueRequest, diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go index bd3a78d5..ae01276e 100644 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ b/crates/agent-gateway/internal/server/websocket_routes_test.go @@ -90,6 +90,7 @@ func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { "provider.models", "chat.subscribe", "chat.unsubscribe", + "chat.activities", "chat.command", "chat.cancel", "chat_queue.get", diff --git a/crates/agent-gateway/internal/session/conversation_stream.go b/crates/agent-gateway/internal/session/conversation_stream.go index b17d8f65..d7799d0e 100644 --- a/crates/agent-gateway/internal/session/conversation_stream.go +++ b/crates/agent-gateway/internal/session/conversation_stream.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/uuid" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) // The conversation stream store is the authoritative relay state for chat: @@ -29,10 +30,14 @@ const ( conversationMaxEventBytes = 8 << 20 conversationIdleRetention = 30 * time.Minute conversationStaleRunTimeout = 10 * time.Minute + conversationOfflineRunTimeout = 30 * time.Minute conversationReaperInterval = time.Minute conversationFinishedRunMemory = 8 conversationSubscriberBuffer = 256 pendingChatRunRetention = 5 * time.Minute + // conversationRunReportLostTimeout is the grace window before a run absent + // from the desktop's run reports is finalized as lost. + conversationRunReportLostTimeout = 15 * time.Second ) const ( @@ -194,36 +199,36 @@ type conversationStreamStore struct { activityHub *chatActivityHub - // lastRuntimeBusyAt is the last time the agent reported a non-zero - // active-run count; while it stays fresh, silent runs are never reaped. - lastRuntimeBusyAt time.Time - reaperOnce sync.Once isOnline func() bool // tunable in tests - eventRetention time.Duration - maxEvents int - maxEventBytes int - idleRetention time.Duration - staleRunTimeout time.Duration - reaperInterval time.Duration + eventRetention time.Duration + maxEvents int + maxEventBytes int + idleRetention time.Duration + staleRunTimeout time.Duration + offlineRunTimeout time.Duration + runReportLostTimeout time.Duration + reaperInterval time.Duration } func newConversationStreamStore(isOnline func() bool) *conversationStreamStore { return &conversationStreamStore{ - streams: make(map[string]*conversationStream), - pendingRuns: make(map[string]*pendingChatRun), - runs: make(map[string]*chatRunRecord), - commandWatchers: make(map[string][]chan ChatCommandUpdate), - activityHub: newChatActivityHub(), - isOnline: isOnline, - eventRetention: conversationEventRetention, - maxEvents: conversationMaxEvents, - maxEventBytes: conversationMaxEventBytes, - idleRetention: conversationIdleRetention, - staleRunTimeout: conversationStaleRunTimeout, - reaperInterval: conversationReaperInterval, + streams: make(map[string]*conversationStream), + pendingRuns: make(map[string]*pendingChatRun), + runs: make(map[string]*chatRunRecord), + commandWatchers: make(map[string][]chan ChatCommandUpdate), + activityHub: newChatActivityHub(), + isOnline: isOnline, + eventRetention: conversationEventRetention, + maxEvents: conversationMaxEvents, + maxEventBytes: conversationMaxEventBytes, + idleRetention: conversationIdleRetention, + staleRunTimeout: conversationStaleRunTimeout, + offlineRunTimeout: conversationOfflineRunTimeout, + runReportLostTimeout: conversationRunReportLostTimeout, + reaperInterval: conversationReaperInterval, } } @@ -980,14 +985,61 @@ func (m *Manager) ForceFinishRun(runID string, status string, errorCode string, // --- maintenance ----------------------------------------------------------- -// onRuntimeStatus feeds the agent's reported active-run count into staleness -// tracking: while the runtime keeps reporting busy, silent runs (a long tool -// call producing no events) must never be reaped. -func (s *conversationStreamStore) onRuntimeStatus(activeRunCount uint32, now time.Time) { +// onRuntimeStatus reconciles the desktop's run ledger with tracked +// activities: active reports vouch per run, finished reports adopt terminal +// signals the gateway missed, and a run absent from both is finalized once +// nothing has vouched for it within the grace window. Every vouch bumps +// activity.UpdatedAt, so its staleness measures continuous absence. +func (s *conversationStreamStore) onRuntimeStatus(event *gatewayv1.RuntimeStatusEvent, now time.Time) { s.mu.Lock() defer s.mu.Unlock() - if activeRunCount > 0 { - s.lastRuntimeBusyAt = now + + activeSet := make(map[string]bool, len(event.GetActiveRuns())) + for _, report := range event.GetActiveRuns() { + activeSet[report.GetRunId()] = true + } + finished := make(map[string]*gatewayv1.ChatRunReport, len(event.GetFinishedRuns())) + for _, report := range event.GetFinishedRuns() { + finished[report.GetRunId()] = report + } + + // Reconcile only tracked activities; finished reports never resurrect a + // stream for a run this store is not tracking. + for _, stream := range s.streams { + if stream.activity == nil { + continue + } + runID := stream.activity.RunID + if stream.activity.State == RunActivityQueued { + // The accepted-command startup watchdog owns the queued phase; + // the desktop may not know the run yet. + continue + } + if activeSet[runID] { + stream.activity.UpdatedAt = now + continue + } + if report, ok := finished[runID]; ok { + state := report.GetState() + errorCode := report.GetErrorCode() + switch state { + case "completed", "failed", "cancelled": + default: + state = "failed" + errorCode = "desktop_run_lost" + } + s.runFinishedLocked(stream, runID, state, errorCode, report.GetMessage(), + map[string]any{"reason": "desktop_reported"}, now) + continue + } + // Stream events vouch too: never finalize a run whose events are still + // flowing through the relay (mirrors the reaper's lastAlive logic). + eventsQuiet := stream.lastEventAt.IsZero() || + now.Sub(stream.lastEventAt) >= s.runReportLostTimeout + if eventsQuiet && now.Sub(stream.activity.UpdatedAt) >= s.runReportLostTimeout { + s.runFinishedLocked(stream, runID, "failed", "desktop_run_lost", + "The desktop runtime stopped reporting this run.", nil, now) + } } } @@ -1016,21 +1068,22 @@ func (s *conversationStreamStore) reap(now time.Time) { for conversationID, stream := range s.streams { s.evictStreamLocked(stream, now) - if stream.activity != nil && online { + if stream.activity != nil { // A run is stale only when NOTHING vouches for it: no stream - // events, no runtime-busy heartbeat, and no activity transition - // within the timeout. A silent long tool call keeps the runtime - // busy heartbeat fresh and is never reaped. + // events and no activity transition/report-vouch within the + // timeout (onRuntimeStatus bumps UpdatedAt for reported runs). lastAlive := stream.lastEventAt - if s.lastRuntimeBusyAt.After(lastAlive) { - lastAlive = s.lastRuntimeBusyAt - } if stream.activity.UpdatedAt.After(lastAlive) { lastAlive = stream.activity.UpdatedAt } - if !lastAlive.IsZero() && now.Sub(lastAlive) > s.staleRunTimeout { - s.runFinishedLocked(stream, stream.activity.RunID, "failed", "stale_run", - "The desktop runtime stopped reporting this run.", nil, now) + if online { + if !lastAlive.IsZero() && now.Sub(lastAlive) > s.staleRunTimeout { + s.runFinishedLocked(stream, stream.activity.RunID, "failed", "stale_run", + "The desktop runtime stopped reporting this run.", nil, now) + } + } else if !lastAlive.IsZero() && now.Sub(lastAlive) > s.offlineRunTimeout { + s.runFinishedLocked(stream, stream.activity.RunID, "failed", "agent_offline", + "The desktop agent went offline during this run.", nil, now) } } diff --git a/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go b/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go new file mode 100644 index 00000000..caec3465 --- /dev/null +++ b/crates/agent-gateway/internal/session/conversation_stream_reconcile_test.go @@ -0,0 +1,204 @@ +package session + +import ( + "testing" + "time" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +func runReport(runID string, conversationID string, state string) *gatewayv1.ChatRunReport { + return &gatewayv1.ChatRunReport{ + RunId: runID, + ConversationId: conversationID, + State: state, + } +} + +func runsReport( + active []*gatewayv1.ChatRunReport, + finished []*gatewayv1.ChatRunReport, +) *gatewayv1.RuntimeStatusEvent { + return &gatewayv1.RuntimeStatusEvent{ + ActiveRunCount: uint32(len(active)), + ActiveRuns: active, + FinishedRuns: finished, + } +} + +func lastEvent(t *testing.T, m *Manager, conversationID string) *ConversationEvent { + t.Helper() + sub := m.SubscribeConversationStream(conversationID, 0, "") + sub.Cleanup() + if len(sub.Events) == 0 { + t.Fatalf("no events for %s", conversationID) + } + return sub.Events[len(sub.Events)-1] +} + +// A terminal the gateway never received (lost desktop signal) is adopted from +// the desktop's finished_runs report with its real final state. +func TestRunReportAdoptsMissedTerminal(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + + m.convStreams.onRuntimeStatus(runsReport(nil, []*gatewayv1.ChatRunReport{ + runReport("run-1", "conv-1", "completed"), + }), time.Now()) + + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || last.Payload["status"] != "completed" { + t.Fatalf("adopted terminal = %s %#v, want run_finished/completed", last.Type, last.Payload) + } + if last.Payload["reason"] != "desktop_reported" { + t.Fatalf("adopted terminal reason = %#v, want desktop_reported", last.Payload["reason"]) + } + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("activity not cleared after adopted terminal, activities=%d", len(activities)) + } + + // A finished report with an unknown state is not trusted verbatim: the + // run fails with desktop_run_lost instead. + m2 := NewManager() + m2.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + m2.convStreams.onRuntimeStatus(runsReport(nil, []*gatewayv1.ChatRunReport{ + runReport("run-2", "conv-2", "exploded"), + }), time.Now()) + last2 := lastEvent(t, m2, "conv-2") + if last2.Type != StreamEventRunFinished || + last2.Payload["status"] != "failed" || + last2.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("invalid-state terminal = %s %#v, want failed/desktop_run_lost", last2.Type, last2.Payload) + } +} + +// A run absent from the desktop's reports survives the grace window (measured +// from the last vouch/transition), then is finalized as lost; a vouch before +// expiry restarts the window. +func TestRunReportFinalizesLostRunAfterGrace(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + t0 := time.Now() + empty := runsReport(nil, nil) + + m.convStreams.onRuntimeStatus(empty, t0.Add(8*time.Second)) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("run finalized below grace, activities=%d", len(activities)) + } + + m.convStreams.onRuntimeStatus(empty, t0.Add(16*time.Second)) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("lost run not finalized after grace, activities=%d", len(activities)) + } + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || + last.Payload["status"] != "failed" || + last.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("lost finish tail = %s %#v, want failed/desktop_run_lost", last.Type, last.Payload) + } + + // Reported active again before grace expiry: the run survives and the + // absence window restarts from the vouch. + m2 := NewManager() + m2.ingestChatControl("run-2", startedControl("run-2", "conv-2")) + t1 := time.Now() + m2.convStreams.onRuntimeStatus(runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-2", "conv-2", "running"), + }, nil), t1.Add(8*time.Second)) + m2.convStreams.onRuntimeStatus(runsReport(nil, nil), t1.Add(20*time.Second)) + if activities := m2.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("grace window must restart after a vouch, activities=%d", len(activities)) + } +} + +// Queued runs belong to the accepted-command startup watchdog; the desktop may +// not know them yet, so reconcile never finalizes them. +func TestRunReportSkipsQueuedRuns(t *testing.T) { + m := NewManager() + m.StartChatCommand("run-1", "conv-1", "/workspace", "client-1", []map[string]any{ + {"type": "user_message", "message": "hello"}, + }) + + t0 := time.Now() + m.convStreams.onRuntimeStatus(runsReport(nil, nil), t0) + m.convStreams.onRuntimeStatus(runsReport(nil, nil), t0.Add(time.Hour)) + + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].State != RunActivityQueued { + t.Fatalf("queued run must survive reconcile, activities=%#v", activities) + } +} + +// Liveness is per run: a vouched run keeps streaming while an absent run in +// another conversation is finalized at grace. +func TestRunReportPerConversationLiveness(t *testing.T) { + m := NewManager() + m.ingestChatControl("run-a", startedControl("run-a", "conv-a")) + m.ingestChatControl("run-b", startedControl("run-b", "conv-b")) + t0 := time.Now() + vouchA := runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-a", "conv-a", "running"), + }, nil) + + m.convStreams.onRuntimeStatus(vouchA, t0) + m.convStreams.onRuntimeStatus(vouchA, t0.Add(16*time.Second)) + + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-a" { + t.Fatalf("vouched run must outlive the lost one, activities=%#v", activities) + } + last := lastEvent(t, m, "conv-b") + if last.Type != StreamEventRunFinished || last.Payload["error_code"] != "desktop_run_lost" { + t.Fatalf("conv-b tail = %s %#v, want failed/desktop_run_lost", last.Type, last.Payload) + } +} + +// The reaper is per run too: a report vouching only for another conversation +// must not shield a run the desktop stopped vouching for. +func TestReaperSparesOnlyVouchedRuns(t *testing.T) { + m := NewManager() + m.convStreams.staleRunTimeout = 10 * time.Millisecond + m.SetSession(&AgentSession{ + toAgent: make(chan *OutboundEnvelope, 1), + done: make(chan struct{}), + streams: make(map[string]*agentStream), + }) + + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + time.Sleep(20 * time.Millisecond) + + m.ingestChatControl("run-other", startedControl("run-other", "conv-other")) + m.convStreams.onRuntimeStatus(runsReport([]*gatewayv1.ChatRunReport{ + runReport("run-other", "conv-other", "running"), + }, nil), time.Now()) + + m.convStreams.reap(time.Now()) + activities := m.ActiveConversationActivities() + if len(activities) != 1 || activities[0].RunID != "run-other" { + t.Fatalf("unvouched run must be reaped, activities=%#v", activities) + } +} + +// Offline runs are not immortal: past offlineRunTimeout they finalize as +// agent_offline; below it they are kept. +func TestReaperFinalizesRunsAfterOfflineTimeout(t *testing.T) { + m := NewManager() // no session: isOnline() == false + m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) + t0 := time.Now() + + m.convStreams.reap(t0.Add(29 * time.Minute)) + if activities := m.ActiveConversationActivities(); len(activities) != 1 { + t.Fatalf("run below offline timeout must be kept, activities=%d", len(activities)) + } + + m.convStreams.reap(t0.Add(31 * time.Minute)) + if activities := m.ActiveConversationActivities(); len(activities) != 0 { + t.Fatalf("run beyond offline timeout must be finalized, activities=%d", len(activities)) + } + last := lastEvent(t, m, "conv-1") + if last.Type != StreamEventRunFinished || + last.Payload["status"] != "failed" || + last.Payload["error_code"] != "agent_offline" { + t.Fatalf("offline finish tail = %s %#v, want failed/agent_offline", last.Type, last.Payload) + } +} diff --git a/crates/agent-gateway/internal/session/conversation_stream_test.go b/crates/agent-gateway/internal/session/conversation_stream_test.go index a250349f..acd66e93 100644 --- a/crates/agent-gateway/internal/session/conversation_stream_test.go +++ b/crates/agent-gateway/internal/session/conversation_stream_test.go @@ -612,7 +612,7 @@ func TestSubscriberOverflowClosesAndResumes(t *testing.T) { } } -func TestReaperSparesSilentRunsWhileRuntimeReportsBusy(t *testing.T) { +func TestReaperSparesSilentRunsWhileReportsVouch(t *testing.T) { m := NewManager() m.convStreams.staleRunTimeout = 10 * time.Millisecond m.SetSession(&AgentSession{ @@ -624,19 +624,23 @@ func TestReaperSparesSilentRunsWhileRuntimeReportsBusy(t *testing.T) { m.ingestChatControl("run-1", startedControl("run-1", "conv-1")) time.Sleep(20 * time.Millisecond) - // A silent long tool call: no events, but the runtime heartbeat says busy. - m.convStreams.onRuntimeStatus(1, time.Now()) + // A silent long tool call: no events, but the run report vouches for it. + m.convStreams.onRuntimeStatus(&gatewayv1.RuntimeStatusEvent{ + ActiveRuns: []*gatewayv1.ChatRunReport{ + {RunId: "run-1", ConversationId: "conv-1", State: "running"}, + }, + }, time.Now()) m.convStreams.reap(time.Now()) if activities := m.ActiveConversationActivities(); len(activities) != 1 { - t.Fatalf("busy runtime must spare the silent run, activities=%d", len(activities)) + t.Fatalf("vouched silent run must be spared, activities=%d", len(activities)) } - // Once the runtime stops vouching for it, the run is reaped after the + // Once the reports stop vouching for it, the run is reaped after the // timeout elapses again. time.Sleep(20 * time.Millisecond) m.convStreams.reap(time.Now()) if activities := m.ActiveConversationActivities(); len(activities) != 0 { - t.Fatalf("stale run must be reaped once the runtime goes idle, activities=%d", len(activities)) + t.Fatalf("stale run must be reaped once vouching stops, activities=%d", len(activities)) } } diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go index 9754b90a..2b3e9845 100644 --- a/crates/agent-gateway/internal/session/manager_dispatch.go +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -25,7 +25,7 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent if runtimeStatus := env.GetRuntimeStatus(); runtimeStatus != nil { m.UpdateRuntimeStatus(session, runtimeStatus) - m.convStreams.onRuntimeStatus(runtimeStatus.GetActiveRunCount(), time.Now()) + m.convStreams.onRuntimeStatus(runtimeStatus, time.Now()) return } diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 1d01985a..3bd79cf4 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -66,14 +66,14 @@ message GatewayEnvelope { SftpRequest sftp_request = 64; SettingsResetSshKnownHostRequest settings_reset_ssh_known_host = 72; ChatQueueRequest chat_queue = 73; - ChatEventReplayRequest chat_event_replay = 74; TunnelStateSnapshot tunnel_state = 80; TunnelMutation tunnel_mutation = 81; TunnelFrame tunnel_frame = 82; } - // Legacy tunnel control/frame payloads (pre-rewrite protocol). - reserved 67, 68, 69; + // Legacy tunnel control/frame payloads (pre-rewrite protocol) and the + // never-wired chat event replay request. + reserved 67, 68, 69, 74; } message AgentEnvelope { @@ -128,7 +128,6 @@ message AgentEnvelope { RuntimeStatusEvent runtime_status = 71; SettingsResetSshKnownHostResponse settings_reset_ssh_known_host_resp = 72; ChatRuntimeSnapshot chat_runtime_snapshot = 77; - ChatEventReplayResponse chat_event_replay_resp = 78; TunnelDesiredState tunnel_desired = 80; TunnelMutationResult tunnel_mutation_result = 81; TunnelFrame tunnel_frame = 82; @@ -136,8 +135,9 @@ message AgentEnvelope { ErrorResponse error = 99; } - // Legacy tunnel control/frame payloads (pre-rewrite protocol). - reserved 67, 68, 69; + // Legacy tunnel control/frame payloads (pre-rewrite protocol) and the + // never-wired chat event replay response. + reserved 67, 68, 69, 78; } message ChatSelectedModel { @@ -618,24 +618,17 @@ message RuntimeStatusEvent { bool visible = 3; uint32 active_run_count = 4; int64 timestamp = 5; + repeated ChatRunReport active_runs = 6; + repeated ChatRunReport finished_runs = 7; } -message ChatEventReplayRequest { +message ChatRunReport { string run_id = 1; string conversation_id = 2; - int64 after_seq = 3; -} - -message ChatEventReplayResponse { - string run_id = 1; - string conversation_id = 2; - repeated ChatReplayEvent events = 3; - bool complete = 4; -} - -message ChatReplayEvent { - int64 seq = 1; - string event_json = 2; + string state = 3; + string error_code = 4; + string message = 5; + int64 updated_at = 6; } message CronManageRequest { diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 44d0a353..ff98012c 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -408,7 +408,18 @@ export default function GatewayApp() { // Transcript stores (one per conversation), the global activity map, and // the command pipeline replace the old live-store registry, running-id // unions, and recovery machinery. - const transcriptStoreRegistry = useMemo(() => createTranscriptStoreRegistry(), []); + // Ref indirection: the registry memo is stable across token changes while + // the api client is not, and divergence resyncs must reach the live client. + const apiRef = useRef(api); + apiRef.current = api; + const transcriptStoreRegistry = useMemo( + () => + createTranscriptStoreRegistry({ + onDivergence: (divergedConversationId) => + apiRef.current?.resyncConversation(divergedConversationId), + }), + [], + ); const activityStore = useMemo(() => createActivityStore(), []); const pipelineOnBoundRef = useRef<(update: ChatCommandUpdate, pending: PendingChatCommand) => void>( () => undefined, @@ -1469,6 +1480,37 @@ export default function GatewayApp() { }); }, [api, chatCommandPipeline]); + // Every (re)connect re-baselines the activity store from the gateway's + // authoritative registry: chat.activity broadcasts are single-shot, so a + // stop that raced a dropped socket would otherwise leave the green dot + // (and streaming cursor fallback) stuck until the next history.list. + useEffect(() => { + if (!api) { + return; + } + let cancelled = false; + const unsubscribe = api.subscribeConnection((connected) => { + if (!connected || cancelled) { + return; + } + void api + .listChatActivities() + .then((items) => { + if (cancelled) { + return; + } + activityStore.hydrate(normalizeActivityHydrationItems(items), { + keepConversationIds: chatCommandPipeline.pendingConversationIds(), + }); + }) + .catch(() => undefined); + }); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [activityStore, api, chatCommandPipeline]); + const subscribeActivityStore = useCallback( (listener: () => void) => activityStore.subscribe(listener), [activityStore], @@ -1529,6 +1571,9 @@ export default function GatewayApp() { readEventRunId(event), eventClientRequestId || undefined, ); + // Settle the sidebar dot by run identity: the stream's terminal is + // authoritative even when the chat.activity broadcast was missed. + activityStore.settleRun(targetConversationId, readEventRunId(event)); const finishedTitle = typeof (event as { title?: unknown }).title === "string" ? ((event as { title: string }).title ?? "").trim() @@ -1562,6 +1607,7 @@ export default function GatewayApp() { } }, [ + activityStore, applyLiveConversationTitle, chatCommandPipeline, handleTunnelManagerChatEvent, @@ -1581,12 +1627,20 @@ export default function GatewayApp() { result.activity.runId, result.activity.clientRequestId, ); + } else if (!chatCommandPipeline.hasPending(targetConversationId)) { + // The authoritative subscribe says nothing is running and no local + // submission is in flight: a lingering dot for this conversation is + // a missed-stop zombie — settle it by its own run identity. + const currentActivity = activityStore.get(targetConversationId); + if (currentActivity) { + activityStore.settleRun(targetConversationId, currentActivity.runId); + } } for (const event of result.events) { observeConversationStreamEvent(targetConversationId, event, { replay: true }); } }, - [chatCommandPipeline, observeConversationStreamEvent], + [activityStore, chatCommandPipeline, observeConversationStreamEvent], ); const handleConversationStreamEvent = useCallback( @@ -1829,11 +1883,15 @@ export default function GatewayApp() { return; } // Authoritative running snapshot: hydrate the activity store (sidebar - // dots) and project activity bookkeeping. + // dots) and project activity bookkeeping. Conversations with an + // in-flight local command are kept — the gateway may not have + // registered their run when the snapshot was built. const runningConversations = normalizeActivityHydrationItems( response.running_conversations, ); - activityStore.hydrate(runningConversations); + activityStore.hydrate(runningConversations, { + keepConversationIds: chatCommandPipeline.pendingConversationIds(), + }); for (const runningConversation of runningConversations) { recordProjectActivity(runningConversation.workdir, runningConversation.updatedAt); } @@ -2013,7 +2071,9 @@ export default function GatewayApp() { const runningConversations = normalizeActivityHydrationItems( response.running_conversations, ); - activityStore.hydrate(runningConversations); + activityStore.hydrate(runningConversations, { + keepConversationIds: chatCommandPipeline.pendingConversationIds(), + }); for (const runningConversation of runningConversations) { recordProjectActivity(runningConversation.workdir, runningConversation.updatedAt); } @@ -2044,6 +2104,7 @@ export default function GatewayApp() { }, [ activityStore, api, + chatCommandPipeline, commitHistoryListState, getHistoryPositionLockedConversationIds, recordProjectActivity, diff --git a/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts index 53439013..58825e29 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/activityStore.ts @@ -11,8 +11,16 @@ export type ConversationActivity = { state: RunActivityState; workdir: string | null; updatedAt: number; + // Local receipt time (store clock): lets hydrate distinguish a push that + // raced ahead of a list snapshot from a genuinely stale entry. + receivedAt: number; }; +// A push received this recently survives a non-empty hydrate batch that omits +// it: the batch snapshot may have been built just before the run registered. +// Stale (stuck) entries are far older than this and still get dropped. +const RECENT_PUSH_KEEP_MS = 15_000; + export type ActivitySnapshot = { activities: ReadonlyMap; revision: number; @@ -24,8 +32,15 @@ export type ActivityStore = { isRunning(conversationId: string): boolean; get(conversationId: string): ConversationActivity | null; applyActivityEvent(event: ConversationActivityEvent): void; - // history.list `running_conversations` hydration: authoritative snapshot of - // every active run at response time. + // Local settlement by run identity: a run_finished (or an activity-less + // subscribe sync) proves the stored run ended even when the chat.activity + // "stopped" broadcast was missed. Only the exact run is cleared — a newer + // run's entry is never touched. + settleRun(conversationId: string, runId: string): void; + // history.list / chat.activities hydration: authoritative snapshot of + // every active run at response time. Entries absent from the batch are + // dropped unless listed in keepConversationIds (locally pending commands + // the gateway may not know about yet). hydrate( items: Array<{ conversationId: string; @@ -34,11 +49,13 @@ export type ActivityStore = { workdir?: string | null; updatedAt?: number; }>, + options?: { keepConversationIds?: ReadonlySet }, ): void; clear(): void; }; -export function createActivityStore(): ActivityStore { +export function createActivityStore(options?: { now?: () => number }): ActivityStore { + const now = options?.now ?? Date.now; let activities = new Map(); let snapshot: ActivitySnapshot = { activities, revision: 0 }; const listeners = new Set<() => void>(); @@ -87,6 +104,7 @@ export function createActivityStore(): ActivityStore { state: event.state ?? "running", workdir: event.workdir, updatedAt: event.updatedAt, + receivedAt: now(), }; if ( current && @@ -101,22 +119,34 @@ export function createActivityStore(): ActivityStore { emit(); }, - hydrate: (items) => { + settleRun: (conversationId, runId) => { + if (!runId) { + return; + } + const current = activities.get(conversationId); + if (!current || current.runId !== runId) { + return; + } + activities = new Map(activities); + activities.delete(conversationId); + emit(); + }, + + hydrate: (items, hydrateOptions) => { + const nowMs = now(); const incoming = new Map(); - let newestBatchUpdatedAt = 0; for (const item of items) { const conversationId = item.conversationId.trim(); const runId = item.runId.trim(); if (!conversationId || !runId) { continue; } - const updatedAt = item.updatedAt ?? 0; - newestBatchUpdatedAt = Math.max(newestBatchUpdatedAt, updatedAt); incoming.set(conversationId, { runId, state: normalizeState(item.state), workdir: item.workdir?.trim() || null, - updatedAt, + updatedAt: item.updatedAt ?? 0, + receivedAt: nowMs, }); } @@ -132,8 +162,11 @@ export function createActivityStore(): ActivityStore { // The snapshot races the chat.activity pushes: merge per entry with // newer-wins (all timestamps come from the gateway clock) so a stale - // list response cannot resurrect a run we already saw finish, and only - // drop absent entries that are older than the batch itself. + // list response cannot resurrect a run we already saw finish. Entries + // absent from the authoritative batch are dropped — the gateway says + // they are not running — except conversations with a locally pending + // command (whose run the gateway may not have registered yet) and + // entries whose push arrived after the batch snapshot was built. const merged = new Map(); for (const [conversationId, activity] of incoming) { const current = activities.get(conversationId); @@ -142,13 +175,15 @@ export function createActivityStore(): ActivityStore { current && current.updatedAt > activity.updatedAt ? current : activity, ); } + const keepConversationIds = hydrateOptions?.keepConversationIds; for (const [conversationId, current] of activities) { if (merged.has(conversationId)) { continue; } - if (current.updatedAt >= newestBatchUpdatedAt) { - // Newer than the snapshot: a push that arrived after the list - // response was built. Keep it. + if ( + keepConversationIds?.has(conversationId) || + nowMs - current.receivedAt <= RECENT_PUSH_KEEP_MS + ) { merged.set(conversationId, current); } } diff --git a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts index 8542ff87..4485bcce 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/chatCommandPipeline.ts @@ -62,6 +62,12 @@ export class ChatCommandPipeline { return this.pending.has(conversationId); } + // Conversations with an in-flight submission — activity hydration must not + // drop these even when an authoritative snapshot does not list them yet. + pendingConversationIds(): Set { + return new Set(this.pending.keys()); + } + async submit(request: ChatCommandRequest): Promise { if (request.optimistic !== false) { this.hooks.getTranscriptStore(request.conversationId).addOptimisticUserEntry({ diff --git a/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts index 73f838f8..d234a70f 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts @@ -144,6 +144,18 @@ export class ConversationStreamClient { registration.handlers.onEvent(event); } + // App-requested resync (e.g. transcript divergence): re-issue + // chat.subscribe from the cursor, exactly like gap recovery. No-op when + // the conversation is not subscribed or the socket is down (reconnect + // resume covers that case). + resync(conversationId: string): void { + const registration = this.registrations.get(conversationId.trim()); + if (!registration || registration.disposed || !this.connected) { + return; + } + void this.sync(registration); + } + // Server told us our subscriber overflowed: resume from the cursor. handleSubscriptionReset(payload: unknown): void { if (!payload || typeof payload !== "object") { diff --git a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts index 76a32870..3d014fd2 100644 --- a/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts +++ b/crates/agent-gateway/web/src/lib/chat/stream/useConversationChat.ts @@ -16,13 +16,25 @@ export type TranscriptStoreRegistry = { clear(): void; }; -export function createTranscriptStoreRegistry(): TranscriptStoreRegistry { +export function createTranscriptStoreRegistry(hooks?: { + // A store detected run-topology divergence (see createTranscriptStore's + // onDivergence); reported with the conversation id the store is currently + // registered under so the app can resubscribe that conversation's stream. + onDivergence?: (conversationId: string) => void; +}): TranscriptStoreRegistry { const stores = new Map(); + // Mutable identity per store: `move` re-keys a draft store to its real + // conversation id, and divergence must report the current key. + const storeIds = new WeakMap(); return { get(conversationId) { let store = stores.get(conversationId); if (!store) { - store = createTranscriptStore(); + const identity = { conversationId }; + store = createTranscriptStore({ + onDivergence: () => hooks?.onDivergence?.(identity.conversationId), + }); + storeIds.set(store, identity); stores.set(conversationId, store); } return store; @@ -37,6 +49,10 @@ export function createTranscriptStoreRegistry(): TranscriptStoreRegistry { } stores.delete(fromConversationId); stores.set(toConversationId, store); + const identity = storeIds.get(store); + if (identity) { + identity.conversationId = toConversationId; + } }, remove(conversationId) { stores.delete(conversationId); diff --git a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts index c4733ca4..18ea603c 100644 --- a/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts +++ b/crates/agent-gateway/web/src/lib/chat/transcript/transcriptStore.ts @@ -84,7 +84,13 @@ function readEventClientRequestId(event: ConversationStreamEvent): string { return typeof value === "string" ? value.trim() : ""; } -export function createTranscriptStore(): TranscriptStore { +export function createTranscriptStore(options?: { + // A stray run_finished arrived for a non-active run while a run is + // streaming: the local view of the run topology diverged from the gateway + // log. Fired at most once per applied sync so the app layer can trigger a + // resubscribe (which re-arms the signal). + onDivergence?: () => void; +}): TranscriptStore { let historyEntries: ChatEntry[] = []; let turns: Turn[] = []; let activeRun: StreamRunActivity | null = null; @@ -95,6 +101,10 @@ export function createTranscriptStore(): TranscriptStore { // Idempotency cursor: the highest log seq already applied. Re-subscribe // replays and snapshot+replay overlaps are dropped below it. let lastSeq = 0; + // Debounces onDivergence: one signal per applied sync (reset in applySync), + // and never twice from the same stream position across syncs. + let divergenceSignaled = false; + let lastDivergenceSeq = -1; let snapshot = EMPTY_SNAPSHOT; let dirty = false; @@ -440,6 +450,15 @@ export function createTranscriptStore(): TranscriptStore { turns = turns.filter((turn) => turn !== stray); schedule(true); } + // The active run may itself be a zombie (its own run_finished was + // lost); let the app resync this conversation to converge. The seq + // guard stops a resync loop when a reset replay re-delivers the same + // stray on every subscribe. + if (!divergenceSignaled && lastSeq !== lastDivergenceSeq) { + divergenceSignaled = true; + lastDivergenceSeq = lastSeq; + options?.onDivergence?.(); + } return; } const payload = event as { status?: string; message?: string; reason?: string }; @@ -618,6 +637,9 @@ export function createTranscriptStore(): TranscriptStore { } const applySyncLocked = (result: ConversationSubscribeResult) => { + // A completed (re)subscribe is the convergence point: re-arm the + // divergence signal so a later stray burst can trigger another resync. + divergenceSignaled = false; if (result.reset) { // Seq continuity broke (gateway restart / buffer gap). Folded and // settled turns hold finished content with stable ids — fold, never diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 799d33ca..0a04e982 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -52,6 +52,7 @@ import type { HistoryShareStatus, HistoryWorkdirsResponse, MemoryManagePayload, + RunningConversationSummary, } from "./gatewayTypes"; import type { TunnelCreateInput, @@ -1081,6 +1082,8 @@ export class GatewayWebSocketClient { private chatQueueListeners = new Set(); private chatActivityListeners = new Set(); private chatCommandUpdateListeners = new Set(); + private connectionListeners = new Set<(connected: boolean) => void>(); + private connectionState = false; private tunnelStateListeners = new Set(); private lastTunnelState: TunnelStateSnapshot | null = null; // Server tunnel.state revisions are only monotonic within one gateway @@ -1334,6 +1337,7 @@ export class GatewayWebSocketClient { void this.ensureConnected() .then(() => { if (this.authenticated) { + this.setConnectionState(true); this.conversationStreams.handleConnected(); } }) @@ -1350,6 +1354,32 @@ export class GatewayWebSocketClient { }; } + // Authenticated-connection state: `true` fires when auth completes (the + // same point conversation streams resume), `false` when the socket drops. + // Late subscribers immediately receive the current state. + subscribeConnection(listener: (connected: boolean) => void): () => void { + this.connectionListeners.add(listener); + listener(this.connectionState); + return () => { + this.connectionListeners.delete(listener); + }; + } + + // On-demand snapshot of every active run (same items as history.list's + // running_conversations, without the list). + async listChatActivities(): Promise { + const response = await this.requestWithRecovery<{ + running_conversations?: RunningConversationSummary[]; + }>("chat.activities", {}); + return Array.isArray(response?.running_conversations) ? response.running_conversations : []; + } + + // Re-issue chat.subscribe for a subscribed conversation from its cursor + // (gap-recovery path); no-op when the conversation is not subscribed. + resyncConversation(conversationId: string): void { + this.conversationStreams.resync(conversationId); + } + subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void { this.chatCommandUpdateListeners.add(listener); return () => { @@ -2344,6 +2374,7 @@ export class GatewayWebSocketClient { this.authenticated = true; this.clearReconnectNoticeTimer(); this.reconnectAttempt = 0; + this.setConnectionState(true); this.conversationStreams.handleConnected(); if (!settled) { settled = true; @@ -2523,6 +2554,7 @@ export class GatewayWebSocketClient { } this.socket = null; this.authenticated = false; + this.setConnectionState(false); const pending = [...this.pending.values()]; this.pending.clear(); @@ -2564,6 +2596,16 @@ export class GatewayWebSocketClient { this.lastInboundAt = Date.now(); } + private setConnectionState(connected: boolean) { + if (this.connectionState === connected) { + return; + } + this.connectionState = connected; + for (const listener of this.connectionListeners) { + listener(connected); + } + } + private emitChatQueue(snapshot: ChatQueueSnapshot) { for (const listener of this.chatQueueListeners) { listener(snapshot); @@ -2625,6 +2667,9 @@ export type GatewayWebSocketClientLike = { subscribeChatQueue(listener: ChatQueueListener): () => void; subscribeChatActivity(listener: ChatActivityListener): () => void; subscribeChatCommandUpdates(listener: ChatCommandUpdateListener): () => void; + subscribeConnection(listener: (connected: boolean) => void): () => void; + listChatActivities(): Promise; + resyncConversation(conversationId: string): void; subscribeConversationStream( conversationId: string, handlers: ConversationStreamHandlers, diff --git a/crates/agent-gateway/web/test/activity-store.test.mjs b/crates/agent-gateway/web/test/activity-store.test.mjs index 29edf514..4d147907 100644 --- a/crates/agent-gateway/web/test/activity-store.test.mjs +++ b/crates/agent-gateway/web/test/activity-store.test.mjs @@ -64,7 +64,8 @@ test("activity events drive the running map with run identity", () => { }); test("hydration drops stale entries and adopts the snapshot", () => { - const store = createActivityStore(); + let clock = 0; + const store = createActivityStore({ now: () => clock }); store.applyActivityEvent({ conversationId: "conv-stale", runId: "run-stale", @@ -75,6 +76,7 @@ test("hydration drops stale entries and adopts the snapshot", () => { updatedAt: 1, }); + clock = 60_000; store.hydrate([ { conversationId: "conv-1", runId: "run-1", state: "running", workdir: "/w", updatedAt: 2 }, { conversationId: "conv-2", runId: "run-2", state: "cancelling", updatedAt: 3 }, @@ -89,8 +91,9 @@ test("hydration drops stale entries and adopts the snapshot", () => { assert.equal(store.get("conv-2")?.state, "cancelling"); }); -test("hydration is an ordered merge: pushes newer than the snapshot win", () => { - const store = createActivityStore(); +test("hydration merges present entries newer-wins and drops stale absent ones", () => { + let clock = 0; + const store = createActivityStore({ now: () => clock }); // A chat.activity push arrived after the history.list response was built. store.applyActivityEvent({ conversationId: "conv-1", @@ -101,6 +104,9 @@ test("hydration is an ordered merge: pushes newer than the snapshot win", () => clientRequestId: null, updatedAt: 30, }); + // Absent from the batch and received long ago: the authoritative snapshot + // says it is not running, so it is dropped even though its updatedAt is + // newer than every batch item (this is exactly the missed-stop zombie). store.applyActivityEvent({ conversationId: "conv-live", runId: "run-live", @@ -111,6 +117,7 @@ test("hydration is an ordered merge: pushes newer than the snapshot win", () => updatedAt: 50, }); + clock = 60_000; // both pushes are now well past the recent-push window store.hydrate([ // Stale row for conv-1 (the snapshot predates the run-2 handoff). { conversationId: "conv-1", runId: "run-1", state: "running", workdir: "/w", updatedAt: 10 }, @@ -120,12 +127,108 @@ test("hydration is an ordered merge: pushes newer than the snapshot win", () => assert.equal(store.get("conv-1")?.runId, "run-2", "newer push beats the stale snapshot row"); assert.equal( store.isRunning("conv-live"), - true, - "entry newer than the whole batch survives despite being absent from it", + false, + "stale entry absent from the batch is dropped even with a newer updatedAt", ); assert.equal(store.get("conv-other")?.runId, "run-9"); }); +test("hydration keeps a just-received push absent from the batch", () => { + let clock = 0; + const store = createActivityStore({ now: () => clock }); + // A remote run's push lands moments before a batch built just too early to + // include it; dropping it would blank a genuinely running conversation. + store.applyActivityEvent({ + conversationId: "conv-fresh", + runId: "run-fresh", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 100, + }); + + clock = 5_000; // within the recent-push keep window + store.hydrate([{ conversationId: "conv-1", runId: "run-1", state: "running", updatedAt: 10 }]); + assert.equal(store.isRunning("conv-fresh"), true, "fresh push survives the stale batch"); + + clock = 60_000; // past the window: the gateway's omission is authoritative + store.hydrate([{ conversationId: "conv-1", runId: "run-1", state: "running", updatedAt: 10 }]); + assert.equal(store.isRunning("conv-fresh"), false, "aged-out entry is dropped"); +}); + +test("hydration keeps absent conversations listed in keepConversationIds", () => { + let clock = 0; + const store = createActivityStore({ now: () => clock }); + store.applyActivityEvent({ + conversationId: "conv-pending", + runId: "run-pending", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 5, + }); + store.applyActivityEvent({ + conversationId: "conv-gone", + runId: "run-gone", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 5, + }); + + clock = 60_000; + store.hydrate( + [{ conversationId: "conv-1", runId: "run-1", state: "running", updatedAt: 10 }], + { keepConversationIds: new Set(["conv-pending"]) }, + ); + + assert.equal( + store.isRunning("conv-pending"), + true, + "locally pending conversation survives the authoritative batch", + ); + assert.equal(store.isRunning("conv-gone"), false, "unlisted absent entry still dropped"); + assert.equal(store.get("conv-1")?.runId, "run-1"); +}); + +test("settleRun clears the entry only on exact run identity", () => { + const store = createActivityStore(); + let notifications = 0; + store.subscribe(() => { + notifications += 1; + }); + store.applyActivityEvent({ + conversationId: "conv-1", + runId: "run-2", + running: true, + state: "running", + workdir: null, + clientRequestId: null, + updatedAt: 10, + }); + assert.equal(notifications, 1); + + // A stale terminal for a superseded run must not clear the newer run's dot. + store.settleRun("conv-1", "run-1"); + assert.equal(store.isRunning("conv-1"), true, "wrong run id leaves the entry"); + assert.equal(notifications, 1, "no notification without a change"); + + // An empty run id matches nothing. + store.settleRun("conv-1", ""); + assert.equal(store.isRunning("conv-1"), true); + + // Unknown conversation: no-op. + store.settleRun("conv-x", "run-2"); + assert.equal(notifications, 1); + + store.settleRun("conv-1", "run-2"); + assert.equal(store.isRunning("conv-1"), false, "identity match settles the entry"); + assert.equal(notifications, 2); +}); + test("an empty hydration snapshot means idle everywhere", () => { const store = createActivityStore(); store.applyActivityEvent({ diff --git a/crates/agent-gateway/web/test/transcript-store.test.mjs b/crates/agent-gateway/web/test/transcript-store.test.mjs index 8a2f2903..78f753e5 100644 --- a/crates/agent-gateway/web/test/transcript-store.test.mjs +++ b/crates/agent-gateway/web/test/transcript-store.test.mjs @@ -1037,3 +1037,85 @@ test("replace keeps a settled exchange whose persistence lags the fetched window ); assertUniqueKeys(snapshot); }); + +test("a stray run_finished drops the stray turn, keeps activeRun, and fires onDivergence once", () => { + let divergences = 0; + const store = createTranscriptStore({ + onDivergence: () => { + divergences += 1; + }, + }); + store.applyEvent(runStarted("run-a", 1)); + store.applyEvent(token("run-a", 2, "reply a")); + store.applyEvent(userMessage("run-b", 3, "queued prompt")); + store.flush(); + assert.equal( + allRows(store.getSnapshot()).some((row) => rowText(row) === "queued prompt"), + true, + ); + + store.applyEvent( + runFinished("run-b", 4, "failed", { message: "superseded", reason: "superseded" }), + ); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun?.runId, "run-a", "active run untouched"); + assert.equal( + allRows(snapshot).some((row) => rowText(row) === "queued prompt"), + false, + "stray turn dropped", + ); + assert.equal( + allRows(snapshot).some((row) => rowText(row) === "reply a"), + true, + "streaming turn untouched", + ); + assert.equal(divergences, 1); + + // A second stray in the same burst is debounced. + store.applyEvent(runFinished("run-c", 5, "failed", { message: "also stray" })); + store.flush(); + assert.equal(store.getSnapshot().activeRun?.runId, "run-a"); + assert.equal(divergences, 1, "one signal per applied sync"); + + // The resulting resync re-arms the signal for a later divergence. + store.applySync({ + conversationId: "conv-1", + streamEpoch: "epoch-1", + latestSeq: 5, + reset: false, + activity: { + runId: "run-a", + state: "running", + startedSeq: 1, + toolStatus: null, + toolStatusIsCompaction: false, + updatedAt: 1, + }, + snapshot: null, + events: [], + }); + store.applyEvent(runFinished("run-d", 6, "failed", { message: "stray again" })); + store.flush(); + assert.equal(divergences, 2, "signal re-armed after the sync"); +}); + +test("a matching run_finished settles the run without firing onDivergence", () => { + let divergences = 0; + const store = createTranscriptStore({ + onDivergence: () => { + divergences += 1; + }, + }); + store.applyEvent(runStarted("run-1", 1)); + store.applyEvent(token("run-1", 2, "answer")); + store.applyEvent(runFinished("run-1", 3)); + store.flush(); + const snapshot = store.getSnapshot(); + assert.equal(snapshot.activeRun, null, "matching terminal settles the run"); + assert.equal( + allRows(snapshot).some((row) => rowText(row) === "answer"), + true, + ); + assert.equal(divergences, 0); +}); diff --git a/crates/agent-gui/src-tauri/src/services/chat_run_ledger.rs b/crates/agent-gui/src-tauri/src/services/chat_run_ledger.rs new file mode 100644 index 00000000..6c677de5 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/chat_run_ledger.rs @@ -0,0 +1,546 @@ +//! Desktop-side ledger of chat-run lifecycle states destined for the gateway. +//! +//! Terminal signals ("completed"/"failed"/"cancelled") must reach the gateway +//! at least once; otherwise the WebUI shows a run as streaming forever. The +//! ledger records every run the desktop knows about and keeps unsent terminal +//! states around so callers can retransmit them until an acknowledged send. +//! +//! Pure std container (no tauri/tokio) so it stays unit-testable. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +const DEFAULT_ACTIVE_TTL: Duration = Duration::from_secs(5 * 60); +const DEFAULT_TERMINAL_RETENTION: Duration = Duration::from_secs(10 * 60); +const DEFAULT_TERMINAL_CAP: usize = 32; + +pub const DESKTOP_RUN_LOST_ERROR_CODE: &str = "desktop_run_lost"; +pub const DESKTOP_RUN_LOST_MESSAGE: &str = "The desktop runtime stopped reporting this run."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRunLedgerState { + Running, + Completed, + Failed, + Cancelled, +} + +impl ChatRunLedgerState { + // These strings are the gateway's run-state vocabulary; do not change them. + pub fn as_str(&self) -> &'static str { + match self { + ChatRunLedgerState::Running => "running", + ChatRunLedgerState::Completed => "completed", + ChatRunLedgerState::Failed => "failed", + ChatRunLedgerState::Cancelled => "cancelled", + } + } + + pub fn is_terminal(&self) -> bool { + !matches!(self, ChatRunLedgerState::Running) + } +} + +#[derive(Debug, Clone)] +pub struct ChatRunLedgerEntry { + pub run_id: String, + pub conversation_id: String, + pub state: ChatRunLedgerState, + pub error_code: String, + pub message: String, + pub terminal_sent: bool, + // Liveness while Running; frozen at terminal time afterwards (touch is a + // no-op on terminal entries), so it doubles as the retention clock. + pub touched_at: Instant, + pub updated_at_ms: i64, +} + +pub struct ChatRunLedger { + entries: HashMap, + active_ttl: Duration, + terminal_retention: Duration, + terminal_cap: usize, +} + +impl Default for ChatRunLedger { + fn default() -> Self { + Self::new() + } +} + +impl ChatRunLedger { + pub fn new() -> Self { + Self::with_tunables( + DEFAULT_ACTIVE_TTL, + DEFAULT_TERMINAL_RETENTION, + DEFAULT_TERMINAL_CAP, + ) + } + + pub fn with_tunables( + active_ttl: Duration, + terminal_retention: Duration, + terminal_cap: usize, + ) -> Self { + Self { + entries: HashMap::new(), + active_ttl, + terminal_retention, + terminal_cap, + } + } + + pub fn mark_running( + &mut self, + run_id: &str, + conversation_id: &str, + now: Instant, + now_ms: i64, + ) -> bool { + let run_id = run_id.trim(); + if run_id.is_empty() { + return false; + } + if let Some(entry) = self.entries.get_mut(run_id) { + if entry.state.is_terminal() { + return false; + } + entry.state = ChatRunLedgerState::Running; + entry.touched_at = now; + entry.updated_at_ms = now_ms; + Self::fill_conversation_id(entry, conversation_id); + return true; + } + self.entries.insert( + run_id.to_string(), + Self::running_entry(run_id, conversation_id, now, now_ms), + ); + true + } + + pub fn touch(&mut self, run_id: &str, conversation_id: &str, now: Instant, now_ms: i64) { + let run_id = run_id.trim(); + if run_id.is_empty() { + return; + } + if let Some(entry) = self.entries.get_mut(run_id) { + if entry.state.is_terminal() { + return; + } + entry.touched_at = now; + entry.updated_at_ms = now_ms; + Self::fill_conversation_id(entry, conversation_id); + return; + } + // Refresh-only when no conversation is known: a heartbeat for a run + // that never started (or whose entry was evicted) must not seed a + // phantom entry the gateway can never resolve to a conversation. + if conversation_id.trim().is_empty() { + return; + } + self.entries.insert( + run_id.to_string(), + Self::running_entry(run_id, conversation_id, now, now_ms), + ); + } + + #[allow(clippy::too_many_arguments)] + pub fn mark_terminal( + &mut self, + run_id: &str, + conversation_id: &str, + state: ChatRunLedgerState, + error_code: &str, + message: &str, + now: Instant, + now_ms: i64, + ) -> bool { + let run_id = run_id.trim(); + if run_id.is_empty() || !state.is_terminal() { + return false; + } + if let Some(entry) = self.entries.get_mut(run_id) { + // First terminal wins: a later, conflicting terminal must not + // overwrite the state that already represents the run outcome. + if entry.state.is_terminal() { + return false; + } + entry.state = state; + entry.error_code = error_code.to_string(); + entry.message = message.to_string(); + entry.terminal_sent = false; + entry.touched_at = now; + entry.updated_at_ms = now_ms; + Self::fill_conversation_id(entry, conversation_id); + return true; + } + self.entries.insert( + run_id.to_string(), + ChatRunLedgerEntry { + run_id: run_id.to_string(), + conversation_id: conversation_id.trim().to_string(), + state, + error_code: error_code.to_string(), + message: message.to_string(), + terminal_sent: false, + touched_at: now, + updated_at_ms: now_ms, + }, + ); + true + } + + pub fn get(&self, run_id: &str) -> Option<&ChatRunLedgerEntry> { + self.entries.get(run_id.trim()) + } + + pub fn mark_terminal_sent(&mut self, run_id: &str) { + if let Some(entry) = self.entries.get_mut(run_id.trim()) { + if entry.state.is_terminal() { + entry.terminal_sent = true; + } + } + } + + pub fn unsent_terminals(&self) -> Vec { + let mut unsent: Vec = self + .entries + .values() + .filter(|entry| entry.state.is_terminal() && !entry.terminal_sent) + .cloned() + .collect(); + unsent.sort_by(|a, b| { + a.updated_at_ms + .cmp(&b.updated_at_ms) + .then_with(|| a.run_id.cmp(&b.run_id)) + }); + unsent + } + + pub fn active_reports(&self, now: Instant) -> Vec { + let mut active: Vec = self + .entries + .values() + .filter(|entry| { + !entry.state.is_terminal() + && now.saturating_duration_since(entry.touched_at) <= self.active_ttl + }) + .cloned() + .collect(); + active.sort_by(|a, b| { + a.updated_at_ms + .cmp(&b.updated_at_ms) + .then_with(|| a.run_id.cmp(&b.run_id)) + }); + active + } + + pub fn recent_terminal_reports(&self) -> Vec { + // Entries past the retention window are removed by `sweep`, so + // everything terminal that is still here is recent enough to report. + let mut recent: Vec = self + .entries + .values() + .filter(|entry| entry.state.is_terminal()) + .cloned() + .collect(); + recent.sort_by(|a, b| { + b.updated_at_ms + .cmp(&a.updated_at_ms) + .then_with(|| a.run_id.cmp(&b.run_id)) + }); + recent.truncate(self.terminal_cap); + recent + } + + pub fn sweep(&mut self, now: Instant, now_ms: i64) -> Vec { + let mut demoted: Vec = Vec::new(); + for entry in self.entries.values_mut() { + if entry.state.is_terminal() { + continue; + } + if now.saturating_duration_since(entry.touched_at) <= self.active_ttl { + continue; + } + entry.state = ChatRunLedgerState::Failed; + entry.error_code = DESKTOP_RUN_LOST_ERROR_CODE.to_string(); + entry.message = DESKTOP_RUN_LOST_MESSAGE.to_string(); + entry.terminal_sent = false; + entry.touched_at = now; + entry.updated_at_ms = now_ms; + demoted.push(entry.clone()); + } + + let terminal_retention = self.terminal_retention; + // Unsent terminals are kept past the normal retention so they keep + // retrying, but 3x retention bounds the leak if sends never succeed. + let unsent_retention = terminal_retention.saturating_mul(3); + self.entries.retain(|_, entry| { + if !entry.state.is_terminal() { + return true; + } + let age = now.saturating_duration_since(entry.touched_at); + if entry.terminal_sent { + age <= terminal_retention + } else { + age <= unsent_retention + } + }); + + demoted + } + + fn running_entry( + run_id: &str, + conversation_id: &str, + now: Instant, + now_ms: i64, + ) -> ChatRunLedgerEntry { + ChatRunLedgerEntry { + run_id: run_id.trim().to_string(), + conversation_id: conversation_id.trim().to_string(), + state: ChatRunLedgerState::Running, + error_code: String::new(), + message: String::new(), + terminal_sent: false, + touched_at: now, + updated_at_ms: now_ms, + } + } + + fn fill_conversation_id(entry: &mut ChatRunLedgerEntry, conversation_id: &str) { + let conversation_id = conversation_id.trim(); + if entry.conversation_id.is_empty() && !conversation_id.is_empty() { + entry.conversation_id = conversation_id.to_string(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ledger() -> ChatRunLedger { + ChatRunLedger::with_tunables(Duration::from_secs(300), Duration::from_secs(600), 4) + } + + #[test] + fn first_terminal_wins() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + assert!(ledger.mark_running("run-1", "conversation-1", t0, 1_000)); + assert!(ledger.mark_terminal( + "run-1", + "conversation-1", + ChatRunLedgerState::Completed, + "", + "", + t0 + Duration::from_secs(1), + 2_000, + )); + assert!(!ledger.mark_terminal( + "run-1", + "conversation-1", + ChatRunLedgerState::Failed, + "late_error", + "should not overwrite", + t0 + Duration::from_secs(2), + 3_000, + )); + assert!(!ledger.mark_running( + "run-1", + "conversation-1", + t0 + Duration::from_secs(3), + 4_000 + )); + + let entries = ledger.unsent_terminals(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].state, ChatRunLedgerState::Completed); + assert_eq!(entries[0].error_code, ""); + assert_eq!(entries[0].updated_at_ms, 2_000); + } + + #[test] + fn touch_without_conversation_never_seeds_an_entry() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + // A claim-time heartbeat may fire before the run is marked running; + // it must not create a phantom entry with no conversation binding. + ledger.touch("run-1", "", t0, 1_000); + assert!(ledger.active_reports(t0).is_empty()); + + // Once the run exists, an id-less touch still refreshes liveness. + ledger.mark_running("run-1", "conversation-1", t0, 1_000); + ledger.touch("run-1", "", t0 + Duration::from_secs(299), 2_000); + assert_eq!( + ledger.active_reports(t0 + Duration::from_secs(400)).len(), + 1 + ); + } + + #[test] + fn touch_after_terminal_is_noop() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + assert!(ledger.mark_terminal( + "run-1", + "conversation-1", + ChatRunLedgerState::Cancelled, + "", + "", + t0, + 1_000, + )); + ledger.touch( + "run-1", + "conversation-1", + t0 + Duration::from_secs(5), + 2_000, + ); + + let entries = ledger.recent_terminal_reports(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].state, ChatRunLedgerState::Cancelled); + assert_eq!(entries[0].updated_at_ms, 1_000); + assert!(ledger + .active_reports(t0 + Duration::from_secs(5)) + .is_empty()); + } + + #[test] + fn unsent_terminals_clear_after_mark_terminal_sent() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + ledger.mark_running("run-1", "conversation-1", t0, 1_000); + ledger.mark_terminal( + "run-1", + "conversation-1", + ChatRunLedgerState::Failed, + "boom", + "run failed", + t0 + Duration::from_secs(1), + 2_000, + ); + + let unsent = ledger.unsent_terminals(); + assert_eq!(unsent.len(), 1); + assert_eq!(unsent[0].run_id, "run-1"); + assert_eq!(unsent[0].error_code, "boom"); + assert_eq!(unsent[0].message, "run failed"); + + ledger.mark_terminal_sent("run-1"); + assert!(ledger.unsent_terminals().is_empty()); + + // A duplicate terminal for an already-terminal run must not reset + // terminal_sent and re-trigger retransmission. + assert!(!ledger.mark_terminal( + "run-1", + "conversation-1", + ChatRunLedgerState::Failed, + "boom", + "run failed", + t0 + Duration::from_secs(2), + 3_000, + )); + assert!(ledger.unsent_terminals().is_empty()); + } + + #[test] + fn ttl_demotion_returns_demoted_entries_once() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + ledger.mark_running("run-1", "conversation-1", t0, 1_000); + + let stale = t0 + Duration::from_secs(301); + let demoted = ledger.sweep(stale, 2_000); + assert_eq!(demoted.len(), 1); + assert_eq!(demoted[0].run_id, "run-1"); + assert_eq!(demoted[0].state, ChatRunLedgerState::Failed); + assert_eq!(demoted[0].error_code, DESKTOP_RUN_LOST_ERROR_CODE); + assert_eq!(demoted[0].message, DESKTOP_RUN_LOST_MESSAGE); + assert!(!demoted[0].terminal_sent); + + // Demoted entries are terminal now and show up for retransmission. + assert_eq!(ledger.unsent_terminals().len(), 1); + // A later sweep must not demote (or return) them again. + assert!(ledger + .sweep(stale + Duration::from_secs(1), 3_000) + .is_empty()); + assert_eq!(ledger.unsent_terminals().len(), 1); + } + + #[test] + fn sweep_keeps_fresh_running_entries() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + ledger.mark_running("run-1", "conversation-1", t0, 1_000); + assert!(ledger + .sweep(t0 + Duration::from_secs(299), 2_000) + .is_empty()); + assert_eq!( + ledger.active_reports(t0 + Duration::from_secs(299)).len(), + 1 + ); + } + + #[test] + fn retention_evicts_sent_terminals_and_keeps_unsent_until_leak_bound() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + ledger.mark_terminal( + "run-sent", + "conversation-1", + ChatRunLedgerState::Completed, + "", + "", + t0, + 1_000, + ); + ledger.mark_terminal_sent("run-sent"); + ledger.mark_terminal( + "run-unsent", + "conversation-2", + ChatRunLedgerState::Failed, + "boom", + "run failed", + t0, + 1_001, + ); + + // Past terminal_retention: sent entry evicted, unsent entry kept. + ledger.sweep(t0 + Duration::from_secs(601), 2_000); + let recent = ledger.recent_terminal_reports(); + assert_eq!(recent.len(), 1); + assert_eq!(recent[0].run_id, "run-unsent"); + assert_eq!(ledger.unsent_terminals().len(), 1); + + // Past 3x terminal_retention: unsent entry evicted too (leak bound). + ledger.sweep(t0 + Duration::from_secs(1_801), 3_000); + assert!(ledger.recent_terminal_reports().is_empty()); + assert!(ledger.unsent_terminals().is_empty()); + } + + #[test] + fn recent_terminal_reports_are_capped_newest_first() { + let mut ledger = test_ledger(); + let t0 = Instant::now(); + for index in 0..6 { + ledger.mark_terminal( + &format!("run-{index}"), + "conversation-1", + ChatRunLedgerState::Completed, + "", + "", + t0 + Duration::from_secs(index), + 1_000 + i64::try_from(index).unwrap(), + ); + } + + let recent = ledger.recent_terminal_reports(); + assert_eq!(recent.len(), 4); + assert_eq!(recent[0].run_id, "run-5"); + assert_eq!(recent[0].updated_at_ms, 1_005); + assert_eq!(recent[3].run_id, "run-2"); + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index 3c1619f7..4a733ff9 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -33,6 +33,7 @@ use crate::runtime::terminal::{ TerminalSessionRecord, TerminalSessionRegistry, TerminalShellOption, TerminalSnapshotResponse, TerminalSshCreateResponse, TerminalStreamEventPayload, TerminalStreamSnapshotResponse, }; +use crate::services::chat_run_ledger::{ChatRunLedger, ChatRunLedgerEntry, ChatRunLedgerState}; use crate::services::cron::CronManager; use crate::services::gateway_bridge; use crate::services::memory::MemoryStore; @@ -285,6 +286,7 @@ pub struct GatewayController { terminal_stream_tx: Mutex>>, settings_snapshot: Mutex>, remote_chat_inbox: Mutex>, + chat_run_ledger: Mutex, pub(crate) tunnel_store: TunnelStore, pub(crate) tunnel_proxy: TunnelProxy, pending_chat_queue_requests: Mutex>>, @@ -329,6 +331,7 @@ impl GatewayController { terminal_stream_tx: Mutex::new(None), settings_snapshot: Mutex::new(None), remote_chat_inbox: Mutex::new(HashMap::new()), + chat_run_ledger: Mutex::new(ChatRunLedger::new()), tunnel_store, tunnel_proxy: TunnelProxy::new(), pending_chat_queue_requests: Mutex::new(HashMap::new()), @@ -415,6 +418,9 @@ impl GatewayController { if let Err(error) = controller.expire_remote_chat_leases().await { eprintln!("expire gateway remote chat leases failed: {error}"); } + if let Err(error) = controller.flush_unsent_chat_run_terminals().await { + eprintln!("flush gateway chat run terminals failed: {error}"); + } } }); }); @@ -526,11 +532,39 @@ impl GatewayController { event: Value, worker_id: Option, ) -> Result<(), String> { - if !self.renew_remote_chat_request_lease(&request_id, worker_id.as_deref(), true)? { + // Terminal events must bypass the lease-freshness check: an expired but + // still-owned lease may no longer be "current", yet dropping the run's + // done/error signal here would leave the WebUI streaming forever. + let is_terminal = chat_event_is_terminal(&event); + if !self.renew_remote_chat_request_lease(&request_id, worker_id.as_deref(), !is_terminal)? { return Ok(()); } - let envelope = build_chat_event_envelope(request_id, event)?; - self.send_agent_envelope(envelope).await + let conversation_id = chat_event_conversation_id(&event); + if is_terminal { + let state = if chat_event_type(&event) == Some("done") { + ChatRunLedgerState::Completed + } else { + ChatRunLedgerState::Failed + }; + // Carry the error text into the ledger so a retransmitted terminal + // control event still surfaces it after the original send failed. + let message = event + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + // Record the terminal before attempting the send so a failed send + // is retransmitted by the ledger flush loop. + self.ledger_mark_run_terminal(&request_id, &conversation_id, state, "", message)?; + } else { + self.ledger_touch_run(&request_id, &conversation_id)?; + } + let envelope = build_chat_event_envelope(request_id.clone(), event)?; + let result = self.send_agent_envelope(envelope).await; + if is_terminal && result.is_ok() { + self.ledger_mark_run_terminal_sent(&request_id)?; + } + result } pub async fn publish_history_sync(&self, event: GatewayHistorySyncEvent) { @@ -559,8 +593,31 @@ impl GatewayController { &self, snapshot: GatewayChatRuntimeSnapshot, ) -> Result<(), String> { + let run_id = snapshot.run_id.trim().to_string(); + let conversation_id = snapshot.conversation_id.trim().to_string(); + let terminal_state = match snapshot.state.trim() { + "completed" => Some(ChatRunLedgerState::Completed), + "failed" => Some(ChatRunLedgerState::Failed), + "cancelled" => Some(ChatRunLedgerState::Cancelled), + _ => None, + }; + if !run_id.is_empty() { + match terminal_state { + Some(state) => { + self.ledger_mark_run_terminal(&run_id, &conversation_id, state, "", "")?; + } + None if snapshot.state.trim() == "running" => { + self.ledger_touch_run(&run_id, &conversation_id)?; + } + None => {} + } + } let envelope = build_chat_runtime_snapshot_envelope(snapshot)?; - self.send_agent_envelope(envelope).await + let result = self.send_agent_envelope(envelope).await; + if terminal_state.is_some() && !run_id.is_empty() && result.is_ok() { + self.ledger_mark_run_terminal_sent(&run_id)?; + } + result } pub async fn publish_settings_sync(&self, payload: Value) -> Result<(), String> { @@ -720,6 +777,9 @@ impl GatewayController { if let Err(error) = self.publish_desired_tunnels().await { eprintln!("publish gateway tunnel desired state failed: {error}"); } + if let Err(error) = self.republish_chat_run_states().await { + eprintln!("republish gateway chat run states failed: {error}"); + } self.spawn_tunnel_probes(None, false); let timeout_seconds = i64::try_from(config.heartbeat_interval.max(5)).unwrap_or(30) * 3; @@ -982,21 +1042,6 @@ impl GatewayController { Some(proto::gateway_envelope::Payload::ChatQueue(request)) => { self.handle_chat_queue_request(request_id, request).await } - Some(proto::gateway_envelope::Payload::ChatEventReplay(request)) => { - match self.handle_chat_event_replay(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatEventReplayResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } Some(proto::gateway_envelope::Payload::CronManage(request)) => { let should_refresh_settings = matches!(request.action.trim(), "create" | "update" | "delete"); @@ -2424,43 +2469,6 @@ impl GatewayController { Ok(snapshot) } - async fn handle_chat_event_replay( - &self, - request: proto::ChatEventReplayRequest, - ) -> Result { - let conversation_id = request.conversation_id.trim().to_string(); - if conversation_id.is_empty() { - return Err("conversation_id is required".to_string()); - } - let after_seq = request.after_seq; - - let record = chat_history::chat_history_get(conversation_id.clone()).await?; - let mut events = Vec::new(); - let mut seq: i64 = 0; - - for segment in &record.segments { - let messages: Vec = - serde_json::from_str(&segment.messages_json).unwrap_or_default(); - for message in messages { - seq += 1; - if seq <= after_seq { - continue; - } - let event_json = serde_json::to_string(&message).unwrap_or_default(); - if !event_json.is_empty() { - events.push(proto::ChatReplayEvent { seq, event_json }); - } - } - } - - Ok(proto::ChatEventReplayResponse { - run_id: request.run_id, - conversation_id, - events, - complete: true, - }) - } - async fn handle_chat_command( self: &Arc, request_id: String, @@ -3053,6 +3061,7 @@ impl GatewayController { Some(now + Duration::from_millis(GATEWAY_CHAT_RUNNING_LEASE_MS)); record.updated_at = now; } + self.ledger_mark_run_running(&request_id, &conversation_id)?; self.send_gateway_chat_control_event(request_id, conversation_id, "started") .await } @@ -3067,6 +3076,7 @@ impl GatewayController { if request_id.is_empty() || conversation_id.is_empty() { return Ok(()); } + self.ledger_mark_run_running(&request_id, &conversation_id)?; self.send_gateway_chat_control_event(request_id, conversation_id, "started") .await } @@ -3136,8 +3146,18 @@ impl GatewayController { if !should_send { return Ok(()); } - self.send_gateway_chat_control_event(request_id, conversation_id, "completed") - .await + // Ledger first: once the inbox record is gone this is the only place + // that still knows the run finished, and the send below can fail. + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Completed, + "", + "", + )?; + self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "completed") + .await?; + self.ledger_mark_run_terminal_sent(&request_id) } pub async fn fail_chat_request( @@ -3152,39 +3172,68 @@ impl GatewayController { let request_id = request_id.trim().to_string(); let worker_id = worker_id.trim().to_string(); let conversation_id = conversation_id.unwrap_or_default(); - let should_send = { + // None: inbox record already gone; Some(true): accepted; Some(false): rejected. + let inbox_outcome = { let mut inbox = self .remote_chat_inbox .lock() .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let Some(record) = inbox.get_mut(&request_id) else { - return Ok(()); - }; - let queued_in_gui = terminal && record.state.trim() == "queued_in_gui"; - if !queued_in_gui && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { - return Ok(()); - } - record.state = if terminal { "failed" } else { "queued" }.to_string(); - record.lease_owner = None; - record.lease_expires_at = None; - record.last_error = Some(message.clone()); - record.updated_at = Instant::now(); - if terminal { - inbox.remove(&request_id); + match inbox.get_mut(&request_id) { + None => None, + Some(record) => { + let queued_in_gui = terminal && record.state.trim() == "queued_in_gui"; + if !queued_in_gui + && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) + { + Some(false) + } else { + record.state = if terminal { "failed" } else { "queued" }.to_string(); + record.lease_owner = None; + record.lease_expires_at = None; + record.last_error = Some(message.clone()); + record.updated_at = Instant::now(); + if terminal { + inbox.remove(&request_id); + } + Some(true) + } + } } - true }; - if !should_send { - return Ok(()); + match inbox_outcome { + Some(false) => return Ok(()), + Some(true) => {} + None => { + // The inbox record can be gone while the run is still live in + // the ledger (e.g. a complete/fail race removed it). Dropping + // this terminal would strand the WebUI, so repair via the + // ledger instead of returning silently. + if !terminal || !self.ledger_has_live_run(&request_id)? { + return Ok(()); + } + } + } + if terminal { + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Failed, + &error_code, + &message, + )?; } self.send_gateway_chat_control_event_with_details( - request_id, + request_id.clone(), conversation_id, "failed", error_code, message, ) - .await + .await?; + if terminal { + self.ledger_mark_run_terminal_sent(&request_id)?; + } + Ok(()) } pub async fn cancel_chat_request( @@ -3214,8 +3263,21 @@ impl GatewayController { if !should_send { return Ok(()); } - self.send_gateway_chat_control_event(request_id, conversation_id, "cancelled") - .await + // This "cancelled" is a genuine run terminal, not a cancel-request ack: + // the inbox record is removed above so no other terminal will ever be + // produced for this request (callers use it to drop queued turns that + // never start; running runs terminate via done/error/fail instead). + // First-terminal-wins keeps this from clobbering an earlier outcome. + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Cancelled, + "", + "", + )?; + self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "cancelled") + .await?; + self.ledger_mark_run_terminal_sent(&request_id) } pub fn heartbeat_chat_request( @@ -3228,18 +3290,21 @@ impl GatewayController { if request_id.is_empty() || worker_id.is_empty() { return Ok(()); } - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - if let Some(record) = inbox.get_mut(request_id) { - if record.lease_owner.as_deref() == Some(worker_id) { - let lease_ms = Self::remote_chat_record_lease_ms(record); - record.lease_expires_at = Some(Instant::now() + Duration::from_millis(lease_ms)); - record.updated_at = Instant::now(); + { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + if let Some(record) = inbox.get_mut(request_id) { + if record.lease_owner.as_deref() == Some(worker_id) { + let lease_ms = Self::remote_chat_record_lease_ms(record); + record.lease_expires_at = + Some(Instant::now() + Duration::from_millis(lease_ms)); + record.updated_at = Instant::now(); + } } } - Ok(()) + self.ledger_touch_run(request_id, "") } pub async fn publish_chat_runtime_status( @@ -3260,8 +3325,30 @@ impl GatewayController { _ => "ready", } .to_string(); - let envelope = - build_gateway_runtime_status_envelope(worker_id, state, visible, active_run_count); + let (active_reports, finished_reports) = { + let (now, _now_ms) = chat_run_ledger_now(); + let ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + (ledger.active_reports(now), ledger.recent_terminal_reports()) + }; + let active_run_count = + active_run_count.max(u32::try_from(active_reports.len()).unwrap_or(u32::MAX)); + let envelope = build_gateway_runtime_status_envelope( + worker_id, + state, + visible, + active_run_count, + active_reports + .iter() + .map(chat_run_report_from_entry) + .collect(), + finished_reports + .iter() + .map(chat_run_report_from_entry) + .collect(), + ); match self.send_agent_envelope(envelope).await { Ok(()) => Ok(()), Err(error) if error.contains("outbound stream is offline") => Ok(()), @@ -3332,14 +3419,190 @@ impl GatewayController { ); } for (request_id, conversation_id) in failed { - self.send_gateway_chat_control_event_with_details( - request_id, + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Failed, + "desktop_runtime_lease_expired", + "Desktop chat runtime stopped before completing the remote request.", + )?; + // One failed send must not abort the remaining terminals; the + // ledger flush loop retries anything that stays unsent. + match self + .send_gateway_chat_control_event_with_details( + request_id.clone(), + conversation_id, + "failed", + "desktop_runtime_lease_expired".to_string(), + "Desktop chat runtime stopped before completing the remote request." + .to_string(), + ) + .await + { + Ok(()) => self.ledger_mark_run_terminal_sent(&request_id)?, + Err(error) => { + eprintln!("send gateway chat lease-expired terminal failed: {error}"); + } + } + } + Ok(()) + } + + fn with_chat_run_ledger( + &self, + f: impl FnOnce(&mut ChatRunLedger) -> T, + ) -> Result { + let mut ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + Ok(f(&mut ledger)) + } + + fn ledger_mark_run_running(&self, run_id: &str, conversation_id: &str) -> Result<(), String> { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| { + ledger.mark_running(run_id, conversation_id, now, now_ms); + }) + } + + fn ledger_touch_run(&self, run_id: &str, conversation_id: &str) -> Result<(), String> { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| ledger.touch(run_id, conversation_id, now, now_ms)) + } + + fn ledger_mark_run_terminal( + &self, + run_id: &str, + conversation_id: &str, + state: ChatRunLedgerState, + error_code: &str, + message: &str, + ) -> Result { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| { + ledger.mark_terminal( + run_id, conversation_id, - "failed", - "desktop_runtime_lease_expired".to_string(), - "Desktop chat runtime stopped before completing the remote request.".to_string(), + state, + error_code, + message, + now, + now_ms, ) - .await?; + }) + } + + fn ledger_mark_run_terminal_sent(&self, run_id: &str) -> Result<(), String> { + self.with_chat_run_ledger(|ledger| ledger.mark_terminal_sent(run_id)) + } + + fn ledger_has_live_run(&self, run_id: &str) -> Result { + self.with_chat_run_ledger(|ledger| { + ledger + .get(run_id) + .map(|entry| !entry.state.is_terminal()) + .unwrap_or(false) + }) + } + + async fn flush_unsent_chat_run_terminals(&self) -> Result<(), String> { + if !self.status().online { + return Ok(()); + } + let unsent = { + let (now, now_ms) = chat_run_ledger_now(); + let mut ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + // Sweep first: runs demoted by the TTL become unsent terminals and + // are picked up by this very flush. + ledger.sweep(now, now_ms); + ledger.unsent_terminals() + }; + for entry in unsent { + // The gateway cannot anchor a control event without a conversation + // (it drops them at ingress); such entries only age out. + if entry.conversation_id.is_empty() { + continue; + } + match self + .send_gateway_chat_control_event_with_details( + entry.run_id.clone(), + entry.conversation_id.clone(), + entry.state.as_str(), + entry.error_code.clone(), + entry.message.clone(), + ) + .await + { + Ok(()) => self.ledger_mark_run_terminal_sent(&entry.run_id)?, + Err(error) => { + eprintln!( + "flush gateway chat run terminal {} failed: {error}", + entry.run_id + ); + } + } + } + Ok(()) + } + + async fn republish_chat_run_states(&self) -> Result<(), String> { + let (active, recent_terminals) = { + let (now, _now_ms) = chat_run_ledger_now(); + let ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + (ledger.active_reports(now), ledger.recent_terminal_reports()) + }; + for entry in active { + if entry.conversation_id.is_empty() { + continue; + } + // "started" is idempotent on the gateway; replaying it re-anchors + // runs the gateway may have lost across a restart. + if let Err(error) = self + .send_gateway_chat_control_event( + entry.run_id.clone(), + entry.conversation_id.clone(), + "started", + ) + .await + { + eprintln!( + "republish gateway chat run {} failed: {error}", + entry.run_id + ); + } + } + // Replay all recent terminals, sent or not: a gateway restart can lose + // them, and the control events are idempotent server-side. Unsent + // terminals older than the recent window are covered by the periodic + // flush a few seconds later. + for entry in recent_terminals { + if entry.conversation_id.is_empty() { + continue; + } + if let Err(error) = self + .send_gateway_chat_control_event_with_details( + entry.run_id.clone(), + entry.conversation_id.clone(), + entry.state.as_str(), + entry.error_code.clone(), + entry.message.clone(), + ) + .await + { + eprintln!( + "republish gateway chat run terminal {} failed: {error}", + entry.run_id + ); + } else { + self.ledger_mark_run_terminal_sent(&entry.run_id)?; + } } Ok(()) } @@ -4130,7 +4393,8 @@ fn serialize_settings_sync_payload(payload: &Value) -> Result { mod tests { use super::{ build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, - build_grpc_url, build_local_settings_update_event_payload, + build_gateway_runtime_status_envelope, build_grpc_url, + build_local_settings_update_event_payload, chat_event_is_terminal, format_gateway_terminal_stream_rpc_error, history_share_resolve_error_code, merge_settings_sync_snapshot, proto, queue_terminal_stream_handshake_frame, required_terminal_project_path_key, set_disconnected_status, GatewayChatRequestEvent, @@ -4807,6 +5071,89 @@ mod tests { assert_eq!(data["uploaded_files"][0]["kind"], "text"); assert_eq!(data["execution_mode"], "agent"); } + + #[test] + fn chat_event_terminal_detection_covers_done_and_error_only() { + assert!(chat_event_is_terminal(&json!({ "type": "done" }))); + assert!(chat_event_is_terminal( + &json!({ "type": "error", "message": "boom" }) + )); + assert!(chat_event_is_terminal(&json!({ "type": " done " }))); + assert!(!chat_event_is_terminal( + &json!({ "type": "token", "text": "hi" }) + )); + assert!(!chat_event_is_terminal(&json!({ "type": "tool_call" }))); + assert!(!chat_event_is_terminal(&json!({ "kind": "done" }))); + assert!(!chat_event_is_terminal(&json!("done"))); + } + + #[test] + fn runtime_status_envelope_carries_run_reports() { + let active_run = proto::ChatRunReport { + run_id: "run-1".to_string(), + conversation_id: "conversation-1".to_string(), + state: "running".to_string(), + error_code: String::new(), + message: String::new(), + updated_at: 1_772_000_000_000, + }; + let finished_run = proto::ChatRunReport { + run_id: "run-2".to_string(), + conversation_id: "conversation-2".to_string(), + state: "failed".to_string(), + error_code: "desktop_run_lost".to_string(), + message: "The desktop runtime stopped reporting this run.".to_string(), + updated_at: 1_772_000_000_500, + }; + + let envelope = build_gateway_runtime_status_envelope( + "worker-1".to_string(), + "busy".to_string(), + true, + 2, + vec![active_run], + vec![finished_run], + ); + + let status = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::RuntimeStatus(status) => status, + _ => panic!("expected runtime status payload"), + }; + assert_eq!(status.worker_id, "worker-1"); + assert_eq!(status.state, "busy"); + assert!(status.visible); + assert_eq!(status.active_run_count, 2); + assert_eq!(status.active_runs.len(), 1); + assert_eq!(status.active_runs[0].run_id, "run-1"); + assert_eq!(status.active_runs[0].state, "running"); + assert_eq!(status.active_runs[0].updated_at, 1_772_000_000_000); + assert_eq!(status.finished_runs.len(), 1); + assert_eq!(status.finished_runs[0].run_id, "run-2"); + assert_eq!(status.finished_runs[0].state, "failed"); + assert_eq!(status.finished_runs[0].error_code, "desktop_run_lost"); + assert_eq!( + status.finished_runs[0].message, + "The desktop runtime stopped reporting this run." + ); + } +} + +fn chat_event_type(event: &Value) -> Option<&str> { + event.get("type").and_then(Value::as_str).map(str::trim) +} + +fn chat_event_is_terminal(event: &Value) -> bool { + matches!(chat_event_type(event), Some("done") | Some("error")) +} + +fn chat_event_conversation_id(event: &Value) -> String { + event + .as_object() + .and_then(|object| { + optional_string_field(object, "conversation_id") + .or_else(|| optional_string_field(object, "conversationId")) + }) + .unwrap_or_default() } fn build_chat_event_envelope( @@ -4988,6 +5335,8 @@ fn build_gateway_runtime_status_envelope( state: String, visible: bool, active_run_count: u32, + active_runs: Vec, + finished_runs: Vec, ) -> proto::AgentEnvelope { proto::AgentEnvelope { request_id: format!("runtime-status-{}", worker_id.trim()), @@ -4999,11 +5348,24 @@ fn build_gateway_runtime_status_envelope( visible, active_run_count, timestamp: now_unix_seconds(), + active_runs, + finished_runs, }, )), } } +fn chat_run_report_from_entry(entry: &ChatRunLedgerEntry) -> proto::ChatRunReport { + proto::ChatRunReport { + run_id: entry.run_id.clone(), + conversation_id: entry.conversation_id.clone(), + state: entry.state.as_str().to_string(), + error_code: entry.error_code.clone(), + message: entry.message.clone(), + updated_at: entry.updated_at_ms, + } +} + fn build_history_sync_envelope( event: GatewayHistorySyncEvent, ) -> Result { @@ -5253,6 +5615,17 @@ pub(crate) fn now_unix_seconds() -> i64 { i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) } +pub(crate) fn now_unix_millis() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + +fn chat_run_ledger_now() -> (Instant, i64) { + (Instant::now(), now_unix_millis()) +} + fn string_field(object: &serde_json::Map, key: &str) -> Result { object .get(key) diff --git a/crates/agent-gui/src-tauri/src/services/mod.rs b/crates/agent-gui/src-tauri/src/services/mod.rs index 78520df2..6a874b83 100644 --- a/crates/agent-gui/src-tauri/src/services/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod chat_run_ledger; pub mod cron; pub mod gateway; pub mod gateway_bridge; diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 44b63dc2..dff09722 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -333,6 +333,8 @@ const HISTORY_SWITCH_OVERLAY_MIN_MS = 260; const PROJECT_HISTORY_DELETE_PAGE_SIZE = 200; const SHARED_HISTORY_LIST_PAGE_SIZE = 200; const GATEWAY_RUNTIME_SNAPSHOT_DEBOUNCE_MS = 300; +// Must stay well below the desktop run ledger's 5-minute active TTL. +const GATEWAY_RUNTIME_RUN_KEEPALIVE_MS = 60_000; function appendManagedSkillSelections(current: readonly string[], names: readonly string[]) { const out = mergeAlwaysEnabledSkillNames(current); @@ -3298,6 +3300,26 @@ export function ChatPage(props: ChatPageProps) { } }, [canShareHistory, remoteRuntimeStatus.connectedSince, remoteRuntimeStatus.sessionId]); + // Keep-alive: a long silent tool call produces no chat events, and the + // desktop run ledger treats an untouched run as lost after its active TTL + // (which would surface a spurious failure on remote clients). Re-publishing + // the running snapshot refreshes both the ledger and the gateway activity. + useEffect(() => { + if (!canShareHistory) { + return; + } + const timerId = window.setInterval(() => { + for (const run of activeGatewayRuntimeRunsRef.current.values()) { + if (run.state === "running") { + void queueGatewayRuntimeSnapshotForRun(run, { state: run.state }); + } + } + }, GATEWAY_RUNTIME_RUN_KEEPALIVE_MS); + return () => { + window.clearInterval(timerId); + }; + }, [canShareHistory]); + function applyGatewayBridgeRebase(conversationId: string, baseMessageRef: HistoryMessageRef) { const targetConversationId = conversationId.trim(); if (!targetConversationId) { diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index 9ba6f4ec..27d8339e 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -116,7 +116,9 @@ Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、 |---|---|---| | Desktop offline | WebUI 请求返回 agent offline 或状态 offline | `session.Manager` 检测当前 session,WebUI 展示离线/不可用状态。 | | WebSocket 断开 | WebUI 自动重连非 Chat 同步;Chat SSE 可按 seq 恢复 | `GatewayWebSocketClient`、SharedWorker 与 fetch SSE client 分别管理重连,Gateway 从 SQLite `chat_events` 补发 seq event。 | -| gRPC stream 断开 | Agent session close,pending stream 结束 | 桌面端 remote auto reconnect 可重新建立 session。 | +| gRPC stream 断开 | Agent session close,pending stream 结束 | 桌面端 remote auto reconnect 可重新建立 session;重连后桌面端 republish chat run 台账(active `started` + 未确认终态控制事件),网关幂等收养。 | +| Chat run 终态信号丢失 | run 已在桌面端结束但网关 activity 未清除 | 桌面端 `ChatRunLedger` 先记账再发送,5s sweeper 重发未送达终态;心跳 `RuntimeStatusEvent.active_runs/finished_runs` 驱动网关对账:finished 报告按真实终态收养,active 报告逐 run 续命,缺席且无事件/续命超过 `runReportLostTimeout`(15s)判 `failed/desktop_run_lost`。 | +| Chat run 卡死兜底 | 桌面端不再上报某 run | 在线走 `staleRunTimeout`(10min,逐 run 续命,单会话忙碌不屏蔽他会话);离线走 `offlineRunTimeout`(30min)判 `failed/agent_offline`。 | | Chat run 重复提交 | 同一 clientRequestId 重复 | SQLite `chat_command_dedup` 去重,内存索引只是热缓存。 | | Chat command 未进入运行态 | 事件流只到 accepted/delivered 后不继续 | HTTP command path 使用 `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` 与 `LIVEAGENT_GATEWAY_CHAT_RENDER_START_TIMEOUT` watchdog 写入 `run.failed`,避免 WebUI 无限等待。 | | 服务退出 | Ctrl+C 后 HTTP/gRPC shutdown | `cmd/gateway/main.go` 和 `shutdown.go` 控制 graceful/force stop。 | From 14618056adbb88a82e04242d39c12e4e4d87dec7 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 00:04:09 +0800 Subject: [PATCH 31/64] fix(settings): preserve UI-only settings during gateway sync --- .../commands/config/settings/gateway_sync.rs | 26 +---- .../src-tauri/src/services/gateway.rs | 105 +++++++++++++++++- 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs index 7636a7e4..8152ef99 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/gateway_sync.rs @@ -46,25 +46,12 @@ pub(crate) fn load_gateway_settings_sync_snapshot(conn: &Connection) -> Result Result { ); Ok(Value::Object(payload)) } - diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index 4a733ff9..5ea1dd29 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -1520,13 +1520,32 @@ impl GatewayController { return self.send_error_response(request_id, 400, error).await; } }; - let public_snapshot = match redact_gateway_settings_sync_payload(snapshot) { + let public_update = match redact_gateway_settings_sync_payload(snapshot) { Ok(payload) => payload, Err(error) => { return self.send_error_response(request_id, 400, error).await; } }; - if let Err(error) = self.store_settings_snapshot(public_snapshot) { + // The update is a partial payload (only changed fields, e.g. + // {"theme":"dark"}). Overlay it onto the current full snapshot; + // storing it as-is would drop every other cached field and let + // rebuilt snapshots revert UI-only settings like theme. + let current_snapshot = match self.current_settings_snapshot().await { + Ok(snapshot) => snapshot, + Err(error) => { + return self.send_error_response(request_id, 500, error).await; + } + }; + let merged_snapshot = match merge_settings_update_into_snapshot( + current_snapshot, + public_update, + ) { + Ok(payload) => payload, + Err(error) => { + return self.send_error_response(request_id, 400, error).await; + } + }; + if let Err(error) = self.store_settings_snapshot(merged_snapshot) { return self.send_error_response(request_id, 500, error).await; } match self @@ -3808,6 +3827,28 @@ fn merge_settings_sync_snapshot(snapshot: Value, cached: Option<&Value>) -> Resu Ok(Value::Object(merged)) } +fn merge_settings_update_into_snapshot(snapshot: Value, update: Value) -> Result { + let mut merged = match snapshot { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + let update = match update { + Value::Object(map) => map, + _ => return Err("gateway settings update payload must be an object".to_string()), + }; + + for (field, value) in update { + // Remote settings are desktop-owned (loaded from the local DB on every + // snapshot rebuild); never let a remote client overwrite them. + if field == "remote" { + continue; + } + merged.insert(field, value); + } + + Ok(Value::Object(merged)) +} + pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistorySyncEvent { GatewayHistorySyncEvent { kind: "upsert".to_string(), @@ -4396,10 +4437,11 @@ mod tests { build_gateway_runtime_status_envelope, build_grpc_url, build_local_settings_update_event_payload, chat_event_is_terminal, format_gateway_terminal_stream_rpc_error, history_share_resolve_error_code, - merge_settings_sync_snapshot, proto, queue_terminal_stream_handshake_frame, - required_terminal_project_path_key, set_disconnected_status, GatewayChatRequestEvent, - GatewayChatRuntimeSnapshot, GatewayController, GatewayStatusSnapshot, - RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, GATEWAY_CHAT_RUNNING_LEASE_MS, + merge_settings_sync_snapshot, merge_settings_update_into_snapshot, proto, + queue_terminal_stream_handshake_frame, required_terminal_project_path_key, + set_disconnected_status, GatewayChatRequestEvent, GatewayChatRuntimeSnapshot, + GatewayController, GatewayStatusSnapshot, RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, + GATEWAY_CHAT_RUNNING_LEASE_MS, }; use crate::commands::settings::RemoteSettingsPayload; use serde_json::{json, Value}; @@ -4766,6 +4808,57 @@ mod tests { ); } + #[test] + fn merge_settings_sync_snapshot_without_cache_leaves_ui_only_fields_absent() { + let db_snapshot = json!({ + "system": { "executionMode": "agent-dev" }, + "cron": [{ "id": "cron-a" }], + }); + + let merged = + merge_settings_sync_snapshot(db_snapshot, None).expect("merge settings sync snapshot"); + + let merged_map = merged.as_object().expect("merged snapshot object"); + assert!(!merged_map.contains_key("theme")); + assert!(!merged_map.contains_key("locale")); + assert!(!merged_map.contains_key("selectedModel")); + assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); + } + + #[test] + fn merge_settings_update_into_snapshot_keeps_unrelated_fields() { + let full_snapshot = json!({ + "system": { "executionMode": "agent-dev" }, + "theme": "dark", + "locale": "en-US", + "selectedModel": { + "customProviderId": "provider-a", + "model": "gpt-5.4" + }, + "remote": { "enableWebTerminal": true }, + }); + let partial_update = json!({ + "theme": "system", + "remote": { "enableWebTerminal": false }, + }); + + let merged = merge_settings_update_into_snapshot(full_snapshot, partial_update) + .expect("merge settings update into snapshot"); + + assert_eq!(merged["theme"], json!("system")); + assert_eq!(merged["locale"], json!("en-US")); + assert_eq!( + merged["selectedModel"], + json!({ + "customProviderId": "provider-a", + "model": "gpt-5.4" + }) + ); + assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); + // Remote settings are desktop-owned and must not be overwritten by clients. + assert_eq!(merged["remote"], json!({ "enableWebTerminal": true })); + } + #[test] fn local_settings_update_event_keeps_private_api_key_updates_only_at_root() { let payload = json!({ From ef48fb6bf3bd7629307e5f0f64cf481bb11310bb Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 00:52:18 +0800 Subject: [PATCH 32/64] fix(settings): prevent duplicate settings sync side effects --- .../src/app/hooks/useGatewaySettingsSync.ts | 55 +++++++------ crates/agent-gui/src/App.tsx | 79 +++++++++++-------- 2 files changed, 76 insertions(+), 58 deletions(-) diff --git a/crates/agent-gateway/web/src/app/hooks/useGatewaySettingsSync.ts b/crates/agent-gateway/web/src/app/hooks/useGatewaySettingsSync.ts index 4ac6c4eb..a7b04da1 100644 --- a/crates/agent-gateway/web/src/app/hooks/useGatewaySettingsSync.ts +++ b/crates/agent-gateway/web/src/app/hooks/useGatewaySettingsSync.ts @@ -36,6 +36,13 @@ export function useGatewaySettingsSync(params: { }); const settingsSaveSequenceRef = useRef(0); const settingsSaveChainRef = useRef>(Promise.resolve()); + // Mirrors `settings` so setSettings/applyGatewaySettings can read the latest value + // synchronously without passing a (side-effecting) function into setSettingsState — + // React 18 StrictMode double-invokes functional state updaters in development, + // which would otherwise run those side effects (and any non-idempotent work like + // crypto.randomUUID() inside caller updaters) twice per call. + const settingsRef = useRef(settings); + settingsRef.current = settings; const [systemThemeVersion, setSystemThemeVersion] = useState(0); // Monaco reads NLS globals while the lazy editor module imports monaco-editor. @@ -101,13 +108,13 @@ export function useGatewaySettingsSync(params: { void api .getSettings() .then((payload) => { - setSettingsState((current) => { - const refreshed = redactSettingsForWebStorage( - resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(current, payload)), - ); - persistWebSettings(refreshed); - return refreshed; - }); + const current = settingsRef.current; + const refreshed = redactSettingsForWebStorage( + resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(current, payload)), + ); + settingsRef.current = refreshed; + persistWebSettings(refreshed); + setSettingsState(refreshed); }) .catch(() => undefined); } @@ -124,29 +131,29 @@ export function useGatewaySettingsSync(params: { const applyGatewaySettings = useCallback( (payload: GatewaySettingsSyncPayload) => { - setSettingsState((prev) => { - const rawNext = resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(prev, payload)); - const next = redactSettingsForWebStorage(rawNext); - if (!hasSettingsSyncChanged(prev, next)) { - return prev; - } - queueSettingsSave(prev, next, "同步桌面端设置失败。", false); - return next; - }); + const prev = settingsRef.current; + const rawNext = resolveAppWorkspaceProjects(applyGatewaySettingsSyncPayload(prev, payload)); + const next = redactSettingsForWebStorage(rawNext); + if (!hasSettingsSyncChanged(prev, next)) { + return; + } + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave(prev, next, "同步桌面端设置失败。", false); }, [queueSettingsSave], ); const setSettings = useCallback( (updater: (prev: AppSettings) => AppSettings) => { - setSettingsState((prev) => { - const updated = updater(prev); - if (updated === prev) return prev; - const rawNext = resolveAppWorkspaceProjects(normalizeSettings(updated)); - const next = redactSettingsForWebStorage(rawNext); - queueSettingsSave(prev, rawNext, "保存 WebUI 设置失败。", true); - return next; - }); + const prev = settingsRef.current; + const updated = updater(prev); + if (updated === prev) return; + const rawNext = resolveAppWorkspaceProjects(normalizeSettings(updated)); + const next = redactSettingsForWebStorage(rawNext); + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave(prev, rawNext, "保存 WebUI 设置失败。", true); }, [queueSettingsSave], ); diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 4788ff41..055a8548 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -135,6 +135,13 @@ export default function App() { const saveSequenceRef = useRef(0); const saveChainRef = useRef>(Promise.resolve()); const defaultWorkdirRef = useRef(""); + // Mirrors `settings` so setSettings/queueSettingsSave can read the latest value + // synchronously without passing a (side-effecting) function into setSettingsState — + // React 18 StrictMode double-invokes functional state updaters in development, + // which would otherwise run those side effects (and any non-idempotent work like + // crypto.randomUUID() inside caller updaters) twice per call. + const settingsRef = useRef(settings); + settingsRef.current = settings; const [systemThemeVersion, setSystemThemeVersion] = useState(0); const effectiveTheme = useMemo( () => resolveEffectiveTheme(settings.theme), @@ -163,6 +170,7 @@ export default function App() { if (!cancelled) { defaultWorkdirRef.current = defaultWorkdir; const loadedWithDefaults = applyRuntimeSystemDefaults(loaded, defaultWorkdir); + settingsRef.current = loadedWithDefaults; setSettingsState(loadedWithDefaults); setSettingsSaveState({ status: "saved" }); void publishGatewaySettingsSync(loadedWithDefaults).catch((error) => { @@ -171,7 +179,9 @@ export default function App() { } } catch (error) { if (!cancelled) { - setSettingsState(getDefaultSettings()); + const fallback = getDefaultSettings(); + settingsRef.current = fallback; + setSettingsState(fallback); setSettingsSaveState({ status: "error", message: asErrorMessage(error, "加载设置失败,已回退到默认配置。"), @@ -206,12 +216,12 @@ export default function App() { }) : next; if (persistResult.ssh && saveSequenceRef.current === saveSequence) { - setSettingsState((current) => - normalizeSettings({ - ...current, - ssh: persistResult.ssh, - }), - ); + const merged = normalizeSettings({ + ...settingsRef.current, + ssh: persistResult.ssh, + }); + settingsRef.current = merged; + setSettingsState(merged); } if (persistResult.conflict) { throw new Error(persistResult.conflict); @@ -239,21 +249,21 @@ export default function App() { const setSettings = useCallback( (updater: (prev: AppSettings) => AppSettings) => { - setSettingsState((prev) => { - const updated = updater(prev); - if (updated === prev) return prev; - const next = applyRuntimeSystemDefaults( - normalizeSettings(updated), - defaultWorkdirRef.current, - ); - queueSettingsSave( - prev, - next, - "保存设置失败。", - hasSettingsSyncChanged(prev, next) || hasSensitiveSettingsUpdates(next), - ); - return next; - }); + const prev = settingsRef.current; + const updated = updater(prev); + if (updated === prev) return; + const next = applyRuntimeSystemDefaults( + normalizeSettings(updated), + defaultWorkdirRef.current, + ); + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave( + prev, + next, + "保存设置失败。", + hasSettingsSyncChanged(prev, next) || hasSensitiveSettingsUpdates(next), + ); }, [queueSettingsSave], ); @@ -263,6 +273,7 @@ export default function App() { const { settings: loaded, defaultWorkdir } = await loadPersistedSettingsWithDefaults(); defaultWorkdirRef.current = defaultWorkdir; const loadedWithDefaults = applyRuntimeSystemDefaults(loaded, defaultWorkdir); + settingsRef.current = loadedWithDefaults; setSettingsState(loadedWithDefaults); setSettingsSaveState({ status: "saved" }); }, []); @@ -338,18 +349,18 @@ export default function App() { return; } - setSettingsState((prev) => { - const next = applyRuntimeSystemDefaults( - applyGatewaySettingsSyncPayload(prev, event.payload), - defaultWorkdirRef.current, - ); - const publicChanged = hasSettingsSyncChanged(prev, next); - if (!publicChanged && !hasSensitiveSettingsUpdatesPayload(event.payload)) { - return prev; - } - queueSettingsSave(prev, next, "同步 WebUI 设置失败。", publicChanged); - return next; - }); + const prev = settingsRef.current; + const next = applyRuntimeSystemDefaults( + applyGatewaySettingsSyncPayload(prev, event.payload), + defaultWorkdirRef.current, + ); + const publicChanged = hasSettingsSyncChanged(prev, next); + if (!publicChanged && !hasSensitiveSettingsUpdatesPayload(event.payload)) { + return; + } + settingsRef.current = next; + setSettingsState(next); + queueSettingsSave(prev, next, "同步 WebUI 设置失败。", publicChanged); }, ); From ff509871bd6e94204310fd566746d4c3e4b001c9 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 00:55:30 +0800 Subject: [PATCH 33/64] fix(ssh): place proxy tags after auth method --- .../web/src/components/project-tools/SshTunnelPanel.tsx | 8 +++++--- .../agent-gateway/web/src/pages/settings/SshSection.tsx | 7 ++++--- .../src/components/project-tools/SshTunnelPanel.tsx | 8 +++++--- crates/agent-gui/src/pages/settings/SshSection.tsx | 7 ++++--- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx index 93d922ed..b2b9a4fc 100644 --- a/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/SshTunnelPanel.tsx @@ -184,9 +184,6 @@ function HostMetaTags(props: { host: SshHostConfig }) { if (host.privateKeyPassphraseConfigured) { tags.push(t("settings.sshPrivateKeyPassphraseConfigured")); } - if (hostHasProxy(host)) { - tags.push(t("settings.sshAdvancedProxy")); - } if (tags.length === 0) return null; return (
@@ -642,6 +639,11 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) { {authLabel(host, t)} + {hostHasProxy(host) ? ( + + {t("settings.sshAdvancedProxy")} + + ) : null}
{endpointLabel(host)} diff --git a/crates/agent-gateway/web/src/pages/settings/SshSection.tsx b/crates/agent-gateway/web/src/pages/settings/SshSection.tsx index bf0c966c..7649bd65 100644 --- a/crates/agent-gateway/web/src/pages/settings/SshSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/SshSection.tsx @@ -790,7 +790,7 @@ function SshHostCard(props: { const showAgentConfigured = host.authType === "agent"; const showProxy = host.proxy.url.trim().length > 0 || host.proxy.port > 0 || host.proxy.passwordConfigured; - const hasMeta = showKeyPath || showKeyConfigured || showAgentConfigured || showProxy; + const hasMeta = showKeyPath || showKeyConfigured || showAgentConfigured; const hasFooter = hasMeta || resetStatus; const actions = ( @@ -845,7 +845,6 @@ function SshHostCard(props: { {showKeyPath ? : null} {showKeyConfigured ? : null} {showAgentConfigured ? : null} - {showProxy ? : null}
); @@ -869,8 +868,9 @@ function SshHostCard(props: {
{host.name}
-
+
+ {showProxy ? : null}
@@ -897,6 +897,7 @@ function SshHostCard(props: {
{host.name} + {showProxy ? : null}
{endpointLabel(host)} diff --git a/crates/agent-gui/src/components/project-tools/SshTunnelPanel.tsx b/crates/agent-gui/src/components/project-tools/SshTunnelPanel.tsx index 13b74853..4797e13c 100644 --- a/crates/agent-gui/src/components/project-tools/SshTunnelPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/SshTunnelPanel.tsx @@ -172,9 +172,6 @@ function HostMetaTags(props: { host: SshHostConfig }) { if (host.privateKeyPassphraseConfigured) { tags.push(t("settings.sshPrivateKeyPassphraseConfigured")); } - if (hostHasProxy(host)) { - tags.push(t("settings.sshAdvancedProxy")); - } if (tags.length === 0) return null; return (
@@ -624,6 +621,11 @@ export function SshTunnelPanel(props: SshTunnelPanelProps) { {authLabel(host, t)} + {hostHasProxy(host) ? ( + + {t("settings.sshAdvancedProxy")} + + ) : null}
{endpointLabel(host)} diff --git a/crates/agent-gui/src/pages/settings/SshSection.tsx b/crates/agent-gui/src/pages/settings/SshSection.tsx index 97d52471..de87ad5a 100644 --- a/crates/agent-gui/src/pages/settings/SshSection.tsx +++ b/crates/agent-gui/src/pages/settings/SshSection.tsx @@ -789,7 +789,7 @@ function SshHostCard(props: { const showAgentConfigured = host.authType === "agent"; const showProxy = host.proxy.url.trim().length > 0 || host.proxy.port > 0 || host.proxy.passwordConfigured; - const hasMeta = showKeyPath || showKeyConfigured || showAgentConfigured || showProxy; + const hasMeta = showKeyPath || showKeyConfigured || showAgentConfigured; const hasFooter = hasMeta || resetStatus; const actions = ( @@ -844,7 +844,6 @@ function SshHostCard(props: { {showKeyPath ? : null} {showKeyConfigured ? : null} {showAgentConfigured ? : null} - {showProxy ? : null}
); @@ -868,8 +867,9 @@ function SshHostCard(props: {
{host.name}
-
+
+ {showProxy ? : null}
@@ -896,6 +896,7 @@ function SshHostCard(props: {
{host.name} + {showProxy ? : null}
{endpointLabel(host)} From 724f3a4bd19ecff7d9a9a05da3a31f397ec3163e Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 08:13:24 +0800 Subject: [PATCH 34/64] fix(gateway): stabilize remote liveness under stream load --- crates/agent-gateway/cmd/gateway/main.go | 13 ++++ crates/agent-gateway/internal/server/grpc.go | 39 ++++++----- .../internal/server/grpc_test.go | 66 +++++++++++++++++++ .../internal/server/websocket.go | 65 +++++++++++++----- .../internal/server/websocket_write_test.go | 64 ++++++++++++++++++ .../internal/session/agent_session.go | 34 +++++++++- .../agent-gateway/internal/session/manager.go | 1 + .../internal/session/manager_dispatch.go | 4 ++ .../internal/session/manager_registry.go | 31 ++++----- .../test/session/manager_test.go | 48 ++++++++++++-- crates/agent-gateway/web/src/i18n/config.ts | 4 +- .../web/src/lib/gatewaySocket.ts | 27 ++++++++ .../src-tauri/src/services/gateway.rs | 54 ++++++++------- 13 files changed, 373 insertions(+), 77 deletions(-) create mode 100644 crates/agent-gateway/internal/server/websocket_write_test.go diff --git a/crates/agent-gateway/cmd/gateway/main.go b/crates/agent-gateway/cmd/gateway/main.go index 05308165..358236bf 100644 --- a/crates/agent-gateway/cmd/gateway/main.go +++ b/crates/agent-gateway/cmd/gateway/main.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" "google.golang.org/grpc/reflection" "github.com/liveagent/agent-gateway/internal/auth" @@ -99,6 +100,18 @@ func newGRPCServer(cfg *config.Config, sm *session.Manager) (*grpc.Server, error grpc.MaxSendMsgSize(cfg.GRPCMaxMessageBytes), grpc.UnaryInterceptor(auth.GRPCUnaryInterceptor(cfg.Token)), grpc.StreamInterceptor(auth.GRPCStreamInterceptor(cfg.Token)), + // Transport-level liveness: h2 PINGs are not subject to application + // queue congestion, so dead links are detected even mid-stream. + grpc.KeepaliveParams(keepalive.ServerParameters{ + Time: 30 * time.Second, + Timeout: 10 * time.Second, + }), + // The desktop GUI pings every 10-60s; MinTime must stay below its + // floor or grpc-go answers with a too_many_pings GOAWAY. + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ + MinTime: 5 * time.Second, + PermitWithoutStream: true, + }), } if cfg.TLSCert != "" || cfg.TLSKey != "" { creds, err := credentials.NewServerTLSFromFile(cfg.TLSCert, cfg.TLSKey) diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go index 5624f67f..311d03dd 100644 --- a/crates/agent-gateway/internal/server/grpc.go +++ b/crates/agent-gateway/internal/server/grpc.go @@ -4,7 +4,6 @@ import ( "context" "errors" "io" - "log" "strings" "time" @@ -67,9 +66,22 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ } }() + pings := sess.Pings() sendErrCh := make(chan error, 1) go func() { for { + // Heartbeats jump the shared data queue so congestion can never + // starve them. + select { + case ping := <-pings: + if err := stream.Send(ping); err != nil { + sendErrCh <- err + cancel() + return + } + continue + default: + } select { case <-ctx.Done(): sendErrCh <- ctx.Err() @@ -78,6 +90,12 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ sendErrCh <- nil cancel() return + case ping := <-pings: + if err := stream.Send(ping); err != nil { + sendErrCh <- err + cancel() + return + } case outbound := <-toAgent: if outbound == nil || outbound.GatewayEnvelope == nil { continue @@ -119,8 +137,10 @@ func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServ return err } + // Any inbound envelope proves the agent is alive; a streaming agent + // must never be declared heartbeat-stale. + s.sm.TouchHeartbeat(sess) if env.GetPong() != nil { - s.sm.TouchHeartbeat(sess) continue } @@ -239,7 +259,7 @@ func (s *GRPCServer) heartbeatPeriod() time.Duration { } func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { - ok, err := sess.TrySendToAgent(&gatewayv1.GatewayEnvelope{ + return sess.SendPing(&gatewayv1.GatewayEnvelope{ RequestId: "ping-" + uuid.NewString(), Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_Ping{ @@ -247,16 +267,5 @@ func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { Timestamp: time.Now().Unix(), }, }, - }) - if errors.Is(err, session.ErrAgentOffline) { - return false - } - if err != nil { - log.Printf("send heartbeat failed: %v", err) - return false - } - if !ok { - log.Printf("skip heartbeat: outbound queue is full") - } - return true + }) == nil } diff --git a/crates/agent-gateway/internal/server/grpc_test.go b/crates/agent-gateway/internal/server/grpc_test.go index e8020849..9f902927 100644 --- a/crates/agent-gateway/internal/server/grpc_test.go +++ b/crates/agent-gateway/internal/server/grpc_test.go @@ -70,3 +70,69 @@ func TestAgentTerminalConnectSendsReadyFrame(t *testing.T) { t.Fatal("gRPC server did not stop") } } + +func TestAgentConnectTouchesHeartbeatOnAnyEnvelope(t *testing.T) { + listener := bufconn.Listen(1024 * 1024) + sm := session.NewManager() + grpcServer := grpc.NewServer() + gatewayv1.RegisterAgentGatewayServer( + grpcServer, + NewGRPCServer(&config.Config{HeartbeatPeriod: 100 * time.Millisecond}, sm), + ) + t.Cleanup(func() { + grpcServer.Stop() + _ = listener.Close() + }) + go func() { + _ = grpcServer.Serve(listener) + }() + + conn, err := grpc.NewClient( + "passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return listener.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("dial bufconn gRPC: %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + stream, err := gatewayv1.NewAgentGatewayClient(conn).AgentConnect(ctx) + if err != nil { + t.Fatalf("open agent stream: %v", err) + } + + // The dedicated lane must deliver pings regardless of data-queue state. + if _, err := stream.Recv(); err != nil { + t.Fatalf("receive initial ping: %v", err) + } + + // Never send a Pong: non-pong traffic alone must keep the session alive + // through several staleness windows (timeout = 3 x 100ms period). + deadline := time.Now().Add(700 * time.Millisecond) + for time.Now().Before(deadline) { + if err := stream.Send(&gatewayv1.AgentEnvelope{RequestId: "activity"}); err != nil { + t.Fatalf("send activity envelope: %v", err) + } + if !sm.IsOnline() { + t.Fatalf("session cleared while agent was actively sending") + } + time.Sleep(50 * time.Millisecond) + } + + // Once traffic stops, the staleness sweep must still clear the session. + waitUntil := time.Now().Add(2 * time.Second) + for sm.IsOnline() { + if time.Now().After(waitUntil) { + t.Fatalf("session not cleared after inbound traffic stopped") + } + time.Sleep(25 * time.Millisecond) + } +} diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 733bcdee..a1d215ee 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -109,8 +109,8 @@ type websocketConnection struct { done chan struct{} authorized bool - lastPongAt time.Time - lastPongMu sync.Mutex + lastInboundAt time.Time + lastInboundMu sync.Mutex historyEvents <-chan *gatewayv1.HistorySyncEvent historyEventsCleanup func() @@ -187,13 +187,13 @@ func (c *websocketConnection) serve() { return } + // Any inbound frame proves the client is alive — heartbeat pongs are + // not the only liveness evidence. + c.touchInboundActivity() + req.ID = strings.TrimSpace(req.ID) req.Type = strings.TrimSpace(req.Type) if req.Type == "pong" { - c.lastPongMu.Lock() - c.lastPongAt = time.Now() - c.lastPongMu.Unlock() - _ = c.conn.SetReadDeadline(time.Now().Add(c.pongTimeout())) continue } if req.ID == "" { @@ -281,7 +281,6 @@ func (c *websocketConnection) handleAuth(req websocketRequest) { c.authorized = true go c.writeLoop() - _ = c.conn.SetReadDeadline(time.Now().Add(c.pongTimeout())) c.startHistorySyncForwarder() c.startSettingsSyncForwarder() c.startTerminalEventForwarder() @@ -505,9 +504,6 @@ func (c *websocketConnection) startWebSocketHeartbeat() { if period <= 0 { period = 15 * time.Second } - c.lastPongMu.Lock() - c.lastPongAt = time.Now() - c.lastPongMu.Unlock() go func() { ticker := time.NewTicker(period) defer ticker.Stop() @@ -516,10 +512,10 @@ func (c *websocketConnection) startWebSocketHeartbeat() { case <-c.done: return case <-ticker.C: - c.lastPongMu.Lock() - lastPong := c.lastPongAt - c.lastPongMu.Unlock() - if time.Since(lastPong) > c.pongTimeout() { + c.lastInboundMu.Lock() + lastInbound := c.lastInboundAt + c.lastInboundMu.Unlock() + if time.Since(lastInbound) > c.idleTimeout() { c.close() return } @@ -537,7 +533,16 @@ func (c *websocketConnection) startWebSocketHeartbeat() { }) } -func (c *websocketConnection) pongTimeout() time.Duration { +// touchInboundActivity records liveness for every inbound frame and pushes the +// read deadline forward accordingly. +func (c *websocketConnection) touchInboundActivity() { + c.lastInboundMu.Lock() + c.lastInboundAt = time.Now() + c.lastInboundMu.Unlock() + _ = c.conn.SetReadDeadline(time.Now().Add(c.idleTimeout())) +} + +func (c *websocketConnection) idleTimeout() time.Duration { period := c.cfg.WebSocketHeartbeatPeriod if period <= 0 { period = 15 * time.Second @@ -614,15 +619,41 @@ func (c *websocketConnection) writeEvent(eventType string, payload any) error { }) } +var errWriteQueueFull = errors.New("write queue full") + func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { + err := c.enqueueEnvelope(envelope) + if errors.Is(err, errWriteQueueFull) { + c.close() + } + return err +} + +// enqueueEnvelope waits up to writeTimeout for a slot when the outbox is +// momentarily full (token bursts to a slow reader); only a persistent backlog +// is fatal. The fast path allocates nothing. +func (c *websocketConnection) enqueueEnvelope(envelope websocketEnvelope) error { select { case <-c.done: return errors.New("connection closed") case c.outbox <- envelope: return nil default: - c.close() - return errors.New("write queue full") + } + + timeout := c.writeTimeout + if timeout <= 0 { + timeout = 10 * time.Second + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-c.done: + return errors.New("connection closed") + case c.outbox <- envelope: + return nil + case <-timer.C: + return errWriteQueueFull } } diff --git a/crates/agent-gateway/internal/server/websocket_write_test.go b/crates/agent-gateway/internal/server/websocket_write_test.go new file mode 100644 index 00000000..d998f47b --- /dev/null +++ b/crates/agent-gateway/internal/server/websocket_write_test.go @@ -0,0 +1,64 @@ +package server + +import ( + "errors" + "testing" + "time" +) + +func newEnqueueTestConnection(outboxSize int, writeTimeout time.Duration) *websocketConnection { + return &websocketConnection{ + outbox: make(chan websocketEnvelope, outboxSize), + writeTimeout: writeTimeout, + done: make(chan struct{}), + } +} + +func TestEnqueueEnvelopeWaitsForDrainedSlot(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, 500*time.Millisecond) + c.outbox <- websocketEnvelope{Type: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + <-c.outbox + }() + + if err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}); err != nil { + t.Fatalf("enqueueEnvelope with draining outbox = %v, want nil", err) + } +} + +func TestEnqueueEnvelopeFailsAfterPersistentBacklog(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, 50*time.Millisecond) + c.outbox <- websocketEnvelope{Type: "ping"} + + started := time.Now() + err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) + if !errors.Is(err, errWriteQueueFull) { + t.Fatalf("enqueueEnvelope with stuck outbox = %v, want errWriteQueueFull", err) + } + if waited := time.Since(started); waited < 50*time.Millisecond { + t.Fatalf("enqueueEnvelope gave up after %s, want at least the 50ms write timeout", waited) + } +} + +func TestEnqueueEnvelopeReturnsWhenConnectionCloses(t *testing.T) { + t.Parallel() + + c := newEnqueueTestConnection(1, time.Second) + c.outbox <- websocketEnvelope{Type: "ping"} + + go func() { + time.Sleep(10 * time.Millisecond) + close(c.done) + }() + + err := c.enqueueEnvelope(websocketEnvelope{Type: "chat.event"}) + if err == nil || err.Error() != "connection closed" { + t.Fatalf("enqueueEnvelope on closed connection = %v, want connection closed", err) + } +} diff --git a/crates/agent-gateway/internal/session/agent_session.go b/crates/agent-gateway/internal/session/agent_session.go index 5bee6705..a22f0cb4 100644 --- a/crates/agent-gateway/internal/session/agent_session.go +++ b/crates/agent-gateway/internal/session/agent_session.go @@ -14,7 +14,8 @@ func NewAgentSession(auth AuthSnapshot) *AgentSession { SessionID: auth.SessionID, ConnectedAt: time.Now(), LastPing: time.Now(), - toAgent: make(chan *OutboundEnvelope, 64), + toAgent: make(chan *OutboundEnvelope, 512), + pingCh: make(chan *gatewayv1.GatewayEnvelope, 1), done: make(chan struct{}), streams: make(map[string]*agentStream), } @@ -48,6 +49,34 @@ func (s *AgentSession) Outbound() <-chan *OutboundEnvelope { return s.toAgent } +func (s *AgentSession) Pings() <-chan *gatewayv1.GatewayEnvelope { + return s.pingCh +} + +// SendPing queues a heartbeat on a dedicated lane that can never be starved +// by the shared outbound queue. Single producer (heartbeatLoop): a still-queued +// older ping is replaced so the freshest timestamp wins. +func (s *AgentSession) SendPing(env *gatewayv1.GatewayEnvelope) error { + select { + case <-s.done: + return ErrAgentOffline + default: + } + select { + case s.pingCh <- env: + default: + select { + case <-s.pingCh: + default: + } + select { + case s.pingCh <- env: + default: + } + } + return nil +} + func (s *AgentSession) Done() <-chan struct{} { return s.done } @@ -82,7 +111,8 @@ func (s *AgentSession) SendToAgentContext(ctx context.Context, env *gatewayv1.Ga case err := <-result: return err case <-ctx.Done(): - s.Close() + // The envelope stays queued; the writer skips it once its context is + // expired. A congested-but-alive session must not be torn down here. return ctx.Err() case <-s.done: return ErrAgentOffline diff --git a/crates/agent-gateway/internal/session/manager.go b/crates/agent-gateway/internal/session/manager.go index ddfadb40..30363bd2 100644 --- a/crates/agent-gateway/internal/session/manager.go +++ b/crates/agent-gateway/internal/session/manager.go @@ -43,6 +43,7 @@ type AgentSession struct { LastPing time.Time toAgent chan *OutboundEnvelope + pingCh chan *gatewayv1.GatewayEnvelope done chan struct{} closeOnce sync.Once diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go index 2b3e9845..42715fc8 100644 --- a/crates/agent-gateway/internal/session/manager_dispatch.go +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -29,6 +29,10 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent return } + if env.GetChatEvent() != nil || env.GetChatControl() != nil || env.GetChatRuntimeSnapshot() != nil { + m.touchRuntimeActivity(session) + } + if runtimeSnapshot := env.GetChatRuntimeSnapshot(); runtimeSnapshot != nil { m.ingestRuntimeSnapshot(runtimeSnapshot) return diff --git a/crates/agent-gateway/internal/session/manager_registry.go b/crates/agent-gateway/internal/session/manager_registry.go index 3d648148..0e1277ec 100644 --- a/crates/agent-gateway/internal/session/manager_registry.go +++ b/crates/agent-gateway/internal/session/manager_registry.go @@ -181,6 +181,20 @@ func (m *Manager) UpdateRuntimeStatus( m.registry.runtimeActiveRunCount = event.GetActiveRunCount() } +// touchRuntimeActivity refreshes the chat-runtime heartbeat when live chat +// traffic proves the desktop runtime is running, even while the webview's +// own status timer is throttled (hidden/occluded window). Only refreshes an +// already-reporting runtime: a zero heartbeat must not become readiness +// (normalizeRuntimeState("") defaults to "ready"). +func (m *Manager) touchRuntimeActivity(session *AgentSession) { + m.registry.mu.Lock() + defer m.registry.mu.Unlock() + if m.registry.session != session || m.registry.runtimeLastHeartbeat.IsZero() { + return + } + m.registry.runtimeLastHeartbeat = time.Now() +} + func (m *Manager) TouchHeartbeat(session *AgentSession) { m.registry.mu.Lock() defer m.registry.mu.Unlock() @@ -232,10 +246,7 @@ func (m *Manager) SendToAgent(env *gatewayv1.GatewayEnvelope) error { if session == nil { return ErrAgentOffline } - - err := session.SendToAgent(env) - m.clearSessionAfterSendError(session, err) - return err + return session.SendToAgent(env) } func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { @@ -245,10 +256,7 @@ func (m *Manager) SendToAgentContext(ctx context.Context, env *gatewayv1.Gateway if session == nil { return ErrAgentOffline } - - err := session.SendToAgentContext(ctx, env) - m.clearSessionAfterSendError(session, err) - return err + return session.SendToAgentContext(ctx, env) } func (m *Manager) SendToAgentOrQueue(ctx context.Context, env *gatewayv1.GatewayEnvelope) error { @@ -259,13 +267,6 @@ func (m *Manager) SendToAgentOrQueue(ctx context.Context, env *gatewayv1.Gateway return err } -func (m *Manager) clearSessionAfterSendError(session *AgentSession, err error) { - if err == nil || session == nil { - return - } - m.ClearSession(session) -} - func (m *Manager) currentSessionEpoch() uint64 { m.registry.mu.RLock() defer m.registry.mu.RUnlock() diff --git a/crates/agent-gateway/test/session/manager_test.go b/crates/agent-gateway/test/session/manager_test.go index 1d978dbb..6657d7bc 100644 --- a/crates/agent-gateway/test/session/manager_test.go +++ b/crates/agent-gateway/test/session/manager_test.go @@ -261,14 +261,14 @@ func TestSendToAgentUnblocksWhenSessionCloses(t *testing.T) { } } -func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { +func TestSendToAgentContextTimeoutKeepsSessionAlive(t *testing.T) { t.Parallel() sm := newTestSessionManager() sess := session.NewAgentSession(sm.LatestAuthSnapshot()) sm.SetSession(sess) - for i := 0; i < 64; i += 1 { + for i := 0; i < cap(sess.Outbound()); i += 1 { if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { t.Fatalf("prime outbound queue: %v", err) } @@ -281,8 +281,48 @@ func TestSendToAgentContextReturnsWhenOutboundQueueIsFull(t *testing.T) { if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("SendToAgentContext with full queue = %v, want context deadline exceeded", err) } - if status := sm.Status(); status.Online { - t.Fatalf("status online = true after SendToAgentContext timeout") + if status := sm.Status(); !status.Online { + t.Fatalf("status online = false after SendToAgentContext timeout; congestion must not kill the session") + } + select { + case <-sess.Done(): + t.Fatalf("session closed after SendToAgentContext timeout") + default: + } +} + +func TestSendPingBypassesFullOutboundQueue(t *testing.T) { + t.Parallel() + + sm := newTestSessionManager() + sess := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(sess) + + for i := 0; i < cap(sess.Outbound()); i += 1 { + if err := sm.SendToAgent(&gatewayv1.GatewayEnvelope{RequestId: fmt.Sprintf("queued-%d", i)}); err != nil { + t.Fatalf("prime outbound queue: %v", err) + } + } + + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-1"}); err != nil { + t.Fatalf("SendPing with full outbound queue: %v", err) + } + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-2"}); err != nil { + t.Fatalf("SendPing replacing queued ping: %v", err) + } + + select { + case ping := <-sess.Pings(): + if ping.GetRequestId() != "ping-2" { + t.Fatalf("queued ping = %q, want latest ping-2", ping.GetRequestId()) + } + default: + t.Fatalf("no ping queued on the dedicated lane") + } + + sess.Close() + if err := sess.SendPing(&gatewayv1.GatewayEnvelope{RequestId: "ping-3"}); !errors.Is(err, session.ErrAgentOffline) { + t.Fatalf("SendPing after close = %v, want ErrAgentOffline", err) } } diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index 5b472f96..6a145135 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1198,7 +1198,7 @@ export const translations: Record> = { "由桌面端 Remote 设置控制。开启后,已登录 WebUI 可为 localhost 或 IP 地址 HTTP 服务创建和关闭临时访问链接。", "settings.remoteHeartbeat": "心跳间隔", "settings.remoteHeartbeatUnit": "秒", - "settings.remoteHeartbeatHint": "与 Gateway 之间的心跳检测间隔,用于维持连接和检测在线状态", + "settings.remoteHeartbeatHint": "与 Gateway 连接的保活心跳间隔(生效范围 10-60 秒),用于维持连接和检测在线状态", "settings.remoteInfoBanner": "启用后,本地 LiveAgent 将通过 gRPC 双向流连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", @@ -2712,7 +2712,7 @@ export const translations: Record> = { "settings.remoteHeartbeat": "Heartbeat Interval", "settings.remoteHeartbeatUnit": "seconds", "settings.remoteHeartbeatHint": - "Heartbeat interval with Gateway for maintaining connection and detecting online status", + "Keepalive heartbeat interval for the Gateway connection (effective range 10-60 seconds), used to maintain the connection and detect online status", "settings.remoteInfoBanner": "When enabled, the local LiveAgent connects to the cloud Gateway via gRPC bidirectional streaming. You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 0a04e982..302064c2 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -1044,6 +1044,10 @@ function isRecoverableGatewayTransportError(error: unknown) { ); } +function isRequestTimeoutError(error: unknown) { + return asErrorMessage(error, "").startsWith("Gateway WebSocket request timed out"); +} + const RECOVERABLE_MEMORY_MANAGE_COMMANDS = new Set([ "memory_list", "memory_read", @@ -1097,6 +1101,7 @@ export class GatewayWebSocketClient { }); private terminalSessionSnapshot = new Map(); private statusPollTimer: number | null = null; + private statusRefreshInFlight = false; private lastStatus: AgentStatus | null = null; private lastStatusError: string | null = null; private lastInboundAt = 0; @@ -2179,7 +2184,23 @@ export class GatewayWebSocketClient { } } + // The transport is demonstrably alive while frames keep arriving; a starved + // status.get during a chat.event burst must not flip the UI to offline. + private hasFreshInboundActivity() { + return ( + this.socket !== null && + this.authenticated && + this.socket.readyState === WebSocket.OPEN && + this.lastInboundAt > 0 && + Date.now() - this.lastInboundAt < SOCKET_INBOUND_STALL_MS + ); + } + private async refreshStatus() { + if (this.statusRefreshInFlight) { + return; + } + this.statusRefreshInFlight = true; try { const status = await this.getStatus(); this.clearReconnectNoticeTimer(); @@ -2190,6 +2211,10 @@ export class GatewayWebSocketClient { this.scheduleReconnectNotice(); return; } + if (isRequestTimeoutError(error) && this.hasFreshInboundActivity()) { + // Keep the last known status; the next poll retries. + return; + } const message = asErrorMessage(error, "status request failed"); const offlineStatus = this.lastStatus !== null @@ -2201,6 +2226,8 @@ export class GatewayWebSocketClient { this.lastStatus = offlineStatus; this.lastStatusError = message; this.emitStatus(offlineStatus, message); + } finally { + this.statusRefreshInFlight = false; } } diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs index 5ea1dd29..55472b8d 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway.rs @@ -695,7 +695,10 @@ impl GatewayController { config_rx: &mut watch::Receiver, ) -> Result<(), String> { let grpc_url = build_grpc_url(&config)?; - let endpoint = build_endpoint(&grpc_url)?; + // The heartbeat interval setting drives the h2 keepalive cadence; the + // lower bound stays above the gateway's keepalive enforcement MinTime. + let keepalive_interval = Duration::from_secs(config.heartbeat_interval.clamp(10, 60)); + let endpoint = build_endpoint(&grpc_url, keepalive_interval)?; let channel = endpoint.connect_lazy(); let mut client = proto::agent_gateway_client::AgentGatewayClient::new(channel) @@ -782,8 +785,8 @@ impl GatewayController { } self.spawn_tunnel_probes(None, false); - let timeout_seconds = i64::try_from(config.heartbeat_interval.max(5)).unwrap_or(30) * 3; - + // Dead links are detected by the transport-level HTTP/2 keepalive + // configured on the endpoint and surface as receive errors here. loop { tokio::select! { changed = config_rx.changed() => { @@ -795,15 +798,11 @@ impl GatewayController { return Ok(()); } } - message = tokio::time::timeout( - Duration::from_secs(u64::try_from(timeout_seconds.max(5)).unwrap_or(15)), - inbound.message(), - ) => { + message = inbound.message() => { match message { - Err(_) => return Err("gateway heartbeat timed out".to_string()), - Ok(Err(err)) => return Err(format!("gateway stream receive failed: {err}")), - Ok(Ok(None)) => return Err("gateway stream closed".to_string()), - Ok(Ok(Some(envelope))) => { + Err(err) => return Err(format!("gateway stream receive failed: {err}")), + Ok(None) => return Err("gateway stream closed".to_string()), + Ok(Some(envelope)) => { self.touch_heartbeat(); self.handle_gateway_envelope(envelope).await?; } @@ -1016,14 +1015,19 @@ impl GatewayController { match envelope.payload { Some(proto::gateway_envelope::Payload::Ping(ping)) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::Pong(proto::PongResponse { - timestamp: ping.timestamp, - })), - }) - .await + // Never block the receive loop on outbound saturation: the + // gateway counts any inbound envelope as liveness, so a pong + // dropped under load is harmless. + if let Ok(sender) = self.current_outbound_sender() { + let _ = sender.try_send(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::Pong(proto::PongResponse { + timestamp: ping.timestamp, + })), + }); + } + Ok(()) } Some(proto::gateway_envelope::Payload::TunnelState(snapshot)) => { self.handle_tunnel_state_snapshot(snapshot); @@ -4951,7 +4955,8 @@ mod tests { #[test] fn build_https_gateway_endpoint_initializes_tls_provider() { - build_endpoint("https://agent.cnweb.org:443").expect("build https gateway endpoint"); + build_endpoint("https://agent.cnweb.org:443", Duration::from_secs(30)) + .expect("build https gateway endpoint"); } #[test] @@ -5625,11 +5630,16 @@ fn is_h2_protocol_error(message: &str) -> bool { normalized.contains("h2 protocol error") || normalized.contains("http2 error") } -fn build_endpoint(grpc_url: &str) -> Result { +fn build_endpoint(grpc_url: &str, keepalive_interval: Duration) -> Result { + // HTTP/2 keepalive owns dead-link detection: PING frames bypass stream + // flow control, so congestion or streaming never delays them. let endpoint = Endpoint::from_shared(grpc_url.to_string()) .map_err(|e| format!("invalid gateway endpoint: {e}"))? .connect_timeout(Duration::from_secs(10)) - .tcp_keepalive(Some(Duration::from_secs(30))); + .tcp_keepalive(Some(Duration::from_secs(30))) + .http2_keep_alive_interval(keepalive_interval) + .keep_alive_timeout(Duration::from_secs(15)) + .keep_alive_while_idle(true); if grpc_url.starts_with("https://") { ensure_rustls_crypto_provider(); From 51c7d519395d749fed550c1445febb4f64e551b9 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 08:13:24 +0800 Subject: [PATCH 35/64] docs(gateway): document WebSocket chat streaming flow --- docs/README.md | 2 +- docs/architecture/gateway.md | 23 +++++++++++------------ docs/architecture/overview.md | 10 +++++----- docs/architecture/protocols.md | 18 +++++++++--------- docs/architecture/webui.md | 10 +++++----- docs/reference/source-map.md | 3 ++- 6 files changed, 33 insertions(+), 33 deletions(-) diff --git a/docs/README.md b/docs/README.md index 602ba363..c6a5750e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ LiveAgent 是一个以桌面端为本地执行核心的 Agent 应用:GUI 负 | [architecture/overview.md](architecture/overview.md) | 系统总览、进程边界、数据流、持久化地图 | 新接手项目者 | | [architecture/gui.md](architecture/gui.md) | 桌面 GUI、Tauri commands/services/runtime、设置与本地执行 | 前端与桌面端开发 | | [architecture/gateway.md](architecture/gateway.md) | Go Gateway 的 HTTP/gRPC/WebSocket、Session Manager、缓冲与认证 | Gateway 开发与排障 | -| [architecture/webui.md](architecture/webui.md) | 浏览器 WebUI、socket 客户端、SharedWorker、状态与安全边界 | WebUI 开发 | +| [architecture/webui.md](architecture/webui.md) | 浏览器 WebUI、socket 客户端、会话流订阅、状态与安全边界 | WebUI 开发 | | [architecture/protocols.md](architecture/protocols.md) | GUI 与 Gateway、WebUI 与 Gateway 的协议合同 | 联调与协议改造 | | [features/chat-runtime.md](features/chat-runtime.md) | 对话运行时、模型层、流式、压缩、hooks、上传与重发 | Chat 功能开发 | | [features/tools.md](features/tools.md) | builtin tools、MCP 动态工具、delegate/subagent、工具执行边界 | 工具系统开发 | diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index 27d8339e..29edc712 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -8,9 +8,9 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 |---|---|---| | Desktop Agent -> Gateway | gRPC `AgentGateway.AgentConnect` 双向流 | 桌面端注册在线 session,接收 WebUI 请求,返回 chat/history/settings/memory/skills 等响应与事件。 | | Desktop Agent -> Gateway | gRPC `AgentGateway.AgentTerminalConnect` 双向流 | 专用终端字节流,承载 attach snapshot、input、resize、output,不与普通控制面共享队列。 | -| WebUI -> Gateway | WebSocket `/ws` | 浏览器端发起 history、settings、skills、memory、cron 等非 Chat request,并订阅同步广播。 | +| WebUI -> Gateway | WebSocket `/ws` | 浏览器端发起 chat(command/subscribe)、history、settings、skills、memory、cron 等 request,并订阅 `chat.event` 与同步广播。 | | WebUI -> Gateway | WebSocket `/ws/terminal` | 专用二进制终端流;输入 fire-and-forget,输出按 session interest 推送轻量 bytes frame。 | -| WebUI -> Gateway | HTTP `/api/*` | Chat command/SSE、状态检查、文件上传、公网分享页、图片代理、静态资源。 | +| WebUI -> Gateway | HTTP `/api/*` | 状态检查、文件上传、公网分享页、图片代理、静态资源。 | ## 入口与服务启动 @@ -23,7 +23,7 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 | `internal/auth/http_middleware.go` | HTTP API token 校验。 | | `internal/server/grpc.go` | `AgentGateway` gRPC 服务实现。 | | `internal/server/http.go` | HTTP mux、WebSocket、API、静态 WebUI 与 public share route。 | -| `internal/server/http_chat.go`、`chat_commands.go`、`chat_payloads.go` | Chat Command HTTP 入口、SSE replay、Gateway -> Desktop `ChatCommandRequest` 下发与事件 payload 映射。 | +| `internal/server/websocket_chat_handlers.go`、`websocket_chat_queue_handlers.go`、`chat_commands.go` | WS `chat.command`/`chat.subscribe`/`chat.cancel`/chat queue handler、订阅 replay 与实时 forward、Gateway -> Desktop `ChatCommandRequest` 下发与事件 payload 映射。 | | `internal/server/websocket.go` | WebUI WebSocket 连接生命周期、鉴权、订阅 forwarder。 | | `internal/server/websocket_routes.go` | WebSocket request type 到 domain handler 的路由表。 | | `internal/server/websocket_*_handlers.go` | fs/history/settings/terminal/git/skills/memory/cron/provider 等非 Chat domain handler。 | @@ -42,14 +42,13 @@ Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌 |---|---|---| | `GET /ws` | token | WebUI 主 WebSocket 协议。 | | `GET /ws/terminal` | token | WebUI 终端专用 WebSocket;首帧 JSON auth,后续使用 `version + kind + headerLength + JSON header + bytes` 的二进制 frame。 | -| `GET /api/chat/events` | token + Origin | WebUI 使用 fetch SSE 订阅/恢复 Chat 事件。 | | `GET /api/status` | token | Gateway 当前 Agent 在线状态。 | | `POST /api/files/import` | token | WebUI 上传可读文件,Gateway 转发给桌面端导入 workspace uploads。 | | `GET /api/public/history-shares/{token}` | public token | 公开只读历史分享数据。 | | `GET /image-proxy` | 视配置/实现而定 | 图片代理,带 URL 安全校验。 | | `/` | 无或按静态资源策略 | 嵌入/构建后的 WebUI 静态资源与 SPA fallback。 | -Chat HTTP API 是严格新协议:command 必须是 `{ type, payload }` envelope,事件订阅使用 `run_id` 或 `conversation_id`,不接受旧 `command` 字段、顶层裸 payload 或 `request_id` 查询别名。 +Chat 走 `/ws` 且是严格新协议:`chat.command` 必须是 `{ type, payload }` envelope,`chat.subscribe` 按 `conversation_id` 订阅,不接受旧 `command` 字段、顶层裸 payload 或 `request_id` 别名。旧 HTTP SSE 路由 `GET /api/chat/events` 已下线。 ## gRPC 服务 @@ -79,10 +78,10 @@ Chat HTTP API 是严格新协议:command 必须是 `{ type, payload }` envelop | SQLite WAL | Gateway 启动时打开 `LIVEAGENT_GATEWAY_CHAT_EVENT_STORE` 指定的 SQLite 文件,启用 WAL、`busy_timeout` 和小事务追加;事件先提交到内存 fan-out,再用不可变快照持久化,避免 SQLite I/O 占用 chat 内存锁。 | | `chat_runs` | 保存 run 元数据、最新 seq、state、error_code、done、workdir、conversation/client request 索引字段。 | | `chat_command_dedup` | 以 `client_request_id` 为主键,重复 command 返回原 run,不重复追加用户消息。 | -| `chat_events` | append-only 事件日志,按 `(run_id, seq)` 唯一;同一 `conversation_id` 内的 `seq` 单调递增,SSE replay 优先读它。`chat.edit_resend` 的 `conversation.rebased` 与新 `user.message.appended` 使用同一批 SQLite 事务写入。 | +| `chat_events` | append-only 事件日志,按 `(run_id, seq)` 唯一;同一 `conversation_id` 内的 `seq` 单调递增,`chat.subscribe` replay 优先读它。`chat.edit_resend` 的 `conversation.rebased` 与新 `user.message.appended` 使用同一批 SQLite 事务写入。 | | `maxBufferedChatRunEvents` | 单次 replay / 进程内最近事件窗口最多 50000 条,避免无界内存和超大 replay。 | | 重启恢复 | Gateway 启动时把未完成 run 标为 `failed/gateway_restarted` 并追加终态事件,避免客户端永久等待失联运行。 | -| `Seq` | WebUI 可用 `afterSeq`/`Last-Event-ID` 从 SQLite 事件日志补收漏掉的事件;新 run 会从同 conversation 已有最大 seq 后继续,避免编辑重发/二次提交时恢复游标撞序。 | +| `Seq` | WebUI 在 `chat.subscribe` 携带 `after_seq` 游标,从 SQLite 事件日志补收漏掉的事件;新 run 会从同 conversation 已有最大 seq 后继续,避免编辑重发/二次提交时恢复游标撞序。 | ## WebSocket 协议角色 @@ -90,8 +89,8 @@ Chat HTTP API 是严格新协议:command 必须是 `{ type, payload }` envelop |---|---| | request/response | WebUI 发带 id 的 request,Gateway 返回同 id response 或 error。 | | broadcast | Gateway 主动推送 `status`、`history.event`、`settings.event`、`terminal`、`sftp` 等非 Chat 同步事件。 | -| chat | 不再走 WebSocket request;提交/编辑/取消走 HTTP command,流式事件走 fetch SSE。 | -| terminal stream | 不走普通 `/ws` request;attach/input/resize/detach 走 `/ws/terminal` 二进制 frame,SharedWorker 为同一 token 复用一条 terminal stream 并按 session fan-out。 | +| chat | 提交/编辑/取消走 WS `chat.command`;流式事件走按 conversation 持久订阅 `chat.subscribe`,经 `chat.event` 推送(订阅缓冲溢出时发 `chat.subscription_reset` 提示客户端按游标重订阅)。 | +| terminal stream | 不走普通 `/ws` request;attach/input/resize/detach 走 `/ws/terminal` 二进制 frame,页面侧 `BrowserGatewayTerminalStreamClient` 为同一 token 复用一条 terminal stream 并按 session fan-out。 | WebSocket server 的实现按三层组织:`websocket.go` 管连接生命周期和订阅 forwarder;`websocket_routes.go` 管 request 路由;`websocket_*_handlers.go` 管 domain handler。handler 只做 payload 校验、调用 Gateway/Desktop service、组装 WebUI 响应。连接内的可变状态不再直接铺在 `websocketConnection` 上,而是通过 terminal tracker 管理。 @@ -103,7 +102,7 @@ Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、 |---|---| | 认证 | HTTP API 与 WebSocket 通过 token;gRPC 通过 interceptor 校验 token。 | | Chat command 防护 | Chat 命令仅经认证后的 WebSocket `chat.command` 提交(连接级 token + Origin 校验,2 MiB payload 上限)。 | -| Chat SSE 防护 | `GET /api/chat/events` 要求 token、Origin 校验、固定窗口 rate limit;replay 单次最多返回 `maxBufferedChatRunEvents` 条。 | +| Chat 订阅防护 | `chat.subscribe` 仅在认证后的 `/ws` 连接上可用;replay 单次最多返回 `maxBufferedChatRunEvents` 条。 | | Provider API key | 普通 settings sync 不应携带真实 key;WebUI 只接收 presence/redacted 字段。 | | 文件访问 | WebUI 上传只把 bytes 交给桌面端导入,Gateway 不直接落地为任意本地路径。 | | 工具执行 | Gateway 不运行 Shell、FS、MCP、Memory mutation 等高权限工具,只转发请求到桌面端。 | @@ -115,10 +114,10 @@ Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、 | 失败 | 表现 | 设计处理 | |---|---|---| | Desktop offline | WebUI 请求返回 agent offline 或状态 offline | `session.Manager` 检测当前 session,WebUI 展示离线/不可用状态。 | -| WebSocket 断开 | WebUI 自动重连非 Chat 同步;Chat SSE 可按 seq 恢复 | `GatewayWebSocketClient`、SharedWorker 与 fetch SSE client 分别管理重连,Gateway 从 SQLite `chat_events` 补发 seq event。 | +| WebSocket 断开 | WebUI 自动重连;Chat 订阅按 `after_seq` 游标恢复 | `GatewayWebSocketClient` 统一管理重连并在重连后重发 `chat.subscribe`,Gateway 从 SQLite `chat_events` 补发 seq event。 | | gRPC stream 断开 | Agent session close,pending stream 结束 | 桌面端 remote auto reconnect 可重新建立 session;重连后桌面端 republish chat run 台账(active `started` + 未确认终态控制事件),网关幂等收养。 | | Chat run 终态信号丢失 | run 已在桌面端结束但网关 activity 未清除 | 桌面端 `ChatRunLedger` 先记账再发送,5s sweeper 重发未送达终态;心跳 `RuntimeStatusEvent.active_runs/finished_runs` 驱动网关对账:finished 报告按真实终态收养,active 报告逐 run 续命,缺席且无事件/续命超过 `runReportLostTimeout`(15s)判 `failed/desktop_run_lost`。 | | Chat run 卡死兜底 | 桌面端不再上报某 run | 在线走 `staleRunTimeout`(10min,逐 run 续命,单会话忙碌不屏蔽他会话);离线走 `offlineRunTimeout`(30min)判 `failed/agent_offline`。 | | Chat run 重复提交 | 同一 clientRequestId 重复 | SQLite `chat_command_dedup` 去重,内存索引只是热缓存。 | -| Chat command 未进入运行态 | 事件流只到 accepted/delivered 后不继续 | HTTP command path 使用 `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` 与 `LIVEAGENT_GATEWAY_CHAT_RENDER_START_TIMEOUT` watchdog 写入 `run.failed`,避免 WebUI 无限等待。 | +| Chat command 未进入运行态 | 事件流只到 accepted/delivered 后不继续 | command path 使用 `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` 与 `LIVEAGENT_GATEWAY_CHAT_RENDER_START_TIMEOUT` watchdog 写入 `run.failed`,避免 WebUI 无限等待。 | | 服务退出 | Ctrl+C 后 HTTP/gRPC shutdown | `cmd/gateway/main.go` 和 `shutdown.go` 控制 graceful/force stop。 | diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 84f70bc1..4bec54b3 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -8,7 +8,7 @@ | 桌面后端 | `crates/agent-gui/src-tauri/src` | Tauri 2、Rust、SQLite、tokio | 系统命令、文件/Shell/进程、MCP runtime、MemoryStore、CronManager、GatewayController、代理服务。 | | Agent 运行时 | `crates/agent-gui/src/lib/chat`、`crates/agent-gui/src/pages/chat`、`crates/agent-gui/src/lib/tools` | TypeScript、`@mariozechner/pi-ai` | 构造上下文、请求模型、执行工具、压缩上下文、持久化历史、发布 Gateway 事件。 | | Gateway | `crates/agent-gateway` | Go、gRPC、net/http、WebSocket | 桌面 Agent 与浏览器 WebUI 的远程中继、认证、会话管理、恢复缓冲、静态 WebUI 和分享页。 | -| WebUI | `crates/agent-gateway/web` | React、TypeScript、Vite、WebSocket、SharedWorker | 远程浏览器端 Chat/Settings/Hub 壳层,通过 Gateway 操作本地 Agent。 | +| WebUI | `crates/agent-gateway/web` | React、TypeScript、Vite、WebSocket | 远程浏览器端 Chat/Settings/Hub 壳层,通过 Gateway 操作本地 Agent。 | | 资料与策略 | `doc/`、`docs/` | Markdown、SQL | 历史设计、专项计划、当前架构索引。 | ## 进程边界 @@ -25,7 +25,7 @@ | 数据流 | 步骤 | 关键路径 | |---|---|---| | 本地桌面对话 | GUI composer 提交消息,`ChatPage` 构造上下文,按 execution mode 进入 text 或 agent turn,模型流式返回,必要时执行 builtin tools,最后写入历史 SQLite。 | `src/pages/ChatPage.tsx`、`src/pages/chat/runTextConversationTurn.ts`、`src/pages/chat/runAgentConversationTurn.ts`、`src/lib/providers/llm.ts`、`src/lib/tools/builtinRegistry.ts` | -| WebUI 远程对话 | WebUI 发 `chat.submit`/`chat.edit_resend` command 到 Gateway,Gateway 立即 accepted 并通过 gRPC `AgentConnect` 下发 `ChatCommandRequest` 到桌面端;桌面端本地运行并持续回传 `ChatEvent`/`ChatControlEvent`,Gateway 按 seq 通过 fetch SSE 广播给 WebUI。 | `web/src/lib/gatewaySocket.ts`、`internal/server/http_chat.go`、`internal/server/chat_commands.go`、`proto/v1/gateway.proto`、`src-tauri/src/services/gateway.rs` | +| WebUI 远程对话 | WebUI 经 `/ws` 发 `chat.command`(`chat.submit`/`chat.edit_resend`)到 Gateway,Gateway 立即 accepted 并通过 gRPC `AgentConnect` 下发 `ChatCommandRequest` 到桌面端;桌面端本地运行并持续回传 `ChatEvent`/`ChatControlEvent`,Gateway 按 seq 经会话订阅(`chat.subscribe`/`chat.event`)推送给 WebUI。 | `web/src/lib/gatewaySocket.ts`、`internal/server/websocket_chat_handlers.go`、`internal/server/chat_commands.go`、`proto/v1/gateway.proto`、`src-tauri/src/services/gateway.rs` | | 设置同步 | GUI load/save 设置到本地 SQLite,同时发布脱敏 settings snapshot 到 Gateway;WebUI 读取/更新 settings 时走 Gateway,普通 sync 不带真实 provider API key。 | `src/lib/settings/*`、`src-tauri/src/commands/settings.rs`、`src/lib/settings/sync.ts`、`web/src/lib/settings/sync.ts` | | 历史同步 | GUI 持久化 `chatHistory` 和 `chatHistorySegment`,操作后发布 history sync;Gateway 转发给 WebUI,WebUI 刷新列表或详情缓存。 | `src-tauri/src/commands/chat_history.rs`、`src-tauri/src/services/gateway.rs`、`web/src/lib/historySync.ts` | | 上传文件 | GUI 直接通过 Tauri 导入工作区 uploads;WebUI 走 Gateway HTTP multipart,Gateway 将 bytes 转成 gRPC request,桌面端导入本地工作区后返回文件引用。 | `src-tauri/src/commands/system.rs`、`internal/handler/upload.go`、`web/src/lib/uploadReadableFiles.ts` | @@ -40,7 +40,7 @@ | Memory 文件 | `~/.liveagent/memory/...` | Tauri Rust | Markdown 是记忆事实源,按 global/project/daily 等目录组织。 | | Memory 索引 | `~/.liveagent/memory/memory-index.sqlite3` | Tauri Rust | `memory_meta`、`memory_fts`、`memory_fts_tri`、audit log。 | | Skills root | `~/.liveagent/skills` | Tauri Rust + GUI | 用户可安装/创建/打包的 Skills runtime root。 | -| Gateway Chat Event Store | `LIVEAGENT_GATEWAY_CHAT_EVENT_STORE` 或用户配置目录 `LiveAgent/gateway-chat.sqlite3` | Go Gateway | `chat_runs`、`chat_command_dedup`、`chat_events`;支持 Chat Command 幂等和 SSE replay。 | +| Gateway Chat Event Store | `LIVEAGENT_GATEWAY_CHAT_EVENT_STORE` 或用户配置目录 `LiveAgent/gateway-chat.sqlite3` | Go Gateway | `chat_runs`、`chat_command_dedup`、`chat_events`;支持 Chat Command 幂等和 `chat.subscribe` replay。 | | WebUI 本地缓存 | Browser localStorage | WebUI | token、脱敏 settings snapshot、UI 偏好与运行态辅助缓存。 | ## 设计原则 @@ -50,7 +50,7 @@ | 桌面端是真相源 | 工具执行、历史、设置、记忆、Cron prompt、MCP runtime 都在 Tauri/GUI 侧落地。 | | Gateway 不越权 | Gateway 不直接访问用户文件系统,不保存真实 provider key;只维护会话、中继和 Chat 事件日志。 | | GUI/WebUI 可用性对齐 | WebUI 复制/镜像了大量 GUI 组件与 settings 子树,但用 shims 和 Gateway client 替换 Tauri API。 | -| 长对话可恢复 | 历史使用 segment + summary checkpoint,Gateway chat run 使用 SQLite seq event log,WebUI 可通过 SSE `after_seq`/`Last-Event-ID` 恢复。 | +| 长对话可恢复 | 历史使用 segment + summary checkpoint,Gateway chat run 使用 SQLite seq event log,WebUI 可通过 `chat.subscribe` 携带 `after_seq` 游标恢复。 | | 功能域清晰 | Chat runtime、Tools、Memory、Skills、MCP、Cron、Hooks、History 都有独立源码区域与后端命令。 | ## 高层模块图 @@ -58,7 +58,7 @@ ```text Browser WebUI ├─ React App / Settings / Hubs / GatewayTranscript - ├─ GatewayWebSocketClient or SharedWorker + ├─ GatewayWebSocketClient (chat command/subscribe + sync) └─ HTTP upload / public share │ ▼ diff --git a/docs/architecture/protocols.md b/docs/architecture/protocols.md index 0270517d..4fc06506 100644 --- a/docs/architecture/protocols.md +++ b/docs/architecture/protocols.md @@ -9,8 +9,8 @@ | gRPC stream | `AgentGateway.AgentTerminalConnect` | Desktop <-> Gateway | 终端专用字节流,承载 `TerminalStreamFrame`,避免 terminal IO 与 chat/settings/history 队头阻塞。 | | WebSocket | `GET /ws` | WebUI <-> Gateway | WebUI 非 Chat 请求/响应、状态广播、history/settings 等同步。 | | WebSocket | `GET /ws/terminal` | WebUI <-> Gateway | WebUI 终端专用二进制流;JSON 控制帧只用于 auth/error,热路径为 bytes frame。 | -| WS Chat Command | WebSocket `chat.command` | WebUI -> Gateway -> Desktop | Chat 提交、编辑重发、取消;命令先被 Gateway accepted,再异步下发桌面端。 | -| HTTP Chat Events | `GET /api/chat/events` | Gateway -> WebUI | fetch-based SSE;按 `conversation_id` 与 `after_seq` 恢复事件流。 | +| WS Chat Command | WebSocket `chat.command` | WebUI -> Gateway -> Desktop | Chat 提交、编辑重发、取消;命令先被 Gateway accepted,再异步下发桌面端;pre-stream 结果经 `chat.command_update` 推送。 | +| WS Chat Stream | WebSocket `chat.subscribe` / `chat.event` | Gateway -> WebUI | 按 conversation 持久订阅(跨 run);订阅响应按 `after_seq`/`stream_epoch` 补发缺失事件,之后 `chat.event` 实时推送,`chat.unsubscribe` 结束。 | | HTTP API | `/api/status` | WebUI -> Gateway | 查询 Agent 在线状态。 | | HTTP upload | `/api/files/import` | WebUI -> Gateway -> Desktop | 上传可读文件并导入桌面 workspace。 | | Public HTTP | `/api/public/history-shares/{token}` | Browser -> Gateway | 公开只读历史分享。 | @@ -30,13 +30,13 @@ |---|---|---|---| | 提交 | WS `chat.command`,`type=chat.submit` | `ChatCommandRequest{type=chat.submit}` | 响应携带 `run_id`/`accepted_seq`;用户消息与 token 事件经会话订阅 `chat.event` 推送。 | | 编辑重发 | WS `chat.command`,`type=chat.edit_resend` | `ChatCommandRequest{type=chat.edit_resend, base_message_ref}` | Gateway 先发布 `rebased` 与新用户消息事件,桌面端随后原子截断并运行新 turn。 | -| 恢复 | `GET /api/chat/events?conversation_id=&after_seq=` | 无 | WebUI 先用 history snapshot/projection hydrate,再由 Gateway 从 SQLite `chat_events` 按 conversation seq 跨 run 补发缺失事件;内存缓存同样按 conversation 汇总最近 run 事件并负责实时 fan-out。 | +| 恢复 | WS `chat.subscribe`,`{conversation_id, after_seq, stream_epoch}` | 无 | WebUI 先用 history snapshot/projection hydrate,订阅响应由 Gateway 从 SQLite `chat_events` 按 conversation seq 跨 run 补发缺失事件(`events`/`latest_seq`/`reset`);内存缓存同样按 conversation 汇总最近 run 事件并负责实时 fan-out;订阅缓冲溢出时 Gateway 发 `chat.subscription_reset`,客户端按游标重新订阅补齐。 | | 取消 | WS `chat.command`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | Gateway 置 `cancelling` 状态,桌面端真实终态优先,超时由 watchdog 兜底 `run_finished(cancelled)`。 | | 完成 | 无 | 无 | `ChatEvent.type=DONE` 映射为 `run.completed` 终态。 | -桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。旧 WebSocket Chat 路由已下线。 +桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。旧 HTTP SSE Chat 路由(`GET /api/chat/events`)已下线。 -HTTP Chat command 只接受 `{ type, payload }` envelope;`command`、顶层裸 payload、SSE `request_id` 别名都不再作为兼容输入。 +Chat command(WS `chat.command`)只接受 `{ type, payload }` envelope;`command`、顶层裸 payload、`request_id` 别名都不再作为兼容输入。 ## Settings 同步 @@ -52,7 +52,7 @@ HTTP Chat command 只接受 `{ type, payload }` envelope;`command`、顶层裸 | 操作 | 语义 | |---|---| -| `history.list` | 分页读取 conversation summary,用于 sidebar;`running_conversations` 会附带 `run_id`、`first_seq`、`latest_seq`、`run_epoch`,让 WebUI 观察远程运行时从当前 run 起点订阅 SSE。 | +| `history.list` | 分页读取 conversation summary,用于 sidebar;`running_conversations` 会附带 `run_id`、`first_seq`、`latest_seq`、`run_epoch`,让 WebUI 观察远程运行时从当前 run 起点发起 `chat.subscribe`。 | | `history.get` | 读取 conversation detail;支持 `max_messages` 返回 tail window。 | | `history.rename` | 修改标题并广播 upsert event。 | | `history.pin` | 修改置顶状态并保持排序。 | @@ -98,7 +98,7 @@ Gateway 不再通过错误文案推断 public share 状态,错误语义由桌 | Input | input frame 为 fire-and-forget bytes;不返回 session metadata,不进入普通 request pending map。 | | Resize | resize frame 只发送最新 cols/rows;不返回 session metadata。 | | Output | output frame 只携带轻量 session id、project key、offset 与 bytes;React session state 不因 output 更新。 | -| SharedWorker | 每个 token 维护一条 terminal stream,上游按 session 复用 attach;多个页面共享 output,只有当前 attached stream 的输入写入上游。 | +| 页面 stream client | `BrowserGatewayTerminalStreamClient` 每页按 token 维护一条 terminal stream,上游按 session 复用 attach;同 session 的多个 handle 共享 output。 | Gateway 的 `/ws/terminal` 连接只维护本连接内的 session attach 集合;detach 只影响这条 terminal stream 的输出投递,不改变桌面端 terminal registry。 @@ -116,7 +116,7 @@ Gateway 的 `/ws/terminal` 连接只维护本连接内的 session attach 集合 |---|---|---| | `clientRequestId` | WebUI Chat Command -> Gateway session manager | 避免重复提交导致两个真实 chat run。 | | `conversationId` -> run index | Gateway session manager | 当前会话刷新/切换后可定位正在运行的事件流。 | -| `Seq` | Gateway SQLite `chat_events` / SSE event id | 同 conversation 内单调递增;断线后从 `afterSeq` 或 `Last-Event-ID` 补发缺失事件。 | +| `Seq` | Gateway SQLite `chat_events` / `chat.event` payload | 同 conversation 内单调递增;断线后 `chat.subscribe` 携带 `after_seq` 游标补发缺失事件。 | | done retention | Gateway session manager | 已结束 run 短时间保留,支持刷新后看到终态。 | | local running ids | WebUI App | 避免正在运行会话被错误切换或误删。 | @@ -127,5 +127,5 @@ Gateway 的 `/ws/terminal` 连接只维护本连接内的 session attach 集合 | 新增 Gateway request | 同步 `proto/v1/gateway.proto`、Go server、Tauri gateway bridge、WebUI client method。 | | 新增 settings 字段 | GUI settings normalize/storage、Rust settings save/load、Gateway redaction whitelist、WebUI settings copy 都要同步。 | | 新增 history 字段 | Rust summary model、proto `ConversationSummary`、Gateway websocket payload、GUI/WebUI sidebar render 都要同步。 | -| 新增 chat event | Desktop event publisher、proto enum、Gateway SSE encoder、WebUI event reducer/transcript 都要同步。 | +| 新增 chat event | Desktop event publisher、proto enum、Gateway 事件规范化与 `chat.event` payload、WebUI event reducer/transcript 都要同步。 | | 涉及 secret | 默认不进普通 sync,必须设计单向或显式更新通道。 | diff --git a/docs/architecture/webui.md b/docs/architecture/webui.md index 16e03e01..871f6a4a 100644 --- a/docs/architecture/webui.md +++ b/docs/architecture/webui.md @@ -9,8 +9,8 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | 模块 | 路径 | 职责 | |---|---|---| | App shell | `crates/agent-gateway/web/src/App.tsx` | 登录、socket 生命周期、settings/history/chat 状态、页面切换、composer/transcript。 | -| Socket client | `web/src/lib/gatewaySocket.ts` | WebSocket 请求/响应、广播监听、Chat Command HTTP 调用、fetch SSE async iterator、恢复与错误处理。 | -| SharedWorker | `web/src/lib/gatewaySocket.worker.ts` | 在支持环境中跨 tab 复用非 Chat WebSocket 连接与请求转发;Chat 流由页面侧 command/SSE 处理。 | +| Socket client | `web/src/lib/gatewaySocket.ts` | WebSocket 请求/响应、广播监听、Chat command 与会话流订阅、恢复与错误处理。 | +| Conversation stream | `web/src/lib/chat/stream/conversationStreamClient.ts` | 按会话持久订阅注册表:维护 `after_seq`/`stream_epoch` 游标、重连自动重订阅、gap resync。 | | Gateway types | `web/src/lib/gatewayTypes.ts` | WebUI 侧协议类型。 | | Settings storage | `web/src/lib/webSettings.ts`、`web/src/lib/settings/*` | 浏览器本地设置缓存、脱敏 provider snapshot、settings sync payload。 | | History sync | `web/src/lib/historySync.ts`、`web/src/lib/historyParser.ts` | 历史列表/详情同步,大历史 worker 解析。 | @@ -27,7 +27,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | 状态订阅 | 订阅 Gateway status,展示 Desktop Agent online/offline。 | | 请求响应 | 所有 request 带 id,Gateway 用同 id 返回 payload 或 error。 | | Chat 流 | 提交/编辑/取消走 WebSocket `chat.command`;流式输出走按会话持久订阅 `chat.subscribe`(`chat.event` 推送,seq 续传)。 | -| 断线恢复 | WebSocket client 处理普通同步重连;Chat SSE 在 history snapshot hydrate 后按同 conversation 单调递增的 `after_seq` 或 `Last-Event-ID` 跨 run 补齐缺失事件;观察正在运行的远程会话时优先使用 `history.list.running_conversations[].first_seq - 1` 作为当前 run 的订阅起点。 | +| 断线恢复 | WebSocket client 处理普通同步重连;Chat 订阅在 history snapshot hydrate 后重发 `chat.subscribe`,按同 conversation 单调递增的 `after_seq`(配合 `stream_epoch`)跨 run 补齐缺失事件;观察正在运行的远程会话时优先使用 `history.list.running_conversations[].first_seq - 1` 作为当前 run 的订阅起点。 | ## WebUI 本地状态 @@ -37,7 +37,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | `settings` | Gateway `settings.get`、`settings.event`、local redacted cache | 渲染 Settings、Chat mode、model list、MCP/Skills/Memory 等。 | | `historyItems` | Gateway `history.list`、`history.event` | 侧边栏、pin/share/delete/rename。 | | `visible transcript` | `history.get`、live chat events、本地 draft | 当前会话内容。 | -| `live stream cache` | Chat Command 返回、SSE event replay | 保持运行中会话流式可见。 | +| `live stream cache` | Chat Command 返回、`chat.subscribe` replay 与 `chat.event` 推送 | 保持运行中会话流式可见。 | | `draft conversation` | WebUI 本地临时 id | 新对话提交后迁移到桌面端返回的真实 conversationId。 | | upload cache | HTTP upload response | 将导入后的 `ChatUploadedFile` 附到下一次 Chat Command。 | @@ -56,7 +56,7 @@ WebUI 是 Gateway 承载的浏览器端操作台。它复用/复制了大量 GUI | 方法族 | 示例 | |---|---| | Auth/status | `status.get`、socket auth/unauthorized handling | -| Chat | HTTP `chat.submit`、`chat.edit_resend`、`chat.cancel`;fetch SSE `/api/chat/events` | +| Chat | WS `chat.command`(`chat.submit`/`chat.edit_resend`/`chat.cancel`)、`chat.subscribe`/`chat.unsubscribe`、`chat.activities`;事件经 `chat.event`/`chat.command_update` 推送 | | History | `history.list`、`history.get`、`history.rename`、`history.pin`、`history.share.get`、`history.share.set`、`history.delete` | | Settings | `settings.get`、`settings.update` | | Providers | `providers.list`、provider model scan related request | diff --git a/docs/reference/source-map.md b/docs/reference/source-map.md index 572ee6a0..e85b2431 100644 --- a/docs/reference/source-map.md +++ b/docs/reference/source-map.md @@ -94,7 +94,8 @@ | WebUI entry | `crates/agent-gateway/web/src/main.tsx` | | App shell | `crates/agent-gateway/web/src/App.tsx` | | Gateway socket | `crates/agent-gateway/web/src/lib/gatewaySocket.ts` | -| SharedWorker | `crates/agent-gateway/web/src/lib/gatewaySocket.worker.ts` | +| Conversation stream client | `crates/agent-gateway/web/src/lib/chat/stream/conversationStreamClient.ts` | +| Terminal stream client | `crates/agent-gateway/web/src/lib/terminal/gatewayTerminalStreamClient.ts` | | Gateway types | `crates/agent-gateway/web/src/lib/gatewayTypes.ts` | | Web settings | `crates/agent-gateway/web/src/lib/webSettings.ts`、`web/src/lib/settings/*` | | History sync/parser | `crates/agent-gateway/web/src/lib/historySync.ts`、`historyParser.ts` | From 29f894c0bb25adeda71fd54262962eb62e3aeab2 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 09:51:06 +0800 Subject: [PATCH 36/64] refactor(gui): split Tauri services into modules --- .../src-tauri/src/runtime/terminal.rs | 4901 -------------- .../src-tauri/src/runtime/terminal/events.rs | 162 + .../src-tauri/src/runtime/terminal/mod.rs | 96 + .../src-tauri/src/runtime/terminal/output.rs | 327 + .../src/runtime/terminal/registry.rs | 654 ++ .../src-tauri/src/runtime/terminal/shell.rs | 294 + .../src/runtime/terminal/ssh_auth.rs | 503 ++ .../src/runtime/terminal/ssh_channel.rs | 201 + .../src/runtime/terminal/ssh_connect.rs | 463 ++ .../src-tauri/src/runtime/terminal/ssh_io.rs | 177 + .../src/runtime/terminal/ssh_session.rs | 779 +++ .../src-tauri/src/runtime/terminal/state.rs | 192 + .../src-tauri/src/runtime/terminal/tabs.rs | 231 + .../src-tauri/src/runtime/terminal/tests.rs | 757 +++ .../src-tauri/src/runtime/terminal/types.rs | 210 + .../src-tauri/src/runtime/terminal/util.rs | 49 + .../src-tauri/src/services/gateway.rs | 5771 ----------------- .../src-tauri/src/services/gateway/chat.rs | 655 ++ .../src/services/gateway/chat_inbox.rs | 942 +++ .../src/services/gateway/connection.rs | 523 ++ .../src/services/gateway/controller.rs | 354 + .../src/services/gateway/envelope_handler.rs | 918 +++ .../src/services/gateway/history_sync.rs | 107 + .../src-tauri/src/services/gateway/mod.rs | 99 + .../src/services/gateway/settings_sync.rs | 167 + .../src-tauri/src/services/gateway/sftp.rs | 259 + .../src/services/gateway/terminal.rs | 851 +++ .../src-tauri/src/services/gateway/tests.rs | 796 +++ .../src-tauri/src/services/gateway/types.rs | 193 + .../src-tauri/src/services/gateway/util.rs | 88 + .../src-tauri/src/services/skills.rs | 3924 ----------- .../src-tauri/src/services/skills/builtin.rs | 211 + .../src-tauri/src/services/skills/clawhub.rs | 223 + .../src-tauri/src/services/skills/create.rs | 110 + .../src-tauri/src/services/skills/install.rs | 241 + .../src-tauri/src/services/skills/jobs.rs | 189 + .../src-tauri/src/services/skills/library.rs | 437 ++ .../src-tauri/src/services/skills/manager.rs | 329 + .../src-tauri/src/services/skills/metadata.rs | 485 ++ .../src-tauri/src/services/skills/mod.rs | 54 + .../src-tauri/src/services/skills/paths.rs | 179 + .../src-tauri/src/services/skills/sources.rs | 437 ++ .../src-tauri/src/services/skills/tests.rs | 638 ++ .../src-tauri/src/services/skills/types.rs | 176 + .../src-tauri/src/services/skills/util.rs | 65 + .../src-tauri/src/services/skills/validate.rs | 335 + 46 files changed, 15156 insertions(+), 14596 deletions(-) delete mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/events.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/output.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/ssh_auth.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/ssh_channel.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/ssh_connect.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/state.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/tabs.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/types.rs create mode 100644 crates/agent-gui/src-tauri/src/runtime/terminal/util.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/gateway.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/chat.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/connection.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/controller.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/mod.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/sftp.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/terminal.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/tests.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/types.rs create mode 100644 crates/agent-gui/src-tauri/src/services/gateway/util.rs delete mode 100644 crates/agent-gui/src-tauri/src/services/skills.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/builtin.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/clawhub.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/create.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/install.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/jobs.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/library.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/manager.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/metadata.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/mod.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/paths.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/sources.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/tests.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/types.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/util.rs create mode 100644 crates/agent-gui/src-tauri/src/services/skills/validate.rs diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal.rs b/crates/agent-gui/src-tauri/src/runtime/terminal.rs deleted file mode 100644 index 776b194a..00000000 --- a/crates/agent-gui/src-tauri/src/runtime/terminal.rs +++ /dev/null @@ -1,4901 +0,0 @@ -use base64::Engine; -use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; -use russh::client; -use russh::keys::agent::client::{AgentClient, AgentStream}; -use russh::keys::agent::AgentIdentity; -use russh::keys::ssh_key::HashAlg; -use russh::keys::{PrivateKeyWithHashAlg, PublicKey, PublicKeyBase64}; -use russh::ChannelMsg; -use russh::MethodKind; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, VecDeque}; -use std::fs; -use std::io::{Read, Write}; -use std::net::{IpAddr, Ipv6Addr}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{mpsc, Arc, Mutex, MutexGuard}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use tauri::{AppHandle, Emitter}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::time::timeout; - -use crate::commands::settings::{ - check_runtime_ssh_known_host, load_runtime_ssh_host, trust_runtime_ssh_known_host, - RuntimeSshHostConfig, RuntimeSshKnownHostKey, RuntimeSshKnownHostStatus, -}; -use crate::runtime::platform::expand_tilde_path; -#[cfg(windows)] -use crate::runtime::process::configure_child_process_group; -use crate::runtime::project_path::{ - project_path_key as normalize_project_path_key, project_path_keys_equal, -}; - -const DEFAULT_ROWS: u16 = 24; -const DEFAULT_COLS: u16 = 80; -const MAX_RING_CHUNKS: usize = 4096; -const MAX_TAIL_BYTES: usize = 256 * 1024; -const SSH_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); -const SSH_RECONNECT_MAX_ATTEMPTS: u8 = 3; -const SSH_RECONNECT_DELAYS: [Duration; 3] = [ - Duration::from_secs(2), - Duration::from_secs(5), - Duration::from_secs(10), -]; -const SSH_RECONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20); -const SSH_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); -const SSH_KEEPALIVE_MAX_MISSES: usize = 3; -const SSH_STATUS_CONNECTED: &str = "connected"; -const SSH_STATUS_RECONNECTING: &str = "reconnecting"; -const SSH_STATUS_DISCONNECTED: &str = "disconnected"; -pub const TERMINAL_EVENT_NAME: &str = "terminal:event"; -pub const TERMINAL_STREAM_EVENT_NAME: &str = "terminal:stream"; -const SSH_EXEC_DEFAULT_MAX_BYTES: usize = 64 * 1024; -const SSH_EXEC_MAX_BYTES: usize = 256 * 1024; -const SSH_EXEC_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); -const SSH_EXEC_MAX_TIMEOUT: Duration = Duration::from_secs(300); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSessionRecord { - pub id: String, - pub project_path_key: String, - pub cwd: String, - pub shell: String, - pub title: String, - pub kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh: Option, - pub pid: Option, - pub cols: u16, - pub rows: u16, - pub created_at: u128, - pub updated_at: u128, - pub finished_at: Option, - pub exit_code: Option, - pub running: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSshMetadata { - pub host_id: String, - pub host_name: String, - pub username: String, - pub host: String, - pub port: u16, - pub auth_type: String, - pub status: String, - pub reconnect_attempt: u8, - pub reconnect_max_attempts: u8, - pub sftp_enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSshPrompt { - pub id: String, - pub kind: String, - pub host_id: String, - pub host_name: String, - pub host: String, - pub port: u16, - pub message: String, - pub fingerprint_sha256: String, - pub key_type: String, - pub answer_echo: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalListResponse { - pub sessions: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSnapshotResponse { - pub session: TerminalSessionRecord, - pub output: String, - pub output_bytes: Vec, - pub truncated: bool, - pub output_start_offset: u64, - pub output_end_offset: u64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSshCreateResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, - pub output: String, - pub output_bytes: Vec, - pub truncated: bool, - pub output_start_offset: u64, - pub output_end_offset: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_prompt: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSshLatencyResponse { - pub session_id: String, - pub latency_ms: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SshTerminalTabRecord { - pub id: String, - pub session_id: String, - pub project_path_key: String, - pub kind: String, - pub created_at: u128, - pub updated_at: u128, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SshTerminalTabsSnapshot { - pub project_path_key: String, - pub tabs: Vec, - pub revision: u64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalSshExecResponse { - pub session_id: String, - pub command: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_signal: Option, - pub stdout: String, - pub stderr: String, - pub stdout_truncated: bool, - pub stderr_truncated: bool, - pub timed_out: bool, - pub duration_ms: u128, -} - -#[derive(Debug, Clone)] -pub struct TerminalSshSessionInfo { - pub project_path_key: String, - pub cwd: String, - pub running: bool, - pub host_id: String, - pub sftp_enabled: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalShellOption { - pub id: String, - pub label: String, - pub command: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalShellOptionsResponse { - pub options: Vec, - pub default_shell: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalEventPayload { - pub kind: String, - pub session_id: String, - pub project_path_key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_start_offset: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_end_offset: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ssh_tabs: Option, -} - -#[derive(Debug, Clone)] -pub struct TerminalEvent { - pub payload: TerminalEventPayload, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalStreamEventPayload { - pub kind: String, - pub session_id: String, - pub project_path_key: String, - pub start_offset: u64, - pub end_offset: u64, - pub bytes: Vec, -} - -#[derive(Debug, Clone)] -pub struct TerminalStreamEvent { - pub payload: TerminalStreamEventPayload, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalStreamSnapshotResponse { - pub session: TerminalSessionRecord, - pub bytes: Vec, - pub truncated: bool, - pub output_start_offset: u64, - pub output_end_offset: u64, -} - -#[derive(Debug, Clone, Copy)] -struct TerminalSize { - cols: u16, - rows: u16, -} - -struct TerminalSessionEntry { - backend: TerminalSessionBackend, - record: Mutex, - output: Mutex, -} - -enum TerminalSessionBackend { - Local { - master: Mutex>, - input_tx: mpsc::SyncSender>, - child: Mutex>, - }, - Ssh { - runtime: Arc, - }, -} - -struct SshSessionRuntime { - handle: tokio::sync::Mutex>>, - input_tx: Mutex>>, - shutdown_tx: Mutex>>, - connection_id: AtomicUsize, - closing: AtomicBool, - reconnect_runner_active: AtomicBool, -} - -impl SshSessionRuntime { - fn new() -> Self { - Self { - handle: tokio::sync::Mutex::new(None), - input_tx: Mutex::new(None), - shutdown_tx: Mutex::new(None), - connection_id: AtomicUsize::new(0), - closing: AtomicBool::new(false), - reconnect_runner_active: AtomicBool::new(false), - } - } - - async fn install_connection( - &self, - handle: client::Handle, - input_tx: tokio::sync::mpsc::Sender, - shutdown_tx: tokio::sync::mpsc::Sender<()>, - ) -> usize { - let connection_id = self.connection_id.fetch_add(1, Ordering::SeqCst) + 1; - *self.handle.lock().await = Some(handle); - if let Ok(mut slot) = self.input_tx.lock() { - *slot = Some(input_tx); - } - if let Ok(mut slot) = self.shutdown_tx.lock() { - *slot = Some(shutdown_tx); - } - connection_id - } - - async fn clear_connection_if_current(&self, connection_id: usize) { - if self.connection_id.load(Ordering::SeqCst) != connection_id { - return; - } - *self.handle.lock().await = None; - if let Ok(mut slot) = self.input_tx.lock() { - *slot = None; - } - if let Ok(mut slot) = self.shutdown_tx.lock() { - *slot = None; - } - } - - fn input_sender(&self) -> Option> { - self.input_tx.lock().ok().and_then(|slot| slot.clone()) - } - - fn shutdown_sender(&self) -> Option> { - self.shutdown_tx.lock().ok().and_then(|slot| slot.clone()) - } - - fn close(&self) -> Option> { - self.closing.store(true, Ordering::SeqCst); - self.shutdown_sender() - } - - fn is_closing(&self) -> bool { - self.closing.load(Ordering::SeqCst) - } - - fn current_connection_id(&self) -> usize { - self.connection_id.load(Ordering::SeqCst) - } - - fn begin_reconnect_runner(&self) -> bool { - !self.reconnect_runner_active.swap(true, Ordering::SeqCst) - } - - fn finish_reconnect_runner(&self) { - self.reconnect_runner_active.store(false, Ordering::SeqCst); - } -} - -enum SshSessionInput { - Data(Vec), - Resize(u32, u32), -} - -enum SshSessionIoEndReason { - Shutdown, - InputClosed, - WriteFailed, - RemoteClosed, - RemoteExitStatus(u32), - RemoteExitSignal(String), - ConnectionLost, -} - -#[derive(Debug, Clone)] -struct PendingSshConnectRequest { - cwd: String, - project_path_key: String, - ssh_host_id: String, - title: Option, - cols: Option, - rows: Option, - sftp_enabled: bool, -} - -enum PendingSshPrompt { - HostKey { - request: PendingSshConnectRequest, - host_key: RuntimeSshKnownHostKey, - }, - KeyboardInteractive { - request: PendingSshConnectRequest, - host_config: RuntimeSshHostConfig, - title: String, - size: TerminalSize, - handle: client::Handle, - }, -} - -#[derive(Debug, Clone)] -struct KeyboardInteractivePromptData { - name: String, - instructions: String, - prompt: String, - echo: bool, -} - -enum SshAuthOutcome { - Authenticated, - KeyboardInteractivePrompt(KeyboardInteractivePromptData), -} - -#[derive(Debug, PartialEq, Eq)] -enum PasswordKbiPromptAction { - RespondEmpty, - SendPassword, - PromptUser, -} - -#[derive(Debug, Clone)] -struct CapturedHostKey { - key: RuntimeSshKnownHostKey, - status: RuntimeSshKnownHostStatus, -} - -#[derive(Debug, Clone)] -struct TerminalOutputChunk { - start_offset: u64, - data: Vec, -} - -#[derive(Debug, Default)] -struct TerminalOutputBuffer { - chunks: VecDeque, - next_offset: u64, -} - -impl TerminalOutputBuffer { - fn append(&mut self, data: Vec) -> (u64, u64) { - let start_offset = self.next_offset; - self.next_offset = self.next_offset.saturating_add(data.len() as u64); - self.chunks - .push_back(TerminalOutputChunk { start_offset, data }); - while self.chunks.len() > MAX_RING_CHUNKS { - self.chunks.pop_front(); - } - (start_offset, self.next_offset) - } -} - -impl TerminalEchoDispatchState { - fn dispatch(&mut self, payload: TerminalStreamEventPayload) -> TerminalOutputDispatch { - let mut dispatch = TerminalOutputDispatch::default(); - for (index, byte) in payload.bytes.iter().copied().enumerate() { - let offset = payload.start_offset.saturating_add(index as u64); - if let Some(origin) = self.consume_echo_byte(byte) { - match origin { - TerminalInputOrigin::Local => { - self.push_local_or_defer_remote(&mut dispatch, &payload, byte, offset); - if terminal_line_end(byte) { - self.flush_remote(&mut dispatch); - } - } - TerminalInputOrigin::Remote => { - self.push_remote_or_defer_local(&mut dispatch, &payload, byte, offset); - if terminal_line_end(byte) { - self.flush_local(&mut dispatch); - } - } - } - } else { - self.push_visible_to_both(&mut dispatch, &payload, byte, offset); - } - } - dispatch - } - - fn consume_echo_byte(&mut self, byte: u8) -> Option { - let front = self.pending.front().copied()?; - if front.byte == byte || (byte == b'\n' && front.byte == b'\r') { - self.pending.pop_front(); - return Some(front.origin); - } - if terminal_line_end(byte) { - if let Some(index) = self - .pending - .iter() - .position(|pending| terminal_line_end(pending.byte)) - { - let origin = self - .pending - .get(index) - .map(|pending| pending.origin) - .unwrap_or(front.origin); - for _ in 0..=index { - self.pending.pop_front(); - } - return Some(origin); - } - } - None - } - - fn push_local_or_defer_remote( - &mut self, - dispatch: &mut TerminalOutputDispatch, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, - ) { - self.push_local(dispatch, template, byte, offset); - push_payload_byte(&mut self.deferred_remote, template, byte, offset); - } - - fn push_remote_or_defer_local( - &mut self, - dispatch: &mut TerminalOutputDispatch, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, - ) { - self.push_remote(dispatch, template, byte, offset); - push_payload_byte(&mut self.deferred_local, template, byte, offset); - } - - fn push_visible_to_both( - &mut self, - dispatch: &mut TerminalOutputDispatch, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, - ) { - self.push_local(dispatch, template, byte, offset); - self.push_remote(dispatch, template, byte, offset); - } - - fn push_local( - &mut self, - dispatch: &mut TerminalOutputDispatch, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, - ) { - if self.deferred_local.is_empty() { - push_payload_byte(&mut dispatch.local, template, byte, offset); - } else { - push_payload_byte(&mut self.deferred_local, template, byte, offset); - } - } - - fn push_remote( - &mut self, - dispatch: &mut TerminalOutputDispatch, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, - ) { - if self.deferred_remote.is_empty() { - push_payload_byte(&mut dispatch.remote, template, byte, offset); - } else { - push_payload_byte(&mut self.deferred_remote, template, byte, offset); - } - } - - fn flush_local(&mut self, dispatch: &mut TerminalOutputDispatch) { - dispatch.local.append(&mut self.deferred_local); - } - - fn flush_remote(&mut self, dispatch: &mut TerminalOutputDispatch) { - dispatch.remote.append(&mut self.deferred_remote); - } - - fn is_empty(&self) -> bool { - self.pending.is_empty() && self.deferred_local.is_empty() && self.deferred_remote.is_empty() - } -} - -fn push_payload_byte( - payloads: &mut Vec, - template: &TerminalStreamEventPayload, - byte: u8, - offset: u64, -) { - let end_offset = offset.saturating_add(1); - if let Some(last) = payloads.last_mut() { - if last.end_offset == offset - && last.session_id == template.session_id - && last.project_path_key == template.project_path_key - { - last.bytes.push(byte); - last.end_offset = end_offset; - return; - } - } - payloads.push(TerminalStreamEventPayload { - kind: template.kind.clone(), - session_id: template.session_id.clone(), - project_path_key: template.project_path_key.clone(), - start_offset: offset, - end_offset, - bytes: vec![byte], - }); -} - -fn terminal_input_echo_candidates( - data: &[u8], - origin: TerminalInputOrigin, -) -> Vec { - let mut bytes = Vec::new(); - let mut escape = TerminalEscapeParseState::None; - for byte in data.iter().copied() { - match escape { - TerminalEscapeParseState::None => { - if byte == 0x1b { - escape = TerminalEscapeParseState::Esc; - } else if terminal_input_echo_candidate(byte) { - bytes.push(PendingEchoByte { byte, origin }); - } - } - TerminalEscapeParseState::Esc => { - escape = if byte == b'[' { - TerminalEscapeParseState::Csi - } else { - TerminalEscapeParseState::None - }; - } - TerminalEscapeParseState::Csi => { - if (0x40..=0x7e).contains(&byte) { - escape = TerminalEscapeParseState::None; - } - } - } - } - bytes -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TerminalEscapeParseState { - None, - Esc, - Csi, -} - -fn terminal_input_echo_candidate(byte: u8) -> bool { - byte == b'\r' || byte == b'\n' || byte == b'\t' || (byte >= 0x20 && byte != 0x7f) -} - -fn terminal_line_end(byte: u8) -> bool { - byte == b'\r' || byte == b'\n' -} - -#[derive(Debug, Clone)] -struct TerminalOutputTail { - output: Vec, - truncated: bool, - output_start_offset: u64, - output_end_offset: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TerminalInputOrigin { - Local, - Remote, -} - -#[derive(Debug, Clone, Copy)] -struct PendingEchoByte { - byte: u8, - origin: TerminalInputOrigin, -} - -#[derive(Debug, Default)] -struct TerminalEchoDispatchState { - pending: VecDeque, - deferred_local: Vec, - deferred_remote: Vec, -} - -#[derive(Debug, Default)] -struct TerminalOutputDispatch { - local: Vec, - remote: Vec, -} - -#[derive(Debug, Default)] -struct SshTerminalTabsState { - tabs: Vec, - revision: u64, -} - -#[derive(Default)] -pub struct TerminalSessionRegistry { - sessions: Mutex>>, - pending_ssh_prompts: Mutex>, - ssh_terminal_tabs_tx: Mutex<()>, - ssh_terminal_tabs: Mutex>, - app_handle: Mutex>, - subscribers: Arc>>>, - stream_subscribers: Arc>>>, - echo_dispatch: Mutex>, - next_subscriber_id: AtomicUsize, -} - -impl Drop for TerminalSessionRegistry { - fn drop(&mut self) { - if let Ok(sessions) = self.sessions.get_mut() { - for entry in sessions.values() { - terminate_terminal_entry(entry); - } - sessions.clear(); - } - } -} - -impl TerminalSessionRegistry { - pub fn attach_app_handle(&self, app_handle: AppHandle) { - if let Ok(mut slot) = self.app_handle.lock() { - *slot = Some(app_handle); - } - } - - pub fn subscribe(&self) -> (mpsc::Receiver, TerminalSubscriberGuard) { - let (tx, rx) = mpsc::channel(); - let id = self.next_subscriber_id.fetch_add(1, Ordering::SeqCst); - if let Ok(mut subscribers) = self.subscribers.lock() { - subscribers.insert(id, tx); - } - ( - rx, - TerminalSubscriberGuard { - id, - subscribers: Arc::clone(&self.subscribers), - }, - ) - } - - pub fn subscribe_stream( - &self, - ) -> ( - mpsc::Receiver, - TerminalStreamSubscriberGuard, - ) { - let (tx, rx) = mpsc::channel(); - let id = self.next_subscriber_id.fetch_add(1, Ordering::SeqCst); - if let Ok(mut subscribers) = self.stream_subscribers.lock() { - subscribers.insert(id, tx); - } - ( - rx, - TerminalStreamSubscriberGuard { - id, - subscribers: Arc::clone(&self.stream_subscribers), - }, - ) - } - - pub fn list(&self, project_path_key: Option) -> TerminalListResponse { - let project_key = project_path_key - .map(|value| normalize_project_path_key(&value)) - .filter(|value| !value.is_empty()); - let mut sessions = self - .sessions - .lock() - .expect("terminal session registry poisoned") - .values() - .filter_map(|entry| entry.record.lock().ok().map(|record| record.clone())) - .filter(|record| { - project_key - .as_ref() - .is_none_or(|wanted| project_path_keys_equal(&record.project_path_key, wanted)) - }) - .collect::>(); - sessions.sort_by(|a, b| { - a.project_path_key - .cmp(&b.project_path_key) - .then(a.created_at.cmp(&b.created_at)) - }); - TerminalListResponse { sessions } - } - - pub fn create( - self: &Arc, - cwd: String, - project_path_key: Option, - shell: Option, - title: Option, - cols: Option, - rows: Option, - ) -> Result { - let cwd = canonicalize_workdir(&cwd)?; - let project_key = project_path_key - .map(|value| normalize_project_path_key(&value)) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string())); - if project_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - - let shell_spec = resolve_shell(shell)?; - let size = TerminalSize { - cols: cols.unwrap_or(DEFAULT_COLS).clamp(20, 400), - rows: rows.unwrap_or(DEFAULT_ROWS).clamp(6, 200), - }; - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows: size.rows, - cols: size.cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|err| format!("failed to open terminal pty: {err}"))?; - - let mut cmd = CommandBuilder::new(&shell_spec.command); - for arg in &shell_spec.args { - cmd.arg(arg); - } - cmd.cwd(&cwd); - configure_terminal_shell_env(&mut cmd, &shell_spec.command); - - let child = pair - .slave - .spawn_command(cmd) - .map_err(|err| format!("failed to spawn terminal shell: {err}"))?; - let pid = child.process_id(); - let mut reader = pair - .master - .try_clone_reader() - .map_err(|err| format!("failed to open terminal reader: {err}"))?; - let writer = pair - .master - .take_writer() - .map_err(|err| format!("failed to open terminal writer: {err}"))?; - let (input_tx, input_rx) = mpsc::sync_channel::>(256); - thread::spawn(move || { - let mut writer = writer; - while let Ok(data) = input_rx.recv() { - if data.is_empty() { - continue; - } - if writer.write_all(&data).is_err() { - break; - } - } - }); - - let id = uuid::Uuid::new_v4().to_string(); - let title = title - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| self.next_terminal_title(&project_key)); - let now = now_ms(); - let record = TerminalSessionRecord { - id: id.clone(), - project_path_key: project_key, - cwd: cwd.display().to_string(), - shell: shell_spec.label, - title, - kind: "local".to_string(), - ssh: None, - pid, - cols: size.cols, - rows: size.rows, - created_at: now, - updated_at: now, - finished_at: None, - exit_code: None, - running: true, - }; - - let entry = Arc::new(TerminalSessionEntry { - backend: TerminalSessionBackend::Local { - master: Mutex::new(pair.master), - input_tx, - child: Mutex::new(child), - }, - record: Mutex::new(record), - output: Mutex::new(TerminalOutputBuffer::default()), - }); - self.sessions - .lock() - .expect("terminal session registry poisoned") - .insert(id.clone(), Arc::clone(&entry)); - self.broadcast("created", &entry, None, None, None); - - let registry = Arc::clone(self); - let reader_session_id = id.clone(); - thread::spawn(move || { - let mut buffer = [0u8; 8192]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(n) => { - registry.append_output(&reader_session_id, buffer[..n].to_vec()); - } - Err(_) => break, - } - } - registry.mark_finished(&reader_session_id); - }); - - self.snapshot(id, Some(MAX_TAIL_BYTES)) - } - - pub async fn create_ssh( - self: &Arc, - cwd: String, - project_path_key: Option, - ssh_host_id: String, - title: Option, - cols: Option, - rows: Option, - sftp_enabled: bool, - ) -> Result { - let cwd = canonicalize_workdir(&cwd)?; - let project_key = project_path_key - .map(|value| normalize_project_path_key(&value)) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string())); - if project_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - let request = PendingSshConnectRequest { - cwd: cwd.display().to_string(), - project_path_key: project_key, - ssh_host_id, - title, - cols, - rows, - sftp_enabled, - }; - self.create_ssh_from_request(request).await - } - - pub async fn answer_ssh_prompt( - self: &Arc, - prompt_id: String, - answer: Option, - trust_host_key: bool, - ) -> Result { - let prompt_id = prompt_id.trim().to_string(); - if prompt_id.is_empty() { - return Err("prompt_id is required".to_string()); - } - let pending = self - .pending_ssh_prompts - .lock() - .map_err(|_| "ssh prompt registry poisoned".to_string())? - .remove(&prompt_id) - .ok_or_else(|| format!("ssh prompt not found: {prompt_id}"))?; - match pending { - PendingSshPrompt::HostKey { request, host_key } => { - if !trust_host_key { - return Err("SSH host key trust was cancelled".to_string()); - } - trust_runtime_ssh_known_host(&host_key)?; - self.create_ssh_from_request(request).await - } - PendingSshPrompt::KeyboardInteractive { - request, - host_config, - title, - size, - mut handle, - } => { - let response = handle - .authenticate_keyboard_interactive_respond(vec![answer.unwrap_or_default()]) - .await - .map_err(|error| { - format!("SSH keyboard-interactive response failed: {error}") - })?; - self.continue_ssh_keyboard_interactive( - request, - host_config, - title, - size, - handle, - response, - None, - ) - .await - } - } - } - - pub fn cancel_ssh_prompt(&self, prompt_id: String) -> Result<(), String> { - let prompt_id = prompt_id.trim().to_string(); - if prompt_id.is_empty() { - return Err("prompt_id is required".to_string()); - } - let pending = self - .pending_ssh_prompts - .lock() - .map_err(|_| "ssh prompt registry poisoned".to_string())? - .remove(&prompt_id); - if let Some(PendingSshPrompt::KeyboardInteractive { handle, .. }) = pending { - tokio::spawn(async move { - let _ = handle - .disconnect( - russh::Disconnect::ByApplication, - "Authentication cancelled", - "en", - ) - .await; - }); - } - Ok(()) - } - - async fn create_ssh_from_request( - self: &Arc, - request: PendingSshConnectRequest, - ) -> Result { - let host_config = load_runtime_ssh_host(&request.ssh_host_id)? - .ok_or_else(|| format!("SSH host not found: {}", request.ssh_host_id.trim()))?; - if host_config.host.trim().is_empty() { - return Err("SSH host is required".to_string()); - } - if host_config.username.trim().is_empty() { - return Err("SSH username is required".to_string()); - } - - let size = TerminalSize { - cols: request.cols.unwrap_or(DEFAULT_COLS).clamp(20, 400), - rows: request.rows.unwrap_or(DEFAULT_ROWS).clamp(6, 200), - }; - let title = request - .title - .as_ref() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| self.next_ssh_title(&request.project_path_key, &host_config.name)); - - let auth = resolve_ssh_auth_material(&host_config)?; - let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); - let mut handle = - match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { - Ok(handle) => handle, - Err(error) => { - if let Some(captured) = captured_host_key.lock().await.clone() { - return self.ssh_host_key_response(request, &host_config, captured); - } - return Err(error); - } - }; - - match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { - SshAuthOutcome::Authenticated => { - self.finish_create_ssh_session(request, host_config, title, size, handle) - .await - } - SshAuthOutcome::KeyboardInteractivePrompt(prompt_data) => self - .ssh_keyboard_interactive_response( - request, - host_config, - title, - size, - handle, - prompt_data, - ), - } - } - - async fn finish_create_ssh_session( - self: &Arc, - request: PendingSshConnectRequest, - host_config: RuntimeSshHostConfig, - title: String, - size: TerminalSize, - handle: client::Handle, - ) -> Result { - let channel = open_ssh_shell_channel(&handle, size).await?; - - let id = uuid::Uuid::new_v4().to_string(); - let now = now_ms(); - let ssh = TerminalSshMetadata { - host_id: host_config.id.clone(), - host_name: host_config.name.clone(), - username: host_config.username.clone(), - host: host_config.host.clone(), - port: host_config.port, - auth_type: host_config.auth_type.clone(), - status: SSH_STATUS_CONNECTED.to_string(), - reconnect_attempt: 0, - reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS, - sftp_enabled: request.sftp_enabled, - }; - let record = TerminalSessionRecord { - id: id.clone(), - project_path_key: request.project_path_key.clone(), - cwd: request.cwd.clone(), - shell: "ssh".to_string(), - title, - kind: "ssh".to_string(), - ssh: Some(ssh), - pid: None, - cols: size.cols, - rows: size.rows, - created_at: now, - updated_at: now, - finished_at: None, - exit_code: None, - running: true, - }; - - let runtime = Arc::new(SshSessionRuntime::new()); - let (input_tx, input_rx) = tokio::sync::mpsc::channel::(256); - let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); - let connection_id = runtime - .install_connection(handle, input_tx, shutdown_tx) - .await; - let entry = Arc::new(TerminalSessionEntry { - backend: TerminalSessionBackend::Ssh { - runtime: Arc::clone(&runtime), - }, - record: Mutex::new(record), - output: Mutex::new(TerminalOutputBuffer::default()), - }); - self.sessions - .lock() - .expect("terminal session registry poisoned") - .insert(id.clone(), Arc::clone(&entry)); - self.broadcast("created", &entry, None, None, None); - - let registry = Arc::clone(self); - tauri::async_runtime::spawn(run_ssh_session_io( - registry, - id.clone(), - Arc::clone(&runtime), - connection_id, - channel, - input_rx, - shutdown_rx, - )); - - self.snapshot(id, Some(MAX_TAIL_BYTES)) - .map(terminal_ssh_create_response_from_snapshot) - } - - async fn reconnect_ssh_session( - self: &Arc, - entry: Arc, - attempt: u8, - ) -> Result<(), String> { - let record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - let ssh = record - .ssh - .clone() - .ok_or_else(|| "SSH session metadata is missing".to_string())?; - let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { - return Err("terminal session is not an SSH connection".to_string()); - }; - if runtime.is_closing() { - return Err("SSH session is closing".to_string()); - } - let host_config = load_runtime_ssh_host(&ssh.host_id)? - .ok_or_else(|| format!("SSH host not found: {}", ssh.host_id.trim()))?; - - let auth = resolve_ssh_auth_material(&host_config)?; - let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); - let mut handle = - match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { - Ok(handle) => handle, - Err(error) => { - if captured_host_key.lock().await.is_some() { - return Err( - "SSH host key requires confirmation before reconnecting".to_string() - ); - } - return Err(error); - } - }; - - match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { - SshAuthOutcome::Authenticated => {} - SshAuthOutcome::KeyboardInteractivePrompt(_) => { - let _ = handle - .disconnect( - russh::Disconnect::ByApplication, - "Keyboard-interactive reconnect requires user input", - "en", - ) - .await; - return Err( - "SSH reconnect requires keyboard-interactive input from the user".to_string(), - ); - } - } - - let size = TerminalSize { - cols: record.cols, - rows: record.rows, - }; - let channel = open_ssh_shell_channel(&handle, size).await?; - let (input_tx, input_rx) = tokio::sync::mpsc::channel::(256); - let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); - let connection_id = runtime - .install_connection(handle, input_tx, shutdown_tx) - .await; - { - let mut record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())?; - record.running = true; - record.finished_at = None; - record.exit_code = None; - record.updated_at = now_ms(); - if let Some(ssh) = record.ssh.as_mut() { - ssh.status = SSH_STATUS_CONNECTED.to_string(); - ssh.reconnect_attempt = 0; - ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; - } - } - self.append_output( - &record.id, - format!("\r\n[SSH] Reconnected after attempt {attempt}.\r\n"), - ); - self.broadcast("reconnected", &entry, None, None, None); - - let registry = Arc::clone(self); - tauri::async_runtime::spawn(run_ssh_session_io( - registry, - record.id, - Arc::clone(runtime), - connection_id, - channel, - input_rx, - shutdown_rx, - )); - Ok(()) - } - - async fn handle_ssh_unexpected_disconnect( - self: Arc, - session_id: String, - runtime: Arc, - connection_id: usize, - ) { - if !runtime.begin_reconnect_runner() { - return; - } - if runtime.current_connection_id() != connection_id { - runtime.finish_reconnect_runner(); - return; - } - runtime.clear_connection_if_current(connection_id).await; - if runtime.is_closing() { - runtime.finish_reconnect_runner(); - return; - } - let Ok(entry) = self.entry(&session_id) else { - runtime.finish_reconnect_runner(); - return; - }; - for attempt in 1..=SSH_RECONNECT_MAX_ATTEMPTS { - if runtime.is_closing() { - runtime.finish_reconnect_runner(); - return; - } - self.mark_ssh_reconnecting(&entry, attempt); - self.append_output( - &session_id, - format!( - "\r\n[SSH] Connection lost. Reconnecting ({attempt}/{SSH_RECONNECT_MAX_ATTEMPTS})...\r\n" - ), - ); - let delay = SSH_RECONNECT_DELAYS - .get(usize::from(attempt.saturating_sub(1))) - .copied() - .unwrap_or_else(|| Duration::from_secs(10)); - tokio::time::sleep(delay).await; - if runtime.is_closing() { - runtime.finish_reconnect_runner(); - return; - } - let reconnect_result = match timeout( - SSH_RECONNECT_ATTEMPT_TIMEOUT, - self.reconnect_ssh_session(Arc::clone(&entry), attempt), - ) - .await - { - Ok(result) => result, - Err(_) => Err(format!( - "SSH reconnect timed out after {} seconds", - SSH_RECONNECT_ATTEMPT_TIMEOUT.as_secs() - )), - }; - match reconnect_result { - Ok(()) => { - runtime.finish_reconnect_runner(); - return; - } - Err(error) => { - self.append_output( - &session_id, - format!( - "[SSH] Reconnect attempt {attempt}/{SSH_RECONNECT_MAX_ATTEMPTS} failed: {error}\r\n" - ), - ); - } - } - } - self.mark_ssh_disconnected(&entry); - self.append_output( - &session_id, - format!("[SSH] Reconnect failed after {SSH_RECONNECT_MAX_ATTEMPTS} attempts.\r\n"), - ); - runtime.finish_reconnect_runner(); - } - - async fn continue_ssh_keyboard_interactive( - self: &Arc, - request: PendingSshConnectRequest, - host_config: RuntimeSshHostConfig, - title: String, - size: TerminalSize, - mut handle: client::Handle, - response: client::KeyboardInteractiveAuthResponse, - auto_password: Option, - ) -> Result { - match continue_keyboard_interactive_auth(&mut handle, response, auto_password).await? { - SshAuthOutcome::Authenticated => { - self.finish_create_ssh_session(request, host_config, title, size, handle) - .await - } - SshAuthOutcome::KeyboardInteractivePrompt(prompt_data) => self - .ssh_keyboard_interactive_response( - request, - host_config, - title, - size, - handle, - prompt_data, - ), - } - } - - fn ssh_keyboard_interactive_response( - self: &Arc, - request: PendingSshConnectRequest, - host_config: RuntimeSshHostConfig, - title: String, - size: TerminalSize, - handle: client::Handle, - prompt_data: KeyboardInteractivePromptData, - ) -> Result { - let prompt_id = uuid::Uuid::new_v4().to_string(); - let message = ssh_keyboard_interactive_message(&prompt_data); - let prompt = TerminalSshPrompt { - id: prompt_id.clone(), - kind: "keyboardInteractive".to_string(), - host_id: host_config.id.clone(), - host_name: host_config.name.clone(), - host: host_config.host.clone(), - port: host_config.port, - message, - fingerprint_sha256: String::new(), - key_type: String::new(), - answer_echo: prompt_data.echo, - }; - self.pending_ssh_prompts - .lock() - .map_err(|_| "ssh prompt registry poisoned".to_string())? - .insert( - prompt_id.clone(), - PendingSshPrompt::KeyboardInteractive { - request, - host_config, - title, - size, - handle, - }, - ); - self.schedule_ssh_prompt_timeout(prompt_id); - Ok(TerminalSshCreateResponse { - session: None, - output: String::new(), - output_bytes: Vec::new(), - truncated: false, - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: Some(prompt), - }) - } - - fn ssh_host_key_response( - self: &Arc, - request: PendingSshConnectRequest, - host_config: &RuntimeSshHostConfig, - captured: CapturedHostKey, - ) -> Result { - match captured.status { - RuntimeSshKnownHostStatus::Known => { - Err("SSH host key check failed unexpectedly".to_string()) - } - RuntimeSshKnownHostStatus::Changed { stored_fingerprint } => Err(format!( - "SSH host key changed for {}:{}. Stored fingerprint: {}. Received fingerprint: {}.", - host_config.host, - host_config.port, - stored_fingerprint, - captured.key.fingerprint_sha256 - )), - RuntimeSshKnownHostStatus::Unknown => { - let prompt_id = uuid::Uuid::new_v4().to_string(); - let prompt = TerminalSshPrompt { - id: prompt_id.clone(), - kind: "hostKey".to_string(), - host_id: host_config.id.clone(), - host_name: host_config.name.clone(), - host: host_config.host.clone(), - port: host_config.port, - message: format!( - "Trust SSH host key for {}:{}?", - host_config.host, host_config.port - ), - fingerprint_sha256: captured.key.fingerprint_sha256.clone(), - key_type: captured.key.key_type.clone(), - answer_echo: false, - }; - self.pending_ssh_prompts - .lock() - .map_err(|_| "ssh prompt registry poisoned".to_string())? - .insert( - prompt_id.clone(), - PendingSshPrompt::HostKey { - request, - host_key: captured.key, - }, - ); - self.schedule_ssh_prompt_timeout(prompt_id); - Ok(TerminalSshCreateResponse { - session: None, - output: String::new(), - output_bytes: Vec::new(), - truncated: false, - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: Some(prompt), - }) - } - } - } - - fn schedule_ssh_prompt_timeout(self: &Arc, prompt_id: String) { - let registry = Arc::clone(self); - tokio::spawn(async move { - tokio::time::sleep(SSH_PROMPT_TIMEOUT).await; - let pending = registry - .pending_ssh_prompts - .lock() - .ok() - .and_then(|mut prompts| prompts.remove(&prompt_id)); - if let Some(PendingSshPrompt::KeyboardInteractive { handle, .. }) = pending { - let _ = handle - .disconnect( - russh::Disconnect::ByApplication, - "Authentication prompt timed out", - "en", - ) - .await; - } - }); - } - - pub fn snapshot( - &self, - session_id: String, - max_bytes: Option, - ) -> Result { - let entry = self.entry(&session_id)?; - let session = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - let tail = read_output_tail(&entry, max_bytes.unwrap_or(MAX_TAIL_BYTES)); - Ok(TerminalSnapshotResponse { - session, - output: String::from_utf8_lossy(&tail.output).into_owned(), - output_bytes: tail.output, - truncated: tail.truncated, - output_start_offset: tail.output_start_offset, - output_end_offset: tail.output_end_offset, - }) - } - - pub fn stream_attach( - &self, - session_id: String, - max_bytes: Option, - ) -> Result { - let entry = self.entry(&session_id)?; - let session = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - let tail = read_output_tail(&entry, max_bytes.unwrap_or(MAX_TAIL_BYTES)); - Ok(TerminalStreamSnapshotResponse { - session, - bytes: tail.output, - truncated: tail.truncated, - output_start_offset: tail.output_start_offset, - output_end_offset: tail.output_end_offset, - }) - } - - pub fn session_record(&self, session_id: String) -> Result { - self.record(session_id) - } - - pub fn ssh_session_info(&self, session_id: &str) -> Result { - let record = self.record(session_id.to_string())?; - if record.kind.trim() != "ssh" { - return Err("terminal session is not an SSH connection".to_string()); - } - let ssh = record - .ssh - .ok_or_else(|| "SSH session metadata is missing".to_string())?; - Ok(TerminalSshSessionInfo { - project_path_key: record.project_path_key, - cwd: record.cwd, - running: record.running, - host_id: ssh.host_id, - sftp_enabled: ssh.sftp_enabled, - }) - } - - pub fn ssh_terminal_tabs_list( - &self, - project_path_key: String, - ) -> Result { - let project_key = required_project_key(project_path_key)?; - let (snapshot, should_broadcast) = { - let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; - if let Some(snapshot) = self.prune_invalid_ssh_terminal_tabs_for_project(&project_key) { - (snapshot, true) - } else { - (self.ssh_terminal_tabs_snapshot(&project_key), false) - } - }; - if should_broadcast { - self.broadcast_ssh_tabs_snapshot(snapshot.clone()); - } - Ok(snapshot) - } - - pub fn ssh_terminal_tab_open( - &self, - session_id: String, - kind: String, - ) -> Result { - let kind = normalize_ssh_terminal_tab_kind(&kind)?; - let (snapshot, should_broadcast) = { - let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; - let session = self.valid_ssh_terminal_tab_session(&session_id, &kind)?; - let tab_id = ssh_terminal_tab_id(&session.id, &kind); - let now = now_ms(); - let mut tabs_by_project = self - .ssh_terminal_tabs - .lock() - .map_err(|_| "ssh terminal tabs registry poisoned".to_string())?; - let state = tabs_by_project - .entry(session.project_path_key.clone()) - .or_default(); - if state.tabs.iter().any(|tab| tab.id == tab_id) { - return Ok(ssh_terminal_tabs_snapshot_from_state( - &session.project_path_key, - state, - )); - } else { - state.tabs.push(SshTerminalTabRecord { - id: tab_id.clone(), - session_id: session.id.clone(), - project_path_key: session.project_path_key.clone(), - kind, - created_at: now, - updated_at: now, - }); - } - state.revision = state.revision.saturating_add(1); - ( - ssh_terminal_tabs_snapshot_from_state(&session.project_path_key, state), - true, - ) - }; - if should_broadcast { - self.broadcast_ssh_tabs_snapshot(snapshot.clone()); - } - Ok(snapshot) - } - - pub fn ssh_terminal_tab_close( - &self, - tab_id: String, - ) -> Result { - let tab_id = tab_id.trim(); - if tab_id.is_empty() { - return Err("tab_id is required".to_string()); - } - let snapshot = { - let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; - let mut tabs_by_project = self - .ssh_terminal_tabs - .lock() - .map_err(|_| "ssh terminal tabs registry poisoned".to_string())?; - let Some(project_key) = tabs_by_project.iter().find_map(|(project_key, state)| { - state - .tabs - .iter() - .any(|tab| tab.id == tab_id) - .then(|| project_key.clone()) - }) else { - return Err(format!("ssh terminal tab not found: {tab_id}")); - }; - let state = tabs_by_project - .get_mut(&project_key) - .ok_or_else(|| format!("ssh terminal tab not found: {tab_id}"))?; - let Some(index) = state.tabs.iter().position(|tab| tab.id == tab_id) else { - return Err(format!("ssh terminal tab not found: {tab_id}")); - }; - state.tabs.remove(index); - state.revision = state.revision.saturating_add(1); - ssh_terminal_tabs_snapshot_from_state(&project_key, state) - }; - self.broadcast_ssh_tabs_snapshot(snapshot.clone()); - Ok(snapshot) - } - - pub async fn ssh_latency( - self: &Arc, - session_id: String, - ) -> Result { - let entry = self.entry(&session_id)?; - let record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - if record.kind.trim() != "ssh" { - return Err("terminal session is not an SSH connection".to_string()); - } - if !record.running { - return Err("SSH connection is not running".to_string()); - } - let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { - return Err("terminal session is not an SSH connection".to_string()); - }; - let start = Instant::now(); - let ping = timeout(Duration::from_secs(3), async { - let handle = runtime.handle.lock().await; - let Some(handle) = handle.as_ref() else { - return Err(russh::Error::Disconnect); - }; - handle.send_ping().await - }) - .await; - match ping { - Ok(Ok(())) => {} - Ok(Err(error)) => return Err(format!("SSH latency check failed: {error}")), - Err(_) => return Err("SSH latency check timed out".to_string()), - } - let elapsed = start.elapsed().as_millis().clamp(1, u128::from(u32::MAX)) as u32; - Ok(TerminalSshLatencyResponse { - session_id: record.id, - latency_ms: elapsed, - }) - } - - pub async fn ssh_exec( - self: &Arc, - session_id: String, - command: String, - cwd: Option, - timeout_ms: Option, - max_bytes: Option, - ) -> Result { - let command = command.trim().to_string(); - if command.is_empty() { - return Err("command is required".to_string()); - } - let entry = self.entry(&session_id)?; - let record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - if record.kind.trim() != "ssh" { - return Err("terminal session is not an SSH connection".to_string()); - } - if !record.running { - return Err("SSH connection is not running".to_string()); - } - let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { - return Err("terminal session is not an SSH connection".to_string()); - }; - - let cwd = cwd - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - let wrapped_command = wrap_ssh_exec_command(&command, cwd.as_deref()); - let timeout_duration = normalize_ssh_exec_timeout(timeout_ms); - let capture_limit = normalize_ssh_exec_max_bytes(max_bytes); - let start = Instant::now(); - let result = timeout( - timeout_duration, - run_ssh_exec_channel(runtime, wrapped_command, capture_limit), - ) - .await; - let duration_ms = start.elapsed().as_millis(); - - match result { - Ok(Ok(mut response)) => { - response.session_id = record.id; - response.command = command; - response.cwd = cwd; - response.duration_ms = duration_ms; - Ok(response) - } - Ok(Err(error)) => { - spawn_ssh_reconnect_runner( - Arc::clone(self), - record.id, - Arc::clone(runtime), - runtime.current_connection_id(), - ); - Err(format!("SSH exec failed: {error}")) - } - Err(_) => Ok(TerminalSshExecResponse { - session_id: record.id, - command, - cwd, - exit_code: None, - exit_signal: None, - stdout: String::new(), - stderr: String::new(), - stdout_truncated: false, - stderr_truncated: false, - timed_out: true, - duration_ms, - }), - } - } - - pub fn input_bytes(&self, session_id: String, data: Vec) -> Result<(), String> { - self.input_bytes_with_origin(session_id, data, TerminalInputOrigin::Local) - } - - pub fn input_bytes_from_remote(&self, session_id: String, data: Vec) -> Result<(), String> { - self.input_bytes_with_origin(session_id, data, TerminalInputOrigin::Remote) - } - - fn input_bytes_with_origin( - &self, - session_id: String, - data: Vec, - origin: TerminalInputOrigin, - ) -> Result<(), String> { - if data.is_empty() { - return Ok(()); - } - let entry = self.entry(&session_id)?; - let running = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .running; - if !running { - return Err("terminal session is not running".to_string()); - } - let echo_bytes = terminal_input_echo_candidates(&data, origin); - match &entry.backend { - TerminalSessionBackend::Local { input_tx, .. } => { - input_tx - .try_send(data) - .map_err(|err| format!("failed to enqueue terminal input: {err}"))?; - } - TerminalSessionBackend::Ssh { runtime } => { - runtime - .input_sender() - .ok_or_else(|| "SSH connection is not connected".to_string())? - .try_send(SshSessionInput::Data(data)) - .map_err(|err| format!("failed to enqueue ssh terminal input: {err}"))?; - } - } - self.record_input_echo_candidates(&session_id, echo_bytes); - self.touch(&entry); - Ok(()) - } - - pub fn stream_resize(&self, session_id: String, cols: u16, rows: u16) -> Result<(), String> { - self.resize(session_id, cols, rows).map(|_| ()) - } - - pub fn resize( - &self, - session_id: String, - cols: u16, - rows: u16, - ) -> Result { - let entry = self.entry(&session_id)?; - let cols = cols.clamp(20, 400); - let rows = rows.clamp(6, 200); - match &entry.backend { - TerminalSessionBackend::Local { master, .. } => { - master - .lock() - .map_err(|_| "terminal master lock poisoned".to_string())? - .resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|err| format!("failed to resize terminal: {err}"))?; - } - TerminalSessionBackend::Ssh { runtime } => { - if let Some(input_tx) = runtime.input_sender() { - input_tx - .try_send(SshSessionInput::Resize(u32::from(cols), u32::from(rows))) - .map_err(|err| format!("failed to resize ssh terminal: {err}"))?; - } - } - } - { - let mut record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())?; - record.cols = cols; - record.rows = rows; - record.updated_at = now_ms(); - } - self.broadcast("resized", &entry, None, None, None); - self.record(session_id) - } - - pub fn rename( - &self, - session_id: String, - title: String, - ) -> Result { - let entry = self.entry(&session_id)?; - let next_title = title.trim(); - if next_title.is_empty() { - return Err("terminal title cannot be empty".to_string()); - } - { - let mut record = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())?; - record.title = next_title.to_string(); - record.updated_at = now_ms(); - } - self.broadcast("renamed", &entry, None, None, None); - self.record(session_id) - } - - pub fn close(&self, session_id: String) -> Result { - let entry = self.entry(&session_id)?; - terminate_terminal_entry(&entry); - let (session, tab_snapshots) = { - let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; - self.mark_finished(&session_id); - self.sessions - .lock() - .expect("terminal session registry poisoned") - .remove(session_id.trim()); - let session = entry - .record - .lock() - .map_err(|_| "terminal session lock poisoned".to_string())? - .clone(); - let tab_snapshots = self.prune_ssh_terminal_tabs_for_session_locked(&session.id); - (session, tab_snapshots) - }; - self.broadcast("closed", &entry, None, None, None); - for snapshot in tab_snapshots { - self.broadcast_ssh_tabs_snapshot(snapshot); - } - Ok(session) - } - - pub fn close_all(&self) -> Result { - let ids = self - .sessions - .lock() - .expect("terminal session registry poisoned") - .keys() - .cloned() - .collect::>(); - self.close_ids(ids) - } - - pub fn close_project(&self, project_path_key: String) -> Result { - let project_key = normalize_project_path_key(&project_path_key); - if project_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - let ids = self - .sessions - .lock() - .expect("terminal session registry poisoned") - .iter() - .filter_map(|(id, entry)| { - entry - .record - .lock() - .ok() - .filter(|record| { - project_path_keys_equal(&record.project_path_key, &project_key) - }) - .map(|_| id.clone()) - }) - .collect::>(); - self.close_ids(ids) - } - - pub fn running_session_count(&self) -> usize { - self.sessions - .lock() - .ok() - .map(|sessions| { - sessions - .values() - .filter_map(|entry| entry.record.lock().ok()) - .filter(|record| record.running) - .count() - }) - .unwrap_or(0) - } - - pub fn read_tail( - &self, - project_path_key: String, - session_id: Option, - max_bytes: Option, - ) -> Result { - let project_key = normalize_project_path_key(&project_path_key); - if project_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - let sessions = self.list(Some(project_key.clone())).sessions; - if sessions.is_empty() { - return Ok(TerminalReadTailResponse { - sessions: Vec::new(), - selected_session: None, - output: String::new(), - truncated: false, - }); - } - let requested_session_id = session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if requested_session_id.is_none() && sessions.len() > 1 { - return Ok(TerminalReadTailResponse { - sessions, - selected_session: None, - output: String::new(), - truncated: false, - }); - } - let selected_id = requested_session_id.unwrap_or_else(|| sessions[0].id.clone()); - let snapshot = self.snapshot(selected_id, max_bytes)?; - if !project_path_keys_equal(&snapshot.session.project_path_key, &project_key) { - return Err("terminal session is outside the current project".to_string()); - } - Ok(TerminalReadTailResponse { - sessions, - selected_session: Some(snapshot.session), - output: snapshot.output, - truncated: snapshot.truncated, - }) - } - - fn lock_ssh_terminal_tabs_tx(&self) -> Result, String> { - self.ssh_terminal_tabs_tx - .lock() - .map_err(|_| "ssh terminal tabs transaction lock poisoned".to_string()) - } - - fn valid_ssh_terminal_tab_session( - &self, - session_id: &str, - kind: &str, - ) -> Result { - let session = self.record(session_id.trim().to_string())?; - if session.kind.trim() != "ssh" { - return Err("terminal session is not an SSH connection".to_string()); - } - let ssh = session - .ssh - .as_ref() - .ok_or_else(|| "SSH session metadata is missing".to_string())?; - if ssh.status == SSH_STATUS_DISCONNECTED { - return Err("SSH connection is disconnected".to_string()); - } - if kind == "sftp" && !ssh.sftp_enabled { - return Err("SFTP is not enabled for this SSH session".to_string()); - } - Ok(session) - } - - fn ssh_terminal_tabs_snapshot(&self, project_path_key: &str) -> SshTerminalTabsSnapshot { - self.ssh_terminal_tabs - .lock() - .ok() - .and_then(|tabs_by_project| { - tabs_by_project - .get(project_path_key) - .map(|state| ssh_terminal_tabs_snapshot_from_state(project_path_key, state)) - }) - .unwrap_or_else(|| SshTerminalTabsSnapshot { - project_path_key: project_path_key.to_string(), - tabs: Vec::new(), - revision: 0, - }) - } - - fn prune_invalid_ssh_terminal_tabs_for_project( - &self, - project_path_key: &str, - ) -> Option { - let valid_sessions = self - .sessions - .lock() - .ok() - .map(|sessions| { - sessions - .values() - .filter_map(|entry| entry.record.lock().ok().map(|record| record.clone())) - .filter(|record| { - project_path_keys_equal(&record.project_path_key, project_path_key) - && record.kind.trim() == "ssh" - && record - .ssh - .as_ref() - .map(|ssh| ssh.status != SSH_STATUS_DISCONNECTED) - .unwrap_or(false) - }) - .map(|record| { - ( - record.id, - record.ssh.map(|ssh| ssh.sftp_enabled).unwrap_or(false), - ) - }) - .collect::>() - }) - .unwrap_or_default(); - - let mut tabs_by_project = self.ssh_terminal_tabs.lock().ok()?; - let state = tabs_by_project.get_mut(project_path_key)?; - let before_len = state.tabs.len(); - state.tabs.retain(|tab| { - valid_sessions - .get(&tab.session_id) - .map(|sftp_enabled| tab.kind != "sftp" || *sftp_enabled) - .unwrap_or(false) - }); - if state.tabs.len() == before_len { - return None; - } - state.revision = state.revision.saturating_add(1); - Some(ssh_terminal_tabs_snapshot_from_state( - project_path_key, - state, - )) - } - - fn prune_ssh_terminal_tabs_for_session_locked( - &self, - session_id: &str, - ) -> Vec { - let session_id = session_id.trim(); - if session_id.is_empty() { - return Vec::new(); - } - let mut tabs_by_project = match self.ssh_terminal_tabs.lock() { - Ok(tabs_by_project) => tabs_by_project, - Err(_) => return Vec::new(), - }; - let mut snapshots = Vec::new(); - for (project_key, state) in tabs_by_project.iter_mut() { - let before_len = state.tabs.len(); - state.tabs.retain(|tab| tab.session_id != session_id); - if state.tabs.len() == before_len { - continue; - } - state.revision = state.revision.saturating_add(1); - snapshots.push(ssh_terminal_tabs_snapshot_from_state(project_key, state)); - } - snapshots - } - - fn close_ids(&self, ids: Vec) -> Result { - let mut sessions = Vec::new(); - for id in ids { - sessions.push(self.close(id)?); - } - Ok(TerminalListResponse { sessions }) - } - - fn next_terminal_title(&self, project_path_key: &str) -> String { - let count = self - .sessions - .lock() - .ok() - .map(|sessions| { - sessions - .values() - .filter_map(|entry| entry.record.lock().ok()) - .filter(|record| { - project_path_keys_equal(&record.project_path_key, project_path_key) - }) - .count() - }) - .unwrap_or(0); - format!("Terminal {}", count + 1) - } - - fn next_ssh_title(&self, project_path_key: &str, host_name: &str) -> String { - let base = host_name.trim(); - let base = if base.is_empty() { "SSH" } else { base }; - let count = self - .sessions - .lock() - .ok() - .map(|sessions| { - sessions - .values() - .filter_map(|entry| entry.record.lock().ok()) - .filter(|record| { - project_path_keys_equal(&record.project_path_key, project_path_key) - && record.kind == "ssh" - && record.title.starts_with(base) - }) - .count() - }) - .unwrap_or(0); - if count == 0 { - base.to_string() - } else { - format!("{base} {}", count + 1) - } - } - - fn entry(&self, session_id: &str) -> Result, String> { - let id = session_id.trim(); - if id.is_empty() { - return Err("terminal_id is required".to_string()); - } - self.sessions - .lock() - .expect("terminal session registry poisoned") - .get(id) - .cloned() - .ok_or_else(|| format!("terminal session not found: {id}")) - } - - fn record(&self, session_id: String) -> Result { - let entry = self.entry(&session_id)?; - entry - .record - .lock() - .map(|record| record.clone()) - .map_err(|_| "terminal session lock poisoned".to_string()) - } - - fn touch(&self, entry: &Arc) { - if let Ok(mut record) = entry.record.lock() { - record.updated_at = now_ms(); - } - } - - fn append_output(&self, session_id: &str, data: impl Into>) { - let Ok(entry) = self.entry(session_id) else { - return; - }; - let data = data.into(); - if data.is_empty() { - return; - } - let (output_start_offset, output_end_offset) = { - let mut output = match entry.output.lock() { - Ok(output) => output, - Err(_) => return, - }; - output.append(data.clone()) - }; - self.touch(&entry); - self.broadcast_output(&entry, data, output_start_offset, output_end_offset); - } - - fn record_input_echo_candidates(&self, session_id: &str, echo_bytes: Vec) { - if echo_bytes.is_empty() { - return; - } - let Ok(mut states) = self.echo_dispatch.lock() else { - return; - }; - let state = states.entry(session_id.to_string()).or_default(); - state.pending.extend(echo_bytes); - while state.pending.len() > MAX_TAIL_BYTES { - state.pending.pop_front(); - } - } - - fn mark_finished(&self, session_id: &str) { - let Ok(entry) = self.entry(session_id) else { - return; - }; - let mut exit_code = None; - if let TerminalSessionBackend::Local { child, .. } = &entry.backend { - if let Ok(mut child) = child.lock() { - if let Ok(status) = child.try_wait() { - exit_code = status.map(|status| status.exit_code() as i32); - } - } - } - { - let mut record = match entry.record.lock() { - Ok(record) => record, - Err(_) => return, - }; - if record.running { - record.running = false; - record.finished_at = Some(now_ms()); - record.exit_code = exit_code; - record.updated_at = now_ms(); - } - } - self.broadcast("exit", &entry, None, None, None); - } - - fn mark_ssh_reconnecting(&self, entry: &Arc, attempt: u8) { - { - let mut record = match entry.record.lock() { - Ok(record) => record, - Err(_) => return, - }; - record.running = false; - record.finished_at = None; - record.exit_code = None; - record.updated_at = now_ms(); - if let Some(ssh) = record.ssh.as_mut() { - ssh.status = SSH_STATUS_RECONNECTING.to_string(); - ssh.reconnect_attempt = attempt; - ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; - } - } - self.broadcast("reconnecting", entry, None, None, None); - } - - fn mark_ssh_disconnected(&self, entry: &Arc) { - let tab_snapshots = { - let Ok(_tabs_tx) = self.lock_ssh_terminal_tabs_tx() else { - return; - }; - let session_id = { - let mut record = match entry.record.lock() { - Ok(record) => record, - Err(_) => return, - }; - let session_id = record.id.clone(); - record.running = false; - record.finished_at = Some(now_ms()); - record.exit_code = None; - record.updated_at = now_ms(); - if let Some(ssh) = record.ssh.as_mut() { - ssh.status = SSH_STATUS_DISCONNECTED.to_string(); - ssh.reconnect_attempt = SSH_RECONNECT_MAX_ATTEMPTS; - ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; - } - session_id - }; - self.prune_ssh_terminal_tabs_for_session_locked(&session_id) - }; - self.broadcast("exit", entry, None, None, None); - for snapshot in tab_snapshots { - self.broadcast_ssh_tabs_snapshot(snapshot); - } - } - - async fn mark_ssh_shell_ended( - self: Arc, - session_id: String, - runtime: Arc, - connection_id: usize, - message: String, - ) { - if runtime.current_connection_id() != connection_id || runtime.is_closing() { - return; - } - runtime.clear_connection_if_current(connection_id).await; - if message.trim().len() > 0 { - self.append_output(&session_id, message); - } - if let Ok(entry) = self.entry(&session_id) { - self.mark_ssh_disconnected(&entry); - } - } - - fn broadcast( - &self, - kind: &str, - entry: &Arc, - data: Option>, - output_start_offset: Option, - output_end_offset: Option, - ) { - let Ok(record) = entry.record.lock().map(|record| record.clone()) else { - return; - }; - let payload = TerminalEventPayload { - kind: kind.to_string(), - session_id: record.id.clone(), - project_path_key: record.project_path_key.clone(), - session: Some(record), - data, - output_start_offset, - output_end_offset, - ssh_tabs: None, - }; - - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); - } - } - - let subscribers = self - .subscribers - .lock() - .map(|subscribers| subscribers.values().cloned().collect::>()) - .unwrap_or_default(); - let event = TerminalEvent { payload }; - for subscriber in subscribers { - let _ = subscriber.send(event.clone()); - } - } - - fn broadcast_output( - &self, - entry: &Arc, - bytes: Vec, - start_offset: u64, - end_offset: u64, - ) { - let Ok(record) = entry.record.lock().map(|record| record.clone()) else { - return; - }; - let payload = TerminalStreamEventPayload { - kind: "output".to_string(), - session_id: record.id, - project_path_key: record.project_path_key, - start_offset, - end_offset, - bytes, - }; - - let dispatch = self.dispatch_terminal_stream_payload(payload); - self.broadcast_terminal_stream_subscribers(&dispatch.remote); - self.emit_terminal_stream_local(&dispatch.local); - } - - fn dispatch_terminal_stream_payload( - &self, - payload: TerminalStreamEventPayload, - ) -> TerminalOutputDispatch { - let Ok(mut states) = self.echo_dispatch.lock() else { - return TerminalOutputDispatch { - local: vec![payload.clone()], - remote: vec![payload], - }; - }; - let session_id = payload.session_id.clone(); - let dispatch = { - let Some(state) = states.get_mut(&session_id) else { - return TerminalOutputDispatch { - local: vec![payload.clone()], - remote: vec![payload], - }; - }; - state.dispatch(payload) - }; - if states - .get(&session_id) - .is_some_and(TerminalEchoDispatchState::is_empty) - { - states.remove(&session_id); - } - dispatch - } - - fn emit_terminal_stream_local(&self, payloads: &[TerminalStreamEventPayload]) { - if payloads.is_empty() { - return; - } - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - for payload in payloads { - let _ = app_handle.emit(TERMINAL_STREAM_EVENT_NAME, payload); - } - } - } - } - - fn broadcast_terminal_stream_subscribers(&self, payloads: &[TerminalStreamEventPayload]) { - if payloads.is_empty() { - return; - } - let subscribers = self - .stream_subscribers - .lock() - .map(|subscribers| subscribers.values().cloned().collect::>()) - .unwrap_or_default(); - for payload in payloads { - let event = TerminalStreamEvent { - payload: payload.clone(), - }; - for subscriber in &subscribers { - let _ = subscriber.send(event.clone()); - } - } - } - - fn broadcast_ssh_tabs_snapshot(&self, snapshot: SshTerminalTabsSnapshot) { - let payload = TerminalEventPayload { - kind: "ssh_tabs_updated".to_string(), - session_id: String::new(), - project_path_key: snapshot.project_path_key.clone(), - session: None, - data: None, - output_start_offset: None, - output_end_offset: None, - ssh_tabs: Some(snapshot), - }; - - if let Ok(app_handle) = self.app_handle.lock() { - if let Some(app_handle) = app_handle.as_ref() { - let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); - } - } - - let subscribers = self - .subscribers - .lock() - .map(|subscribers| subscribers.values().cloned().collect::>()) - .unwrap_or_default(); - let event = TerminalEvent { payload }; - for subscriber in subscribers { - let _ = subscriber.send(event.clone()); - } - } -} - -pub(crate) struct TerminalSftpConnection { - pub(crate) _handle: client::Handle, - pub(crate) session: russh_sftp::client::SftpSession, -} - -pub(crate) struct LiveAgentSshClient { - host: String, - port: u16, - captured_host_key: Arc>>, -} - -impl client::Handler for LiveAgentSshClient { - type Error = russh::Error; - - async fn check_server_key( - &mut self, - server_public_key: &PublicKey, - ) -> Result { - let key_base64 = - base64::engine::general_purpose::STANDARD.encode(server_public_key.public_key_bytes()); - let key = RuntimeSshKnownHostKey { - host: self.host.clone(), - port: self.port, - key_type: server_public_key.algorithm().as_str().to_string(), - key_base64, - fingerprint_sha256: server_public_key.fingerprint(HashAlg::Sha256).to_string(), - }; - match check_runtime_ssh_known_host(&key) { - Ok(RuntimeSshKnownHostStatus::Known) => Ok(true), - Ok(status) => { - *self.captured_host_key.lock().await = Some(CapturedHostKey { key, status }); - Ok(false) - } - Err(error) => { - *self.captured_host_key.lock().await = Some(CapturedHostKey { - key, - status: RuntimeSshKnownHostStatus::Changed { - stored_fingerprint: error, - }, - }); - Ok(false) - } - } - } -} - -enum ResolvedSshAuth { - Password(String), - PrivateKey { - key: String, - passphrase: Option, - }, - Agent, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SshProxyKind { - Socks5, - Http, -} - -#[derive(Debug, Clone)] -struct ResolvedSshProxy { - kind: SshProxyKind, - host: String, - port: u16, - username: String, - password: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SshPathProfile { - Windows, - Posix, -} - -fn ssh_proxy_configured(host: &RuntimeSshHostConfig) -> bool { - !host.proxy.url.trim().is_empty() - || host.proxy.port > 0 - || !host.proxy.username.trim().is_empty() - || host.proxy.password_configured -} - -async fn connect_ssh_handle( - host_config: &RuntimeSshHostConfig, - captured_host_key: Arc>>, -) -> Result, String> { - let ssh_client = LiveAgentSshClient { - host: host_config.host.clone(), - port: host_config.port, - captured_host_key, - }; - let config = Arc::new(ssh_client_config()); - let stream = open_ssh_transport(host_config).await?; - client::connect_stream(config, stream, ssh_client) - .await - .map_err(|error| format!("SSH connection failed: {error}")) -} - -fn ssh_client_config() -> client::Config { - client::Config { - keepalive_interval: Some(SSH_KEEPALIVE_INTERVAL), - keepalive_max: SSH_KEEPALIVE_MAX_MISSES, - nodelay: true, - ..Default::default() - } -} - -async fn open_ssh_transport(host_config: &RuntimeSshHostConfig) -> Result { - if !ssh_proxy_configured(host_config) { - let stream = TcpStream::connect((host_config.host.as_str(), host_config.port)) - .await - .map_err(|error| { - format!( - "SSH TCP connection to {}:{} failed: {error}", - host_config.host, host_config.port - ) - })?; - configure_ssh_transport_stream(&stream); - return Ok(stream); - } - - let proxy = resolve_ssh_proxy(host_config)?; - let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)) - .await - .map_err(|error| { - format!( - "SSH proxy connection to {}:{} failed: {error}", - proxy.host, proxy.port - ) - })?; - match proxy.kind { - SshProxyKind::Http => { - http_connect_proxy( - &mut stream, - host_config.host.as_str(), - host_config.port, - &proxy, - ) - .await?; - } - SshProxyKind::Socks5 => { - socks5_connect_proxy( - &mut stream, - host_config.host.as_str(), - host_config.port, - &proxy, - ) - .await?; - } - } - configure_ssh_transport_stream(&stream); - Ok(stream) -} - -fn configure_ssh_transport_stream(stream: &TcpStream) { - let _ = stream.set_nodelay(true); -} - -fn resolve_ssh_proxy(host_config: &RuntimeSshHostConfig) -> Result { - let raw_url = host_config.proxy.url.trim(); - if raw_url.is_empty() { - return Err("SSH proxy host is required".to_string()); - } - let (scheme, authority) = split_proxy_scheme(raw_url); - let kind = resolve_proxy_kind(host_config.proxy.proxy_type.as_str(), scheme)?; - let authority = authority - .split(['/', '?', '#']) - .next() - .unwrap_or(authority) - .trim(); - let authority = authority.rsplit('@').next().unwrap_or(authority); - let (proxy_host, url_port) = split_host_port(authority); - if proxy_host.trim().is_empty() { - return Err("SSH proxy host is required".to_string()); - } - let configured_port = u16::try_from(host_config.proxy.port) - .ok() - .filter(|port| *port >= 1); - let default_port = match kind { - SshProxyKind::Socks5 => 1080, - SshProxyKind::Http => 8080, - }; - Ok(ResolvedSshProxy { - kind, - host: proxy_host, - port: configured_port.or(url_port).unwrap_or(default_port), - username: host_config.proxy.username.trim().to_string(), - password: host_config.proxy.password.trim().to_string(), - }) -} - -fn split_proxy_scheme(input: &str) -> (Option<&str>, &str) { - if let Some(index) = input.find("://") { - let (scheme, rest) = input.split_at(index); - return (Some(scheme), &rest[3..]); - } - (None, input) -} - -fn resolve_proxy_kind(raw_type: &str, scheme: Option<&str>) -> Result { - let source = scheme.unwrap_or(raw_type).trim().to_ascii_lowercase(); - match source.as_str() { - "http" => Ok(SshProxyKind::Http), - "" | "socks5" | "socks" => Ok(SshProxyKind::Socks5), - other => Err(format!("SSH proxy type is not supported: {other}")), - } -} - -fn split_host_port(authority: &str) -> (String, Option) { - let authority = authority.trim(); - if let Some(rest) = authority.strip_prefix('[') { - if let Some(end) = rest.find(']') { - let host = rest[..end].to_string(); - let port = rest[end + 1..].strip_prefix(':').and_then(parse_u16_port); - return (host, port); - } - } - if let Some((host, port)) = authority.rsplit_once(':') { - if !host.contains(':') { - return (host.to_string(), parse_u16_port(port)); - } - } - (authority.to_string(), None) -} - -fn parse_u16_port(value: &str) -> Option { - value.trim().parse::().ok().filter(|port| *port >= 1) -} - -async fn http_connect_proxy( - stream: &mut TcpStream, - target_host: &str, - target_port: u16, - proxy: &ResolvedSshProxy, -) -> Result<(), String> { - let target = host_port_authority(target_host, target_port); - let mut request = - format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\nProxy-Connection: Keep-Alive\r\n"); - if !proxy.username.is_empty() || !proxy.password.is_empty() { - let token = base64::engine::general_purpose::STANDARD - .encode(format!("{}:{}", proxy.username, proxy.password)); - request.push_str(&format!("Proxy-Authorization: Basic {token}\r\n")); - } - request.push_str("\r\n"); - stream - .write_all(request.as_bytes()) - .await - .map_err(|error| format!("SSH HTTP proxy CONNECT request failed: {error}"))?; - - let mut response = Vec::with_capacity(512); - let mut byte = [0u8; 1]; - while !response.ends_with(b"\r\n\r\n") { - if response.len() >= 16 * 1024 { - return Err("SSH HTTP proxy CONNECT response is too large".to_string()); - } - let n = stream - .read(&mut byte) - .await - .map_err(|error| format!("SSH HTTP proxy CONNECT response failed: {error}"))?; - if n == 0 { - return Err("SSH HTTP proxy closed before CONNECT completed".to_string()); - } - response.push(byte[0]); - } - let text = String::from_utf8_lossy(&response); - let status_line = text.lines().next().unwrap_or_default(); - let status_code = status_line - .split_whitespace() - .nth(1) - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - if !(200..300).contains(&status_code) { - return Err(format!( - "SSH HTTP proxy CONNECT failed: {}", - status_line.trim() - )); - } - Ok(()) -} - -async fn socks5_connect_proxy( - stream: &mut TcpStream, - target_host: &str, - target_port: u16, - proxy: &ResolvedSshProxy, -) -> Result<(), String> { - let wants_auth = !proxy.username.is_empty() || !proxy.password.is_empty(); - if wants_auth - && (proxy.username.len() > u8::MAX as usize || proxy.password.len() > u8::MAX as usize) - { - return Err("SSH SOCKS5 proxy username/password is too long".to_string()); - } - let greeting: &[u8] = if wants_auth { - &[0x05, 0x02, 0x00, 0x02] - } else { - &[0x05, 0x01, 0x00] - }; - stream - .write_all(greeting) - .await - .map_err(|error| format!("SSH SOCKS5 proxy greeting failed: {error}"))?; - let mut method = [0u8; 2]; - stream - .read_exact(&mut method) - .await - .map_err(|error| format!("SSH SOCKS5 proxy method response failed: {error}"))?; - if method[0] != 0x05 { - return Err("SSH SOCKS5 proxy returned an invalid version".to_string()); - } - match method[1] { - 0x00 => {} - 0x02 => { - let mut auth = Vec::with_capacity(3 + proxy.username.len() + proxy.password.len()); - auth.push(0x01); - auth.push(proxy.username.len() as u8); - auth.extend_from_slice(proxy.username.as_bytes()); - auth.push(proxy.password.len() as u8); - auth.extend_from_slice(proxy.password.as_bytes()); - stream - .write_all(&auth) - .await - .map_err(|error| format!("SSH SOCKS5 proxy auth request failed: {error}"))?; - let mut auth_response = [0u8; 2]; - stream - .read_exact(&mut auth_response) - .await - .map_err(|error| format!("SSH SOCKS5 proxy auth response failed: {error}"))?; - if auth_response != [0x01, 0x00] { - return Err("SSH SOCKS5 proxy authentication failed".to_string()); - } - } - 0xff => return Err("SSH SOCKS5 proxy has no acceptable auth method".to_string()), - other => { - return Err(format!( - "SSH SOCKS5 proxy selected unsupported auth method: {other}" - )) - } - } - - let mut request = Vec::new(); - request.extend_from_slice(&[0x05, 0x01, 0x00]); - write_socks5_address(&mut request, target_host)?; - request.extend_from_slice(&target_port.to_be_bytes()); - stream - .write_all(&request) - .await - .map_err(|error| format!("SSH SOCKS5 proxy CONNECT request failed: {error}"))?; - - let mut response = [0u8; 4]; - stream - .read_exact(&mut response) - .await - .map_err(|error| format!("SSH SOCKS5 proxy CONNECT response failed: {error}"))?; - if response[0] != 0x05 { - return Err("SSH SOCKS5 proxy returned an invalid CONNECT version".to_string()); - } - if response[1] != 0x00 { - return Err(format!( - "SSH SOCKS5 proxy CONNECT failed: {}", - socks5_reply_label(response[1]) - )); - } - let address_len = match response[3] { - 0x01 => 4, - 0x03 => { - let mut len = [0u8; 1]; - stream - .read_exact(&mut len) - .await - .map_err(|error| format!("SSH SOCKS5 proxy response failed: {error}"))?; - usize::from(len[0]) - } - 0x04 => 16, - other => { - return Err(format!( - "SSH SOCKS5 proxy returned unsupported address type: {other}" - )) - } - }; - let mut discard = vec![0u8; address_len + 2]; - stream - .read_exact(&mut discard) - .await - .map_err(|error| format!("SSH SOCKS5 proxy response failed: {error}"))?; - Ok(()) -} - -fn write_socks5_address(out: &mut Vec, host: &str) -> Result<(), String> { - let normalized_host = strip_ipv6_brackets(host.trim()); - if let Ok(ip) = normalized_host.parse::() { - match ip { - IpAddr::V4(ip) => { - out.push(0x01); - out.extend_from_slice(&ip.octets()); - } - IpAddr::V6(ip) => { - out.push(0x04); - out.extend_from_slice(&ip.octets()); - } - } - return Ok(()); - } - if normalized_host.is_empty() || normalized_host.len() > u8::MAX as usize { - return Err("SSH SOCKS5 target host is empty or too long".to_string()); - } - out.push(0x03); - out.push(normalized_host.len() as u8); - out.extend_from_slice(normalized_host.as_bytes()); - Ok(()) -} - -fn strip_ipv6_brackets(host: &str) -> &str { - host.strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host) -} - -fn host_port_authority(host: &str, port: u16) -> String { - let host = host.trim(); - if strip_ipv6_brackets(host).parse::().is_ok() && !host.starts_with('[') { - format!("[{host}]:{port}") - } else { - format!("{host}:{port}") - } -} - -fn socks5_reply_label(code: u8) -> &'static str { - match code { - 0x01 => "general failure", - 0x02 => "connection not allowed", - 0x03 => "network unreachable", - 0x04 => "host unreachable", - 0x05 => "connection refused", - 0x06 => "TTL expired", - 0x07 => "command not supported", - 0x08 => "address type not supported", - _ => "unknown error", - } -} - -fn resolve_ssh_auth_material(host: &RuntimeSshHostConfig) -> Result { - if host.auth_type == "agent" { - Ok(ResolvedSshAuth::Agent) - } else if host.auth_type == "privateKey" { - let key = if !host.private_key.trim().is_empty() { - host.private_key.trim().to_string() - } else { - let path = host.private_key_path.trim(); - if path.is_empty() { - return Err("SSH private key is not configured".to_string()); - } - let expanded = expand_ssh_private_key_path(path); - fs::read_to_string(&expanded) - .map_err(|error| { - format!( - "failed to read SSH private key {}: {error}", - expanded.display() - ) - })? - .trim() - .to_string() - }; - if key.is_empty() { - return Err("SSH private key is empty".to_string()); - } - let passphrase = host.private_key_passphrase.trim().to_string(); - Ok(ResolvedSshAuth::PrivateKey { - key, - passphrase: (!passphrase.is_empty()).then_some(passphrase), - }) - } else { - let password = host.password.trim().to_string(); - if password.is_empty() { - return Err("SSH password is not configured".to_string()); - } - Ok(ResolvedSshAuth::Password(password)) - } -} - -fn expand_ssh_private_key_path(path: &str) -> PathBuf { - let home = dirs::home_dir() - .map(|path| path.to_string_lossy().into_owned()) - .unwrap_or_default(); - let profile = if cfg!(windows) { - SshPathProfile::Windows - } else { - SshPathProfile::Posix - }; - let expanded = expand_ssh_identity_path_for_profile(&home, path, profile); - PathBuf::from(expanded) -} - -fn expand_ssh_identity_path_for_profile( - home_path: &str, - path: &str, - profile: SshPathProfile, -) -> String { - expand_ssh_identity_path_for_profile_with_env(home_path, path, profile, |key| { - std::env::var(key).ok() - }) -} - -fn expand_ssh_identity_path_for_profile_with_env( - home_path: &str, - path: &str, - profile: SshPathProfile, - env: F, -) -> String -where - F: Fn(&str) -> Option, -{ - let trimmed = strip_wrapping_quotes(path); - if trimmed.is_empty() { - return String::new(); - } - match profile { - SshPathProfile::Windows => expand_windows_ssh_identity_path(home_path, &trimmed, env), - SshPathProfile::Posix => expand_posix_ssh_identity_path(home_path, &trimmed), - } -} - -fn strip_wrapping_quotes(path: &str) -> String { - let trimmed = path.trim(); - if trimmed.len() >= 2 { - let first = trimmed.as_bytes()[0] as char; - let last = trimmed.as_bytes()[trimmed.len() - 1] as char; - if (first == '"' && last == '"') || (first == '\'' && last == '\'') { - return trimmed[1..trimmed.len() - 1].to_string(); - } - } - trimmed.to_string() -} - -fn expand_windows_ssh_identity_path(home_path: &str, path: &str, env: F) -> String -where - F: Fn(&str) -> Option, -{ - if is_windows_absolute_path(path) { - return path.to_string(); - } - if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { - return join_windows_identity_path(home_path, rest); - } - if let Some(rest) = path - .strip_prefix("$HOME/") - .or_else(|| path.strip_prefix("$HOME\\")) - { - return join_windows_identity_path(home_path, rest); - } - if let Some(rest) = path - .strip_prefix("${HOME}/") - .or_else(|| path.strip_prefix("${HOME}\\")) - { - return join_windows_identity_path(home_path, rest); - } - if let Some(rest) = strip_prefix_ci(path, "%USERPROFILE%") { - if rest.starts_with('\\') || rest.starts_with('/') { - let user_profile = env("USERPROFILE").unwrap_or_else(|| home_path.to_string()); - return join_windows_identity_path(&user_profile, rest); - } - } - if let Some(rest) = strip_prefix_ci(path, "%HOMEDRIVE%%HOMEPATH%") { - if rest.starts_with('\\') || rest.starts_with('/') { - let home_drive = env("HOMEDRIVE").unwrap_or_default(); - let home_path_env = env("HOMEPATH").unwrap_or_default(); - let home = if home_drive.is_empty() && home_path_env.is_empty() { - home_path.to_string() - } else { - format!("{home_drive}{home_path_env}") - }; - return join_windows_identity_path(&home, rest); - } - } - if path.starts_with('\\') || path.starts_with('/') { - return path.to_string(); - } - join_windows_identity_path(home_path, path) -} - -fn expand_posix_ssh_identity_path(home_path: &str, path: &str) -> String { - if let Some(rest) = path.strip_prefix("~/") { - return join_posix_identity_path(home_path, rest); - } - if let Some(rest) = path.strip_prefix("$HOME/") { - return join_posix_identity_path(home_path, rest); - } - if let Some(rest) = path.strip_prefix("${HOME}/") { - return join_posix_identity_path(home_path, rest); - } - if path.starts_with('/') { - return trim_trailing_posix_slashes(path); - } - join_posix_identity_path(home_path, path) -} - -fn is_windows_absolute_path(path: &str) -> bool { - if path.starts_with(r"\\?\") || path.starts_with(r"//?/") { - return true; - } - if path.len() >= 3 - && path.as_bytes()[1] == b':' - && path.as_bytes()[0].is_ascii_alphabetic() - && matches!(path.as_bytes()[2], b'\\' | b'/') - { - return true; - } - path.starts_with(r"\\") || path.starts_with("//") -} - -fn strip_prefix_ci<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { - value - .get(..prefix.len()) - .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) - .then(|| &value[prefix.len()..]) -} - -fn join_windows_identity_path(base: &str, child: &str) -> String { - let separator = if base.contains('\\') { '\\' } else { '/' }; - let base = base.trim_end_matches(['\\', '/']); - let child = child.trim_start_matches(['\\', '/']); - if child.is_empty() { - base.to_string() - } else if base.is_empty() { - child.to_string() - } else { - format!("{base}{separator}{child}") - } -} - -fn join_posix_identity_path(base: &str, child: &str) -> String { - let base = base.trim_end_matches('/'); - let child = child.trim_start_matches('/'); - if child.is_empty() { - base.to_string() - } else if base.is_empty() { - child.to_string() - } else { - format!("{base}/{child}") - } -} - -fn trim_trailing_posix_slashes(path: &str) -> String { - let mut next = path.to_string(); - while next.len() > 1 && next.ends_with('/') { - next.pop(); - } - next -} - -async fn authenticate_ssh_handle( - handle: &mut client::Handle, - host: &RuntimeSshHostConfig, - auth: ResolvedSshAuth, -) -> Result { - match auth { - ResolvedSshAuth::Password(password) => { - let result = handle - .authenticate_password(host.username.as_str(), password.clone()) - .await - .map_err(|error| format!("SSH password authentication failed: {error}"))?; - if result.success() { - return Ok(SshAuthOutcome::Authenticated); - } - if auth_result_can_continue_with_kbi(&result) { - let response = handle - .authenticate_keyboard_interactive_start(host.username.as_str(), None::) - .await - .map_err(|error| { - format!("SSH keyboard-interactive authentication failed: {error}") - })?; - return continue_keyboard_interactive_auth(handle, response, Some(password)).await; - } - Err("SSH authentication failed".to_string()) - } - ResolvedSshAuth::PrivateKey { key, passphrase } => { - let key_pair = russh::keys::decode_secret_key(&key, passphrase.as_deref()) - .map_err(|error| format!("Invalid SSH private key: {error}"))?; - let key = PrivateKeyWithHashAlg::new(Arc::new(key_pair), Some(HashAlg::Sha256)); - let result = handle - .authenticate_publickey(host.username.as_str(), key) - .await - .map_err(|error| format!("SSH private key authentication failed: {error}"))?; - if result.success() { - return Ok(SshAuthOutcome::Authenticated); - } - if auth_result_can_continue_with_kbi(&result) { - let response = handle - .authenticate_keyboard_interactive_start(host.username.as_str(), None::) - .await - .map_err(|error| { - format!("SSH keyboard-interactive authentication failed: {error}") - })?; - return continue_keyboard_interactive_auth(handle, response, None).await; - } - Err("SSH authentication failed".to_string()) - } - ResolvedSshAuth::Agent => authenticate_ssh_handle_with_agent(handle, host).await, - } -} - -async fn authenticate_ssh_handle_with_agent( - handle: &mut client::Handle, - host: &RuntimeSshHostConfig, -) -> Result { - let mut agent = connect_ssh_agent().await?; - let identities = agent - .request_identities() - .await - .map_err(|error| format!("SSH agent identity lookup failed: {error}"))?; - if identities.is_empty() { - return Err("SSH agent has no identities".to_string()); - } - - let mut can_continue_with_kbi = false; - let mut last_error = String::new(); - for identity in identities { - let result = - authenticate_ssh_agent_identity(handle, host.username.as_str(), &identity, &mut agent) - .await; - let result = match result { - Ok(result) => result, - Err(error) => { - last_error = error; - continue; - } - }; - if result.success() { - return Ok(SshAuthOutcome::Authenticated); - } - can_continue_with_kbi |= auth_result_can_continue_with_kbi(&result); - } - - if can_continue_with_kbi { - let response = handle - .authenticate_keyboard_interactive_start(host.username.as_str(), None::) - .await - .map_err(|error| format!("SSH keyboard-interactive authentication failed: {error}"))?; - return continue_keyboard_interactive_auth(handle, response, None).await; - } - - if last_error.is_empty() { - Err("SSH agent authentication failed".to_string()) - } else { - Err(format!("SSH agent authentication failed: {last_error}")) - } -} - -async fn authenticate_ssh_agent_identity( - handle: &mut client::Handle, - username: &str, - identity: &AgentIdentity, - agent: &mut AgentClient>, -) -> Result { - match identity { - AgentIdentity::PublicKey { key, .. } => handle - .authenticate_publickey_with(username, key.clone(), Some(HashAlg::Sha256), agent) - .await - .map_err(|error| error.to_string()), - AgentIdentity::Certificate { certificate, .. } => handle - .authenticate_certificate_with( - username, - certificate.clone(), - Some(HashAlg::Sha256), - agent, - ) - .await - .map_err(|error| error.to_string()), - } -} - -async fn connect_ssh_agent() -> Result>, String> { - #[cfg(windows)] - { - let mut errors = Vec::new(); - match AgentClient::connect_pageant().await { - Ok(agent) => return Ok(agent.dynamic()), - Err(error) => errors.push(format!("Pageant: {error}")), - } - if let Ok(sock) = std::env::var("SSH_AUTH_SOCK") { - let sock = sock.trim(); - if !sock.is_empty() { - match AgentClient::connect_named_pipe(sock).await { - Ok(agent) => return Ok(agent.dynamic()), - Err(error) => errors.push(format!("SSH_AUTH_SOCK named pipe: {error}")), - } - } - } - match AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await { - Ok(agent) => return Ok(agent.dynamic()), - Err(error) => errors.push(format!("OpenSSH named pipe: {error}")), - } - Err(format!( - "SSH agent is not available ({})", - errors.join("; ") - )) - } - - #[cfg(unix)] - { - AgentClient::connect_env() - .await - .map(|agent| agent.dynamic()) - .map_err(|error| format!("SSH agent is not available: {error}")) - } - - #[cfg(not(any(unix, windows)))] - { - Err("SSH agent is not supported on this platform".to_string()) - } -} - -fn auth_result_can_continue_with_kbi(result: &client::AuthResult) -> bool { - matches!( - result, - client::AuthResult::Failure { - remaining_methods, - .. - } if remaining_methods.contains(&MethodKind::KeyboardInteractive) - ) -} - -fn prompt_looks_like_password(prompt: &str) -> bool { - let normalized = prompt.trim().to_ascii_lowercase(); - normalized.contains("password") || prompt.contains("密码") -} - -fn classify_password_kbi_prompts( - prompts: &[client::Prompt], - password_prompt_consumed: bool, -) -> PasswordKbiPromptAction { - if prompts.is_empty() { - PasswordKbiPromptAction::RespondEmpty - } else if !password_prompt_consumed - && prompts.len() == 1 - && !prompts[0].echo - && prompt_looks_like_password(&prompts[0].prompt) - { - PasswordKbiPromptAction::SendPassword - } else { - PasswordKbiPromptAction::PromptUser - } -} - -async fn continue_keyboard_interactive_auth( - handle: &mut client::Handle, - mut response: client::KeyboardInteractiveAuthResponse, - auto_password: Option, -) -> Result { - let mut password_prompt_consumed = false; - for _ in 0..5 { - match response { - client::KeyboardInteractiveAuthResponse::Success => { - return Ok(SshAuthOutcome::Authenticated); - } - client::KeyboardInteractiveAuthResponse::Failure { .. } => { - return Err("SSH keyboard-interactive authentication failed".to_string()); - } - client::KeyboardInteractiveAuthResponse::InfoRequest { - name, - instructions, - prompts, - } => match classify_password_kbi_prompts(&prompts, password_prompt_consumed) { - PasswordKbiPromptAction::RespondEmpty => { - response = handle - .authenticate_keyboard_interactive_respond(Vec::new()) - .await - .map_err(|error| { - format!("SSH keyboard-interactive response failed: {error}") - })?; - } - PasswordKbiPromptAction::SendPassword if auto_password.is_some() => { - password_prompt_consumed = true; - response = handle - .authenticate_keyboard_interactive_respond(vec![auto_password - .clone() - .unwrap_or_default()]) - .await - .map_err(|error| { - format!("SSH keyboard-interactive response failed: {error}") - })?; - } - PasswordKbiPromptAction::SendPassword | PasswordKbiPromptAction::PromptUser => { - if prompts.len() != 1 { - return Err( - "SSH keyboard-interactive requested multiple prompts, which is not supported in V1." - .to_string(), - ); - } - let prompt = prompts - .into_iter() - .next() - .ok_or_else(|| "SSH keyboard-interactive prompt is empty".to_string())?; - return Ok(SshAuthOutcome::KeyboardInteractivePrompt( - KeyboardInteractivePromptData { - name, - instructions, - prompt: prompt.prompt, - echo: prompt.echo, - }, - )); - } - }, - } - } - Err("SSH keyboard-interactive exceeded maximum prompt rounds".to_string()) -} - -fn ssh_keyboard_interactive_message(prompt_data: &KeyboardInteractivePromptData) -> String { - let mut parts = Vec::new(); - if !prompt_data.name.trim().is_empty() { - parts.push(prompt_data.name.trim().to_string()); - } - if !prompt_data.instructions.trim().is_empty() { - parts.push(prompt_data.instructions.trim().to_string()); - } - if !prompt_data.prompt.trim().is_empty() { - parts.push(prompt_data.prompt.trim().to_string()); - } - if parts.is_empty() { - "SSH keyboard-interactive authentication requires input.".to_string() - } else { - parts.join("\n") - } -} - -async fn open_ssh_shell_channel( - handle: &client::Handle, - size: TerminalSize, -) -> Result, String> { - let channel = handle - .channel_open_session() - .await - .map_err(|error| format!("SSH channel open failed: {error}"))?; - channel - .request_pty( - false, - "xterm-256color", - u32::from(size.cols), - u32::from(size.rows), - 0, - 0, - &[], - ) - .await - .map_err(|error| format!("SSH PTY request failed: {error}"))?; - channel - .request_shell(false) - .await - .map_err(|error| format!("SSH shell request failed: {error}"))?; - Ok(channel) -} - -pub(crate) async fn open_sftp_connection_for_host( - ssh_host_id: &str, -) -> Result { - let host_config = load_runtime_ssh_host(ssh_host_id)? - .ok_or_else(|| format!("SSH host not found: {}", ssh_host_id.trim()))?; - if host_config.host.trim().is_empty() { - return Err("SSH host is required".to_string()); - } - if host_config.username.trim().is_empty() { - return Err("SSH username is required".to_string()); - } - - let auth = resolve_ssh_auth_material(&host_config)?; - let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); - let mut handle = match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { - Ok(handle) => handle, - Err(error) => { - if captured_host_key.lock().await.is_some() { - return Err("SSH host key requires confirmation before opening SFTP".to_string()); - } - return Err(error); - } - }; - - match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { - SshAuthOutcome::Authenticated => {} - SshAuthOutcome::KeyboardInteractivePrompt(_) => { - let _ = handle - .disconnect( - russh::Disconnect::ByApplication, - "Keyboard-interactive SFTP authentication requires Bash prompt first", - "en", - ) - .await; - return Err( - "SSH keyboard-interactive authentication requires opening Bash first".to_string(), - ); - } - } - - let channel = handle - .channel_open_session() - .await - .map_err(|error| format!("SFTP channel open failed: {error}"))?; - channel - .request_subsystem(true, "sftp") - .await - .map_err(|error| format!("SFTP subsystem request failed: {error}"))?; - let session = russh_sftp::client::SftpSession::new(channel.into_stream()) - .await - .map_err(|error| format!("SFTP session failed: {error}"))?; - Ok(TerminalSftpConnection { - _handle: handle, - session, - }) -} - -async fn run_ssh_exec_channel( - runtime: &Arc, - command: String, - max_bytes: usize, -) -> Result { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let mut stdout_truncated = false; - let mut stderr_truncated = false; - let mut exit_code = None; - let mut exit_signal = None; - - let channel = { - let handle = runtime.handle.lock().await; - let Some(handle) = handle.as_ref() else { - return Err("SSH connection is not connected".to_string()); - }; - handle - .channel_open_session() - .await - .map_err(|error| format!("SSH exec channel open failed: {error}"))? - }; - channel - .exec(true, command.into_bytes()) - .await - .map_err(|error| format!("SSH exec request failed: {error}"))?; - let (mut read_half, _write_half) = channel.split(); - - loop { - match read_half.wait().await { - Some(ChannelMsg::Data { data }) => { - append_limited(&mut stdout, data.as_ref(), max_bytes, &mut stdout_truncated); - } - Some(ChannelMsg::ExtendedData { data, .. }) => { - append_limited(&mut stderr, data.as_ref(), max_bytes, &mut stderr_truncated); - } - Some(ChannelMsg::ExitStatus { exit_status }) => { - exit_code = Some(exit_status); - } - Some(ChannelMsg::ExitSignal { signal_name, .. }) => { - exit_signal = Some(format!("{signal_name:?}")); - } - Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, - _ => {} - } - } - - Ok(TerminalSshExecResponse { - session_id: String::new(), - command: String::new(), - cwd: None, - exit_code, - exit_signal, - stdout: String::from_utf8_lossy(&stdout).to_string(), - stderr: String::from_utf8_lossy(&stderr).to_string(), - stdout_truncated, - stderr_truncated, - timed_out: false, - duration_ms: 0, - }) -} - -fn normalize_ssh_exec_timeout(timeout_ms: Option) -> Duration { - let requested = timeout_ms - .filter(|value| *value > 0) - .map(Duration::from_millis) - .unwrap_or(SSH_EXEC_DEFAULT_TIMEOUT); - requested.clamp(Duration::from_secs(1), SSH_EXEC_MAX_TIMEOUT) -} - -fn normalize_ssh_exec_max_bytes(max_bytes: Option) -> usize { - max_bytes - .filter(|value| *value > 0) - .unwrap_or(SSH_EXEC_DEFAULT_MAX_BYTES) - .clamp(4 * 1024, SSH_EXEC_MAX_BYTES) -} - -fn append_limited(buffer: &mut Vec, data: &[u8], max_bytes: usize, truncated: &mut bool) { - if buffer.len() >= max_bytes { - if !data.is_empty() { - *truncated = true; - } - return; - } - let remaining = max_bytes - buffer.len(); - if data.len() > remaining { - buffer.extend_from_slice(&data[..remaining]); - *truncated = true; - } else { - buffer.extend_from_slice(data); - } -} - -fn wrap_ssh_exec_command(command: &str, cwd: Option<&str>) -> String { - match cwd.map(str::trim).filter(|value| !value.is_empty()) { - Some(cwd) => format!("cd {} && {}", shell_single_quote(cwd), command), - None => command.to_string(), - } -} - -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -async fn run_ssh_session_io( - registry: Arc, - session_id: String, - runtime: Arc, - connection_id: usize, - channel: russh::Channel, - mut input_rx: tokio::sync::mpsc::Receiver, - mut shutdown_rx: tokio::sync::mpsc::Receiver<()>, -) { - let (mut read_half, write_half) = channel.split(); - let (writer_end_tx, mut writer_end_rx) = tokio::sync::mpsc::channel::(1); - let writer_runtime = Arc::clone(&runtime); - tauri::async_runtime::spawn(async move { - let mut writer = write_half.make_writer(); - let reason = loop { - tokio::select! { - _ = shutdown_rx.recv() => { - let handle = writer_runtime.handle.lock().await; - if let Some(handle) = handle.as_ref() { - let _ = handle.disconnect(russh::Disconnect::ByApplication, "User disconnected", "en").await; - } - break SshSessionIoEndReason::Shutdown; - } - input = input_rx.recv() => { - match input { - Some(SshSessionInput::Data(data)) => { - if writer.write_all(&data).await.is_err() { - break SshSessionIoEndReason::WriteFailed; - } - } - Some(SshSessionInput::Resize(cols, rows)) => { - let _ = write_half.window_change(cols, rows, 0, 0).await; - } - None => { - break SshSessionIoEndReason::InputClosed; - }, - } - } - } - }; - let _ = writer_end_tx.send(reason).await; - }); - let mut remote_exit_reason: Option = None; - let end_reason = loop { - tokio::select! { - reason = writer_end_rx.recv() => { - break reason.unwrap_or(SshSessionIoEndReason::InputClosed); - } - message = read_half.wait() => { - match message { - Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { - registry.append_output(&session_id, data.as_ref().to_vec()); - } - Some(ChannelMsg::ExitStatus { exit_status }) => { - remote_exit_reason = Some(SshSessionIoEndReason::RemoteExitStatus(exit_status)); - } - Some(ChannelMsg::ExitSignal { signal_name, .. }) => { - remote_exit_reason = Some(SshSessionIoEndReason::RemoteExitSignal(format!("{signal_name:?}"))); - } - Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) => { - break remote_exit_reason.unwrap_or(SshSessionIoEndReason::RemoteClosed); - } - None => { - break remote_exit_reason.unwrap_or(SshSessionIoEndReason::ConnectionLost); - } - _ => {} - } - } - } - }; - - finish_ssh_session_io(registry, session_id, runtime, connection_id, end_reason).await; -} - -async fn finish_ssh_session_io( - registry: Arc, - session_id: String, - runtime: Arc, - connection_id: usize, - end_reason: SshSessionIoEndReason, -) { - if runtime.is_closing() { - return; - } - match end_reason { - SshSessionIoEndReason::Shutdown | SshSessionIoEndReason::InputClosed => {} - SshSessionIoEndReason::RemoteExitStatus(status) => { - registry - .mark_ssh_shell_ended( - session_id, - runtime, - connection_id, - format!("\r\n[SSH] Remote shell exited with status {status}.\r\n"), - ) - .await; - } - SshSessionIoEndReason::RemoteExitSignal(signal) => { - registry - .mark_ssh_shell_ended( - session_id, - runtime, - connection_id, - format!("\r\n[SSH] Remote shell exited after signal {signal}.\r\n"), - ) - .await; - } - SshSessionIoEndReason::RemoteClosed => { - registry - .mark_ssh_shell_ended( - session_id, - runtime, - connection_id, - "\r\n[SSH] Remote shell closed.\r\n".to_string(), - ) - .await; - } - SshSessionIoEndReason::ConnectionLost => { - if ssh_connection_alive(&runtime, connection_id).await { - registry - .mark_ssh_shell_ended( - session_id, - runtime, - connection_id, - "\r\n[SSH] Remote shell closed.\r\n".to_string(), - ) - .await; - } else { - spawn_ssh_reconnect_runner(registry, session_id, runtime, connection_id); - } - } - SshSessionIoEndReason::WriteFailed => { - spawn_ssh_reconnect_runner(registry, session_id, runtime, connection_id); - } - } -} - -async fn ssh_connection_alive(runtime: &Arc, connection_id: usize) -> bool { - if runtime.current_connection_id() != connection_id || runtime.is_closing() { - return false; - } - let ping = timeout(Duration::from_secs(2), async { - let handle = runtime.handle.lock().await; - let Some(handle) = handle.as_ref() else { - return Err(russh::Error::Disconnect); - }; - handle.send_ping().await - }) - .await; - matches!(ping, Ok(Ok(()))) -} - -fn spawn_ssh_reconnect_runner( - registry: Arc, - session_id: String, - runtime: Arc, - connection_id: usize, -) { - // russh drives each session on the current Tokio runtime, so reconnects must - // live on Tauri's long-running runtime rather than a short-lived thread runtime. - tauri::async_runtime::spawn(async move { - registry - .handle_ssh_unexpected_disconnect(session_id, runtime, connection_id) - .await; - }); -} - -fn terminal_ssh_create_response_from_snapshot( - snapshot: TerminalSnapshotResponse, -) -> TerminalSshCreateResponse { - TerminalSshCreateResponse { - session: Some(snapshot.session), - output: snapshot.output, - output_bytes: snapshot.output_bytes, - truncated: snapshot.truncated, - output_start_offset: snapshot.output_start_offset, - output_end_offset: snapshot.output_end_offset, - ssh_prompt: None, - } -} - -fn required_project_key(project_path_key: String) -> Result { - let project_key = normalize_project_path_key(&project_path_key); - if project_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - Ok(project_key) -} - -fn normalize_ssh_terminal_tab_kind(kind: &str) -> Result { - match kind.trim().to_ascii_lowercase().as_str() { - "bash" => Ok("bash".to_string()), - "sftp" => Ok("sftp".to_string()), - "" => Err("tab kind is required".to_string()), - other => Err(format!("unsupported ssh terminal tab kind: {other}")), - } -} - -fn ssh_terminal_tab_id(session_id: &str, kind: &str) -> String { - format!("{}:{}", kind.trim(), session_id.trim()) -} - -fn ssh_terminal_tabs_snapshot_from_state( - project_path_key: &str, - state: &SshTerminalTabsState, -) -> SshTerminalTabsSnapshot { - SshTerminalTabsSnapshot { - project_path_key: project_path_key.to_string(), - tabs: state.tabs.clone(), - revision: state.revision, - } -} - -pub struct TerminalSubscriberGuard { - id: usize, - subscribers: Arc>>>, -} - -impl Drop for TerminalSubscriberGuard { - fn drop(&mut self) { - if let Ok(mut subscribers) = self.subscribers.lock() { - subscribers.remove(&self.id); - } - } -} - -pub struct TerminalStreamSubscriberGuard { - id: usize, - subscribers: Arc>>>, -} - -impl Drop for TerminalStreamSubscriberGuard { - fn drop(&mut self) { - if let Ok(mut subscribers) = self.subscribers.lock() { - subscribers.remove(&self.id); - } - } -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct TerminalReadTailResponse { - pub sessions: Vec, - pub selected_session: Option, - pub output: String, - pub truncated: bool, -} - -fn read_output_tail(entry: &TerminalSessionEntry, max_bytes: usize) -> TerminalOutputTail { - let output = match entry.output.lock() { - Ok(output) => output, - Err(_) => { - return TerminalOutputTail { - output: Vec::new(), - truncated: false, - output_start_offset: 0, - output_end_offset: 0, - } - } - }; - read_output_chunks_tail(&output, max_bytes) -} - -fn read_output_chunks_tail(output: &TerminalOutputBuffer, max_bytes: usize) -> TerminalOutputTail { - let output_end_offset = output.next_offset; - if max_bytes == 0 { - return TerminalOutputTail { - output: Vec::new(), - truncated: output_end_offset > 0, - output_start_offset: output_end_offset, - output_end_offset, - }; - } - let mut remaining = max_bytes; - let mut chunks = VecDeque::new(); - let mut truncated = false; - for chunk in output.chunks.iter().rev() { - if remaining == 0 { - truncated = true; - break; - } - let len = chunk.data.len(); - if len > remaining { - let start = len.saturating_sub(remaining); - chunks.push_front(TerminalOutputChunk { - start_offset: chunk.start_offset.saturating_add(start as u64), - data: chunk.data[start..].to_vec(), - }); - truncated = true; - break; - } - remaining = remaining.saturating_sub(len); - chunks.push_front(chunk.clone()); - } - let output_start_offset = chunks - .front() - .map(|chunk| chunk.start_offset) - .unwrap_or(output_end_offset); - let mut output_bytes = Vec::new(); - for chunk in chunks { - output_bytes.extend_from_slice(&chunk.data); - } - TerminalOutputTail { - output: output_bytes, - truncated: truncated || output_start_offset > 0, - output_start_offset, - output_end_offset, - } -} - -fn terminate_terminal_entry(entry: &Arc) { - match &entry.backend { - TerminalSessionBackend::Local { child, .. } => { - let pid = entry.record.lock().ok().and_then(|record| record.pid); - terminate_process_tree_best_effort(pid); - if let Ok(mut child) = child.lock() { - let _ = child.kill(); - } - } - TerminalSessionBackend::Ssh { runtime } => { - if let Some(shutdown_tx) = runtime.close() { - let _ = shutdown_tx.try_send(()); - } - } - } -} - -fn terminate_process_tree_best_effort(pid: Option) { - let Some(pid) = pid else { - return; - }; - if pid == 0 { - return; - } - - #[cfg(windows)] - { - // `taskkill` is a console app; hide its window so app exit stays clean. - let mut command = std::process::Command::new("taskkill"); - configure_child_process_group(&mut command); - let _ = command - .args(["/PID", &pid.to_string(), "/T", "/F"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } - - #[cfg(unix)] - { - let _ = std::process::Command::new("kill") - .args(["-TERM", &format!("-{pid}")]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status(); - } -} - -fn now_ms() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() -} - -fn canonicalize_workdir(workdir: &str) -> Result { - let raw = workdir.trim(); - if raw.is_empty() { - return Err("workdir is required".to_string()); - } - let path = expand_tilde_path(raw); - if !path.is_absolute() { - return Err(format!("workdir must be absolute: {workdir}")); - } - let metadata = fs::metadata(&path).map_err(|_| format!("workdir does not exist: {workdir}"))?; - if !metadata.is_dir() { - return Err(format!("workdir must be a directory: {workdir}")); - } - let canonical = - fs::canonicalize(&path).map_err(|err| format!("failed to canonicalize workdir: {err}"))?; - Ok(strip_windows_unc_prefix(canonical)) -} - -fn strip_windows_unc_prefix(path: PathBuf) -> PathBuf { - #[cfg(windows)] - { - let s = path.to_string_lossy(); - if let Some(stripped) = s.strip_prefix(r"\\?\") { - return PathBuf::from(stripped); - } - } - path -} - -fn is_program_on_path(program: &str) -> bool { - let path_var = std::env::var_os("PATH").unwrap_or_default(); - for dir in std::env::split_paths(&path_var) { - if dir.join(program).is_file() { - return true; - } - } - false -} - -fn scrub_terminal_shell_env(cmd: &mut CommandBuilder) { - for key in ["npm_config_prefix", "NPM_CONFIG_PREFIX"] { - cmd.env_remove(key); - } -} - -fn configure_terminal_shell_env(cmd: &mut CommandBuilder, shell_command: &str) { - scrub_terminal_shell_env(cmd); - cmd.env("TERM", "xterm-256color"); - cmd.env("COLORTERM", "truecolor"); - if is_zsh_shell(shell_command) { - configure_zsh_colored_prompt(cmd); - } -} - -fn configure_zsh_colored_prompt(cmd: &mut CommandBuilder) { - let colored_prompt = "%F{green}%n%f%F{yellow}@%f%F{blue}%m%f %F{magenta}%1~%f %F{cyan}%#%f "; - let zdotdir = create_zsh_prompt_overlay(colored_prompt); - if let Some(dir) = zdotdir { - cmd.env("ZDOTDIR", dir.to_string_lossy().as_ref()); - } -} - -fn create_zsh_prompt_overlay(prompt: &str) -> Option { - let base = dirs::cache_dir() - .or_else(dirs::home_dir) - .unwrap_or_else(|| PathBuf::from("/tmp")); - let zdotdir = base.join("liveagent-zsh"); - if fs::create_dir_all(&zdotdir).is_err() { - return None; - } - - let home = dirs::home_dir().unwrap_or_default(); - let user_zshrc = home.join(".zshrc"); - let user_zshenv = home.join(".zshenv"); - - let zshenv_content = format!( - "export _LIVEAGENT_REAL_ZDOTDIR=\"$HOME\"\n\ - [[ -f \"{}\" ]] && source \"{}\"\n", - user_zshenv.display(), - user_zshenv.display(), - ); - let zshrc_content = format!( - "[[ -f \"{}\" ]] && source \"{}\"\n\ - PROMPT='{}'\n\ - unset ZDOTDIR\n", - user_zshrc.display(), - user_zshrc.display(), - prompt, - ); - - if fs::write(zdotdir.join(".zshenv"), zshenv_content).is_err() { - return None; - } - if fs::write(zdotdir.join(".zshrc"), zshrc_content).is_err() { - return None; - } - Some(zdotdir) -} - -struct ShellSpec { - label: String, - command: String, - args: Vec, -} - -fn resolve_shell(shell: Option) -> Result { - let requested = shell - .map(|value| value.trim().to_ascii_lowercase()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| "default".to_string()); - - if cfg!(windows) { - let powershell_args = vec![ - "-NoLogo".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - ]; - match requested.as_str() { - "pwsh" => Ok(ShellSpec { - label: "PowerShell 7".to_string(), - command: "pwsh.exe".to_string(), - args: powershell_args, - }), - "powershell" | "default" => Ok(ShellSpec { - label: "PowerShell".to_string(), - command: "powershell.exe".to_string(), - args: powershell_args, - }), - "cmd" => Ok(ShellSpec { - label: "Cmd".to_string(), - command: "cmd.exe".to_string(), - args: Vec::new(), - }), - other => Err(format!("unsupported Windows terminal shell: {other}")), - } - } else { - let command = std::env::var("SHELL") - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty() && Path::new(value).is_absolute()) - .or_else(resolve_unix_shell_fallback) - .ok_or_else(|| "failed to resolve login shell".to_string())?; - let label = Path::new(&command) - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("shell") - .to_string(); - Ok(ShellSpec { - label, - args: unix_shell_args(&command), - command, - }) - } -} - -fn unix_shell_args(command: &str) -> Vec { - if is_zsh_shell(command) { - return vec!["-o".to_string(), "NO_PROMPT_SP".to_string()]; - } - Vec::new() -} - -fn is_zsh_shell(command: &str) -> bool { - Path::new(command) - .file_name() - .and_then(|value| value.to_str()) - .map(|value| value.eq_ignore_ascii_case("zsh")) - .unwrap_or(false) -} - -fn resolve_unix_shell_fallback() -> Option { - let candidates: &[&str] = if cfg!(target_os = "macos") { - &["/bin/zsh", "/bin/bash", "/bin/sh"] - } else { - &["/bin/bash", "/bin/zsh", "/bin/sh"] - }; - candidates - .iter() - .find(|candidate| Path::new(candidate).exists()) - .map(|value| (*value).to_string()) -} - -pub fn terminal_shell_options() -> TerminalShellOptionsResponse { - if cfg!(windows) { - let mut options = vec![ - TerminalShellOption { - id: "powershell".to_string(), - label: "PowerShell".to_string(), - command: "powershell.exe".to_string(), - }, - TerminalShellOption { - id: "cmd".to_string(), - label: "Cmd".to_string(), - command: "cmd.exe".to_string(), - }, - ]; - if is_program_on_path("pwsh.exe") { - options.insert( - 0, - TerminalShellOption { - id: "pwsh".to_string(), - label: "PowerShell 7".to_string(), - command: "pwsh.exe".to_string(), - }, - ); - } - TerminalShellOptionsResponse { - default_shell: "powershell".to_string(), - options, - } - } else { - let shell = resolve_shell(None).unwrap_or_else(|_| ShellSpec { - label: "sh".to_string(), - command: "/bin/sh".to_string(), - args: Vec::new(), - }); - TerminalShellOptionsResponse { - default_shell: "default".to_string(), - options: vec![TerminalShellOption { - id: "default".to_string(), - label: shell.label, - command: shell.command, - }], - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn shell_options_include_default() { - let options = terminal_shell_options(); - assert!(!options.default_shell.trim().is_empty()); - assert!(!options.options.is_empty()); - } - - #[test] - fn ssh_client_config_enables_interactive_keepalive() { - let config = ssh_client_config(); - - assert_eq!(config.keepalive_interval, Some(SSH_KEEPALIVE_INTERVAL)); - assert_eq!(config.keepalive_max, SSH_KEEPALIVE_MAX_MISSES); - assert!(config.nodelay); - } - - #[test] - fn output_tail_respects_byte_limit_inside_large_chunk() { - let mut output = TerminalOutputBuffer::default(); - output.append(b"prefix".to_vec()); - output.append(b"abcdefghijklmnopqrstuvwxyz".to_vec()); - - let tail = read_output_chunks_tail(&output, 8); - - assert_eq!(tail.output, b"stuvwxyz"); - assert_eq!(tail.output_start_offset, 24); - assert_eq!(tail.output_end_offset, 32); - assert!(tail.truncated); - } - - #[test] - fn output_tail_keeps_offsets_for_repeated_text() { - let mut output = TerminalOutputBuffer::default(); - output.append(b"uploads\n".to_vec()); - output.append(b"uploads\n".to_vec()); - - let tail = read_output_chunks_tail(&output, MAX_TAIL_BYTES); - - assert_eq!(tail.output, b"uploads\nuploads\n"); - assert_eq!(tail.output_start_offset, 0); - assert_eq!(tail.output_end_offset, 16); - assert!(!tail.truncated); - } - - #[test] - fn remote_input_echo_is_delayed_for_local_until_enter() { - let mut state = TerminalEchoDispatchState::default(); - state - .pending - .extend(b"echo hi\r".iter().copied().map(|byte| PendingEchoByte { - byte, - origin: TerminalInputOrigin::Remote, - })); - - let first = state.dispatch(test_stream_payload(0, b"echo")); - assert_eq!(collect_payload_bytes(&first.remote), b"echo"); - assert!(first.local.is_empty()); - - let second = state.dispatch(test_stream_payload(4, b" hi\r\n")); - assert_eq!(collect_payload_bytes(&second.remote), b" hi\r\n"); - assert_eq!(collect_payload_bytes(&second.local), b"echo hi\r\n"); - assert!(state.is_empty()); - } - - #[test] - fn local_input_echo_is_delayed_for_remote_until_enter() { - let mut state = TerminalEchoDispatchState::default(); - state - .pending - .extend(b"pwd\r".iter().copied().map(|byte| PendingEchoByte { - byte, - origin: TerminalInputOrigin::Local, - })); - - let first = state.dispatch(test_stream_payload(10, b"pw")); - assert_eq!(collect_payload_bytes(&first.local), b"pw"); - assert!(first.remote.is_empty()); - - let second = state.dispatch(test_stream_payload(12, b"d\r\n")); - assert_eq!(collect_payload_bytes(&second.local), b"d\r\n"); - assert_eq!(collect_payload_bytes(&second.remote), b"pwd\r\n"); - assert!(state.is_empty()); - } - - #[test] - fn no_echo_password_input_does_not_leak_to_other_side() { - let mut state = TerminalEchoDispatchState::default(); - state - .pending - .extend(b"secret\r".iter().copied().map(|byte| PendingEchoByte { - byte, - origin: TerminalInputOrigin::Remote, - })); - - let dispatch = state.dispatch(test_stream_payload(30, b"\r\n")); - - assert_eq!(collect_payload_bytes(&dispatch.local), b"\r\n"); - assert_eq!(collect_payload_bytes(&dispatch.remote), b"\r\n"); - assert!(state.is_empty()); - } - - #[test] - fn non_echo_output_stays_visible_to_both_sides() { - let mut state = TerminalEchoDispatchState::default(); - state.pending.push_back(PendingEchoByte { - byte: b'a', - origin: TerminalInputOrigin::Remote, - }); - - let dispatch = state.dispatch(test_stream_payload(50, b"build\n")); - - assert_eq!(collect_payload_bytes(&dispatch.local), b"build\n"); - assert_eq!(collect_payload_bytes(&dispatch.remote), b"build\n"); - assert!(!state.is_empty()); - } - - #[test] - fn input_echo_candidates_skip_escape_sequences() { - let candidates = - terminal_input_echo_candidates(b"a\x1b[A\x1b[1;5Cb\r", TerminalInputOrigin::Remote); - let bytes = candidates - .iter() - .map(|candidate| candidate.byte) - .collect::>(); - - assert_eq!(bytes, b"ab\r"); - } - - #[test] - fn failed_input_enqueue_does_not_record_pending_echo() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - - let result = registry.input_bytes_from_remote("ssh-1".to_string(), b"secret\r".to_vec()); - - assert!(result.is_err()); - let states = registry - .echo_dispatch - .lock() - .expect("terminal echo dispatch lock"); - assert!(!states.contains_key("ssh-1")); - } - - fn insert_test_ssh_session( - registry: &TerminalSessionRegistry, - id: &str, - project_path_key: &str, - sftp_enabled: bool, - status: &str, - ) { - let now = now_ms(); - let record = TerminalSessionRecord { - id: id.to_string(), - project_path_key: normalize_project_path_key(project_path_key), - cwd: project_path_key.to_string(), - shell: "ssh".to_string(), - title: id.to_string(), - kind: "ssh".to_string(), - ssh: Some(TerminalSshMetadata { - host_id: format!("host-{id}"), - host_name: format!("Host {id}"), - username: "tester".to_string(), - host: "127.0.0.1".to_string(), - port: 22, - auth_type: "password".to_string(), - status: status.to_string(), - reconnect_attempt: 0, - reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS, - sftp_enabled, - }), - pid: None, - cols: DEFAULT_COLS, - rows: DEFAULT_ROWS, - created_at: now, - updated_at: now, - finished_at: None, - exit_code: None, - running: status == SSH_STATUS_CONNECTED, - }; - let entry = Arc::new(TerminalSessionEntry { - backend: TerminalSessionBackend::Ssh { - runtime: Arc::new(SshSessionRuntime::new()), - }, - record: Mutex::new(record), - output: Mutex::new(TerminalOutputBuffer::default()), - }); - registry - .sessions - .lock() - .expect("terminal session registry poisoned") - .insert(id.to_string(), entry); - } - - fn test_stream_payload(start_offset: u64, bytes: &[u8]) -> TerminalStreamEventPayload { - TerminalStreamEventPayload { - kind: "output".to_string(), - session_id: "terminal-1".to_string(), - project_path_key: "/tmp/project".to_string(), - start_offset, - end_offset: start_offset + bytes.len() as u64, - bytes: bytes.to_vec(), - } - } - - fn collect_payload_bytes(payloads: &[TerminalStreamEventPayload]) -> Vec { - payloads - .iter() - .flat_map(|payload| payload.bytes.iter().copied()) - .collect() - } - - #[test] - fn ssh_terminal_tab_open_is_idempotent_without_shared_active() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - - let first = registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect("open bash tab"); - let second = registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect("reopen bash tab"); - let sftp = registry - .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) - .expect("open sftp tab"); - - assert_eq!(first.tabs.len(), 1); - assert_eq!(second.tabs.len(), 1); - assert_eq!(sftp.tabs.len(), 2); - assert_eq!(first.revision, second.revision); - } - - #[test] - fn ssh_terminal_tab_close_is_global_without_closing_session() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - insert_test_ssh_session( - ®istry, - "ssh-2", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect("open first tab"); - registry - .ssh_terminal_tab_open("ssh-2".to_string(), "bash".to_string()) - .expect("open second tab"); - - let snapshot = registry - .ssh_terminal_tab_close("bash:ssh-1".to_string()) - .expect("close first tab"); - - assert_eq!(snapshot.tabs.len(), 1); - assert!(registry.session_record("ssh-1".to_string()).is_ok()); - } - - #[test] - fn ssh_terminal_tab_open_rejects_disabled_sftp() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - false, - SSH_STATUS_CONNECTED, - ); - - let error = registry - .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) - .expect_err("sftp tab should be rejected"); - - assert!(error.contains("SFTP is not enabled")); - } - - #[test] - fn ssh_terminal_tabs_prune_when_session_closes() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect("open bash tab"); - registry - .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) - .expect("open sftp tab"); - - registry - .close("ssh-1".to_string()) - .expect("close ssh session"); - let snapshot = registry - .ssh_terminal_tabs_list("/tmp/project".to_string()) - .expect("list tabs"); - - assert!(snapshot.tabs.is_empty()); - } - - #[test] - fn ssh_terminal_tabs_prune_when_ssh_disconnects() { - let registry = TerminalSessionRegistry::default(); - insert_test_ssh_session( - ®istry, - "ssh-1", - "/tmp/project", - true, - SSH_STATUS_CONNECTED, - ); - registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect("open bash tab"); - registry - .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) - .expect("open sftp tab"); - let entry = registry - .sessions - .lock() - .expect("terminal session registry poisoned") - .get("ssh-1") - .cloned() - .expect("ssh session entry"); - - registry.mark_ssh_disconnected(&entry); - - let snapshot = registry - .ssh_terminal_tabs_list("/tmp/project".to_string()) - .expect("list tabs"); - assert!(snapshot.tabs.is_empty()); - let error = registry - .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) - .expect_err("disconnected ssh tab should be rejected"); - assert!(error.contains("disconnected")); - } - - #[test] - fn ssh_auth_result_detects_keyboard_interactive_continuation() { - let mut methods = russh::MethodSet::empty(); - methods.push(MethodKind::KeyboardInteractive); - assert!(auth_result_can_continue_with_kbi( - &client::AuthResult::Failure { - remaining_methods: methods, - partial_success: false, - } - )); - - let mut password_only = russh::MethodSet::empty(); - password_only.push(MethodKind::Password); - assert!(!auth_result_can_continue_with_kbi( - &client::AuthResult::Failure { - remaining_methods: password_only, - partial_success: false, - }, - )); - assert!(!auth_result_can_continue_with_kbi( - &client::AuthResult::Success - )); - } - - #[test] - fn ssh_password_kbi_prompt_classification_uses_saved_password_once() { - let prompts = vec![client::Prompt { - prompt: "Password:".to_string(), - echo: false, - }]; - assert_eq!( - classify_password_kbi_prompts(&prompts, false), - PasswordKbiPromptAction::SendPassword - ); - assert_eq!( - classify_password_kbi_prompts(&prompts, true), - PasswordKbiPromptAction::PromptUser - ); - assert_eq!( - classify_password_kbi_prompts(&[], false), - PasswordKbiPromptAction::RespondEmpty - ); - assert_eq!( - classify_password_kbi_prompts( - &[client::Prompt { - prompt: "OTP:".to_string(), - echo: false, - }], - false, - ), - PasswordKbiPromptAction::PromptUser - ); - } - - #[test] - fn ssh_keyboard_interactive_message_combines_server_fields() { - let message = ssh_keyboard_interactive_message(&KeyboardInteractivePromptData { - name: "Verification".to_string(), - instructions: "Enter code".to_string(), - prompt: "OTP:".to_string(), - echo: false, - }); - - assert_eq!(message, "Verification\nEnter code\nOTP:"); - } - - #[test] - fn ssh_identity_path_expands_windows_profile_without_posix_rewrites() { - let env = |key: &str| match key { - "USERPROFILE" => Some(r"C:\Users\Alice".to_string()), - "HOMEDRIVE" => Some("C:".to_string()), - "HOMEPATH" => Some(r"\Users\Alice".to_string()), - _ => None, - }; - - assert_eq!( - expand_ssh_identity_path_for_profile_with_env( - r"C:\Users\Alice", - r"~\.ssh\id_ed25519", - SshPathProfile::Windows, - env, - ), - r"C:\Users\Alice\.ssh\id_ed25519" - ); - assert_eq!( - expand_ssh_identity_path_for_profile_with_env( - r"C:\Users\Alice", - r"%USERPROFILE%\.ssh\id_rsa", - SshPathProfile::Windows, - env, - ), - r"C:\Users\Alice\.ssh\id_rsa" - ); - assert_eq!( - expand_ssh_identity_path_for_profile_with_env( - r"C:\Users\Alice", - r"%HOMEDRIVE%%HOMEPATH%\.ssh\id_rsa", - SshPathProfile::Windows, - env, - ), - r"C:\Users\Alice\.ssh\id_rsa" - ); - assert_eq!( - expand_ssh_identity_path_for_profile_with_env( - r"C:\Users\Alice", - r"C:Keys\id_rsa", - SshPathProfile::Windows, - env, - ), - r"C:\Users\Alice\C:Keys\id_rsa" - ); - assert_eq!( - expand_ssh_identity_path_for_profile_with_env( - r"C:\Users\Alice", - r"\\?\C:\Keys\id_rsa", - SshPathProfile::Windows, - env, - ), - r"\\?\C:\Keys\id_rsa" - ); - } - - #[test] - fn ssh_identity_path_preserves_posix_backslash_semantics() { - assert_eq!( - expand_ssh_identity_path_for_profile( - "/Users/alice", - "~/keys/id_ed25519", - SshPathProfile::Posix - ), - "/Users/alice/keys/id_ed25519" - ); - assert_eq!( - expand_ssh_identity_path_for_profile( - "/Users/alice", - "$HOME/.ssh/id_rsa", - SshPathProfile::Posix - ), - "/Users/alice/.ssh/id_rsa" - ); - assert_eq!( - expand_ssh_identity_path_for_profile( - "/Users/alice", - "${HOME}/.ssh/id_rsa", - SshPathProfile::Posix - ), - "/Users/alice/.ssh/id_rsa" - ); - assert_eq!( - expand_ssh_identity_path_for_profile("/Users/alice", r"dir\key", SshPathProfile::Posix), - r"/Users/alice/dir\key" - ); - } - - #[test] - fn ssh_proxy_parser_resolves_http_and_socks5_endpoints() { - let mut host = RuntimeSshHostConfig { - id: "prod".to_string(), - name: "Production".to_string(), - host: "prod.example.com".to_string(), - port: 22, - username: "deploy".to_string(), - auth_type: "agent".to_string(), - password: String::new(), - private_key: String::new(), - private_key_path: String::new(), - private_key_passphrase: String::new(), - proxy: crate::commands::settings::RuntimeSshProxyConfig { - proxy_type: "socks5".to_string(), - url: "socks5://127.0.0.1:1081".to_string(), - port: 0, - username: "proxy-user".to_string(), - password: "proxy-pass".to_string(), - password_configured: true, - }, - }; - - let proxy = resolve_ssh_proxy(&host).expect("resolve socks proxy"); - assert_eq!(proxy.kind, SshProxyKind::Socks5); - assert_eq!(proxy.host, "127.0.0.1"); - assert_eq!(proxy.port, 1081); - assert_eq!(proxy.username, "proxy-user"); - assert_eq!(proxy.password, "proxy-pass"); - - host.proxy.url = "http://proxy.local".to_string(); - host.proxy.port = 8080; - let proxy = resolve_ssh_proxy(&host).expect("resolve http proxy"); - assert_eq!(proxy.kind, SshProxyKind::Http); - assert_eq!(proxy.host, "proxy.local"); - assert_eq!(proxy.port, 8080); - } - - #[test] - fn socks5_address_writer_encodes_domain_and_ip_targets() { - let mut domain = Vec::new(); - write_socks5_address(&mut domain, "prod.example.com").expect("domain target"); - assert_eq!( - domain, - [&[0x03, 16][..], b"prod.example.com".as_slice(),].concat() - ); - - let mut ipv4 = Vec::new(); - write_socks5_address(&mut ipv4, "127.0.0.1").expect("ipv4 target"); - assert_eq!(ipv4, vec![0x01, 127, 0, 0, 1]); - - assert_eq!(host_port_authority("::1", 22), "[::1]:22"); - } - - #[test] - fn terminal_shell_env_scrubs_npm_prefix() { - let mut command = CommandBuilder::new("/bin/sh"); - command.env("npm_config_prefix", "/tmp/npm-prefix"); - command.env("NPM_CONFIG_PREFIX", "/tmp/npm-prefix"); - command.env("TERM", "dumb"); - - configure_terminal_shell_env(&mut command, "/bin/sh"); - - assert!(command.get_env("npm_config_prefix").is_none()); - assert!(command.get_env("NPM_CONFIG_PREFIX").is_none()); - assert_eq!( - command.get_env("TERM").and_then(|value| value.to_str()), - Some("xterm-256color") - ); - assert_eq!( - command - .get_env("COLORTERM") - .and_then(|value| value.to_str()), - Some("truecolor") - ); - assert!(command.get_env("PROMPT_EOL_MARK").is_none()); - } - - #[test] - fn zsh_terminal_shell_disables_prompt_sp() { - assert_eq!( - unix_shell_args("/bin/zsh"), - vec!["-o".to_string(), "NO_PROMPT_SP".to_string()] - ); - assert!(unix_shell_args("/bin/bash").is_empty()); - } - - #[test] - fn registry_creates_lists_renames_and_closes_session() { - let registry = Arc::new(TerminalSessionRegistry::default()); - let tempdir = tempfile::tempdir().expect("tempdir"); - let cwd = tempdir.path().display().to_string(); - - let created = registry - .create( - cwd.clone(), - Some(cwd.clone()), - None, - Some("Test Terminal".to_string()), - Some(80), - Some(24), - ) - .expect("create terminal session"); - assert!(created.session.running); - assert_eq!(created.session.title, "Test Terminal"); - - let listed = registry.list(Some(cwd.clone())).sessions; - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, created.session.id); - - let resized = registry - .resize(created.session.id.clone(), 100, 30) - .expect("resize terminal session"); - assert_eq!(resized.cols, 100); - assert_eq!(resized.rows, 30); - - let renamed = registry - .rename(created.session.id.clone(), "Renamed Terminal".to_string()) - .expect("rename terminal session"); - assert_eq!(renamed.title, "Renamed Terminal"); - - let closed = registry - .close(created.session.id.clone()) - .expect("close terminal session"); - assert!(!closed.running); - assert!(registry.list(Some(cwd)).sessions.is_empty()); - } - - #[test] - fn registry_closes_project_sessions() { - let registry = Arc::new(TerminalSessionRegistry::default()); - let project_a = tempfile::tempdir().expect("project a"); - let project_b = tempfile::tempdir().expect("project b"); - let cwd_a = project_a.path().display().to_string(); - let cwd_b = project_b.path().display().to_string(); - - registry - .create( - cwd_a.clone(), - Some(cwd_a.clone()), - None, - Some("A".to_string()), - Some(80), - Some(24), - ) - .expect("create project a terminal"); - registry - .create( - cwd_b.clone(), - Some(cwd_b.clone()), - None, - Some("B".to_string()), - Some(80), - Some(24), - ) - .expect("create project b terminal"); - assert_eq!(registry.running_session_count(), 2); - - let closed = registry - .close_project(cwd_a.clone()) - .expect("close project a terminals"); - assert_eq!(closed.sessions.len(), 1); - assert!(registry.list(Some(cwd_a)).sessions.is_empty()); - assert_eq!(registry.list(Some(cwd_b)).sessions.len(), 1); - - registry.close_all().expect("close remaining terminals"); - assert_eq!(registry.running_session_count(), 0); - } - - #[test] - fn read_tail_requires_terminal_id_when_project_has_multiple_sessions() { - let registry = Arc::new(TerminalSessionRegistry::default()); - let tempdir = tempfile::tempdir().expect("tempdir"); - let cwd = tempdir.path().display().to_string(); - - let first = registry - .create( - cwd.clone(), - Some(cwd.clone()), - None, - Some("First".to_string()), - Some(80), - Some(24), - ) - .expect("create first terminal session"); - registry - .create( - cwd.clone(), - Some(cwd.clone()), - None, - Some("Second".to_string()), - Some(80), - Some(24), - ) - .expect("create second terminal session"); - - let ambiguous = registry - .read_tail(cwd.clone(), None, Some(1024)) - .expect("read ambiguous terminal tail"); - assert_eq!(ambiguous.sessions.len(), 2); - assert!(ambiguous.selected_session.is_none()); - assert!(ambiguous.output.is_empty()); - - let selected = registry - .read_tail(cwd, Some(first.session.id), Some(1024)) - .expect("read selected terminal tail"); - assert!(selected.selected_session.is_some()); - assert_eq!(selected.sessions.len(), 2); - - registry.close_all().expect("close terminal sessions"); - } -} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs new file mode 100644 index 00000000..83eb1225 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/events.rs @@ -0,0 +1,162 @@ +use std::sync::Arc; +use tauri::Emitter; + +use super::*; + +impl TerminalSessionRegistry { + pub(crate) fn broadcast( + &self, + kind: &str, + entry: &Arc, + data: Option>, + output_start_offset: Option, + output_end_offset: Option, + ) { + let Ok(record) = entry.record.lock().map(|record| record.clone()) else { + return; + }; + let payload = TerminalEventPayload { + kind: kind.to_string(), + session_id: record.id.clone(), + project_path_key: record.project_path_key.clone(), + session: Some(record), + data, + output_start_offset, + output_end_offset, + ssh_tabs: None, + }; + + if let Ok(app_handle) = self.app_handle.lock() { + if let Some(app_handle) = app_handle.as_ref() { + let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); + } + } + + let subscribers = self + .subscribers + .lock() + .map(|subscribers| subscribers.values().cloned().collect::>()) + .unwrap_or_default(); + let event = TerminalEvent { payload }; + for subscriber in subscribers { + let _ = subscriber.send(event.clone()); + } + } + + pub(crate) fn broadcast_output( + &self, + entry: &Arc, + bytes: Vec, + start_offset: u64, + end_offset: u64, + ) { + let Ok(record) = entry.record.lock().map(|record| record.clone()) else { + return; + }; + let payload = TerminalStreamEventPayload { + kind: "output".to_string(), + session_id: record.id, + project_path_key: record.project_path_key, + start_offset, + end_offset, + bytes, + }; + + let dispatch = self.dispatch_terminal_stream_payload(payload); + self.broadcast_terminal_stream_subscribers(&dispatch.remote); + self.emit_terminal_stream_local(&dispatch.local); + } + + pub(crate) fn dispatch_terminal_stream_payload( + &self, + payload: TerminalStreamEventPayload, + ) -> TerminalOutputDispatch { + let Ok(mut states) = self.echo_dispatch.lock() else { + return TerminalOutputDispatch { + local: vec![payload.clone()], + remote: vec![payload], + }; + }; + let session_id = payload.session_id.clone(); + let dispatch = { + let Some(state) = states.get_mut(&session_id) else { + return TerminalOutputDispatch { + local: vec![payload.clone()], + remote: vec![payload], + }; + }; + state.dispatch(payload) + }; + if states + .get(&session_id) + .is_some_and(TerminalEchoDispatchState::is_empty) + { + states.remove(&session_id); + } + dispatch + } + + pub(crate) fn emit_terminal_stream_local(&self, payloads: &[TerminalStreamEventPayload]) { + if payloads.is_empty() { + return; + } + if let Ok(app_handle) = self.app_handle.lock() { + if let Some(app_handle) = app_handle.as_ref() { + for payload in payloads { + let _ = app_handle.emit(TERMINAL_STREAM_EVENT_NAME, payload); + } + } + } + } + + pub(crate) fn broadcast_terminal_stream_subscribers( + &self, + payloads: &[TerminalStreamEventPayload], + ) { + if payloads.is_empty() { + return; + } + let subscribers = self + .stream_subscribers + .lock() + .map(|subscribers| subscribers.values().cloned().collect::>()) + .unwrap_or_default(); + for payload in payloads { + let event = TerminalStreamEvent { + payload: payload.clone(), + }; + for subscriber in &subscribers { + let _ = subscriber.send(event.clone()); + } + } + } + + pub(crate) fn broadcast_ssh_tabs_snapshot(&self, snapshot: SshTerminalTabsSnapshot) { + let payload = TerminalEventPayload { + kind: "ssh_tabs_updated".to_string(), + session_id: String::new(), + project_path_key: snapshot.project_path_key.clone(), + session: None, + data: None, + output_start_offset: None, + output_end_offset: None, + ssh_tabs: Some(snapshot), + }; + + if let Ok(app_handle) = self.app_handle.lock() { + if let Some(app_handle) = app_handle.as_ref() { + let _ = app_handle.emit(TERMINAL_EVENT_NAME, &payload); + } + } + + let subscribers = self + .subscribers + .lock() + .map(|subscribers| subscribers.values().cloned().collect::>()) + .unwrap_or_default(); + let event = TerminalEvent { payload }; + for subscriber in subscribers { + let _ = subscriber.send(event.clone()); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs new file mode 100644 index 00000000..de943078 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/mod.rs @@ -0,0 +1,96 @@ +//! 终端运行时模块(拆分自原单文件 terminal.rs,代码逐字迁移,行为不变)。 +//! +//! - [`types`]:对外 DTO / 事件负载与响应结构 +//! - [`state`]:会话内部状态(会话表项、SSH 会话运行时、挂起提示等) +//! - [`output`]:输出环形缓冲、回显判定与尾部读取 +//! - [`registry`]:`TerminalSessionRegistry` 核心生命周期(创建/列表/输入/尺寸/关闭/订阅) +//! - [`ssh_session`]:SSH 会话编排(创建/提示应答/重连/延迟/exec) +//! - [`tabs`]:SSH 终端标签页快照与清理 +//! - [`events`]:事件广播与终端流分发 +//! - [`ssh_connect`]:SSH 传输建立与 HTTP/SOCKS5 代理 +//! - [`ssh_auth`]:SSH 认证材料解析、身份路径展开与键盘交互认证 +//! - [`ssh_channel`]:SSH shell/exec/SFTP 通道 +//! - [`ssh_io`]:SSH 会话 IO 泵与重连执行器 +//! - [`shell`]:本地 shell 解析、PTY 环境与进程清理 +//! - [`util`]:小型辅助函数 + +use std::collections::HashMap; +use std::sync::atomic::AtomicUsize; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; +use tauri::AppHandle; + +mod events; +mod output; +mod registry; +mod shell; +mod ssh_auth; +mod ssh_channel; +mod ssh_connect; +mod ssh_io; +mod ssh_session; +mod state; +mod tabs; +#[cfg(test)] +mod tests; +mod types; +mod util; + +pub(crate) use output::*; +pub use shell::terminal_shell_options; +pub(crate) use shell::*; +pub(crate) use ssh_auth::*; +pub(crate) use ssh_channel::*; +pub(crate) use ssh_connect::*; +pub(crate) use ssh_io::*; +pub(crate) use state::*; +pub use types::*; +pub(crate) use util::*; + +pub(crate) const DEFAULT_ROWS: u16 = 24; +pub(crate) const DEFAULT_COLS: u16 = 80; +pub(crate) const MAX_RING_CHUNKS: usize = 4096; +pub(crate) const MAX_TAIL_BYTES: usize = 256 * 1024; +pub(crate) const SSH_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); +pub(crate) const SSH_RECONNECT_MAX_ATTEMPTS: u8 = 3; +pub(crate) const SSH_RECONNECT_DELAYS: [Duration; 3] = [ + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(10), +]; +pub(crate) const SSH_RECONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(20); +pub(crate) const SSH_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); +pub(crate) const SSH_KEEPALIVE_MAX_MISSES: usize = 3; +pub(crate) const SSH_STATUS_CONNECTED: &str = "connected"; +pub(crate) const SSH_STATUS_RECONNECTING: &str = "reconnecting"; +pub(crate) const SSH_STATUS_DISCONNECTED: &str = "disconnected"; +pub const TERMINAL_EVENT_NAME: &str = "terminal:event"; +pub const TERMINAL_STREAM_EVENT_NAME: &str = "terminal:stream"; +pub(crate) const SSH_EXEC_DEFAULT_MAX_BYTES: usize = 64 * 1024; +pub(crate) const SSH_EXEC_MAX_BYTES: usize = 256 * 1024; +pub(crate) const SSH_EXEC_DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const SSH_EXEC_MAX_TIMEOUT: Duration = Duration::from_secs(300); + +#[derive(Default)] +pub struct TerminalSessionRegistry { + sessions: Mutex>>, + pending_ssh_prompts: Mutex>, + ssh_terminal_tabs_tx: Mutex<()>, + ssh_terminal_tabs: Mutex>, + app_handle: Mutex>, + subscribers: Arc>>>, + stream_subscribers: Arc>>>, + echo_dispatch: Mutex>, + next_subscriber_id: AtomicUsize, +} + +impl Drop for TerminalSessionRegistry { + fn drop(&mut self) { + if let Ok(sessions) = self.sessions.get_mut() { + for entry in sessions.values() { + terminate_terminal_entry(entry); + } + sessions.clear(); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/output.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/output.rs new file mode 100644 index 00000000..1cc8035a --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/output.rs @@ -0,0 +1,327 @@ +use std::collections::VecDeque; + +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct TerminalOutputChunk { + pub(crate) start_offset: u64, + pub(crate) data: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct TerminalOutputBuffer { + pub(crate) chunks: VecDeque, + pub(crate) next_offset: u64, +} + +impl TerminalOutputBuffer { + pub(crate) fn append(&mut self, data: Vec) -> (u64, u64) { + let start_offset = self.next_offset; + self.next_offset = self.next_offset.saturating_add(data.len() as u64); + self.chunks + .push_back(TerminalOutputChunk { start_offset, data }); + while self.chunks.len() > MAX_RING_CHUNKS { + self.chunks.pop_front(); + } + (start_offset, self.next_offset) + } +} + +impl TerminalEchoDispatchState { + pub(crate) fn dispatch( + &mut self, + payload: TerminalStreamEventPayload, + ) -> TerminalOutputDispatch { + let mut dispatch = TerminalOutputDispatch::default(); + for (index, byte) in payload.bytes.iter().copied().enumerate() { + let offset = payload.start_offset.saturating_add(index as u64); + if let Some(origin) = self.consume_echo_byte(byte) { + match origin { + TerminalInputOrigin::Local => { + self.push_local_or_defer_remote(&mut dispatch, &payload, byte, offset); + if terminal_line_end(byte) { + self.flush_remote(&mut dispatch); + } + } + TerminalInputOrigin::Remote => { + self.push_remote_or_defer_local(&mut dispatch, &payload, byte, offset); + if terminal_line_end(byte) { + self.flush_local(&mut dispatch); + } + } + } + } else { + self.push_visible_to_both(&mut dispatch, &payload, byte, offset); + } + } + dispatch + } + + pub(crate) fn consume_echo_byte(&mut self, byte: u8) -> Option { + let front = self.pending.front().copied()?; + if front.byte == byte || (byte == b'\n' && front.byte == b'\r') { + self.pending.pop_front(); + return Some(front.origin); + } + if terminal_line_end(byte) { + if let Some(index) = self + .pending + .iter() + .position(|pending| terminal_line_end(pending.byte)) + { + let origin = self + .pending + .get(index) + .map(|pending| pending.origin) + .unwrap_or(front.origin); + for _ in 0..=index { + self.pending.pop_front(); + } + return Some(origin); + } + } + None + } + + pub(crate) fn push_local_or_defer_remote( + &mut self, + dispatch: &mut TerminalOutputDispatch, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, + ) { + self.push_local(dispatch, template, byte, offset); + push_payload_byte(&mut self.deferred_remote, template, byte, offset); + } + + pub(crate) fn push_remote_or_defer_local( + &mut self, + dispatch: &mut TerminalOutputDispatch, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, + ) { + self.push_remote(dispatch, template, byte, offset); + push_payload_byte(&mut self.deferred_local, template, byte, offset); + } + + pub(crate) fn push_visible_to_both( + &mut self, + dispatch: &mut TerminalOutputDispatch, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, + ) { + self.push_local(dispatch, template, byte, offset); + self.push_remote(dispatch, template, byte, offset); + } + + pub(crate) fn push_local( + &mut self, + dispatch: &mut TerminalOutputDispatch, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, + ) { + if self.deferred_local.is_empty() { + push_payload_byte(&mut dispatch.local, template, byte, offset); + } else { + push_payload_byte(&mut self.deferred_local, template, byte, offset); + } + } + + pub(crate) fn push_remote( + &mut self, + dispatch: &mut TerminalOutputDispatch, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, + ) { + if self.deferred_remote.is_empty() { + push_payload_byte(&mut dispatch.remote, template, byte, offset); + } else { + push_payload_byte(&mut self.deferred_remote, template, byte, offset); + } + } + + pub(crate) fn flush_local(&mut self, dispatch: &mut TerminalOutputDispatch) { + dispatch.local.append(&mut self.deferred_local); + } + + pub(crate) fn flush_remote(&mut self, dispatch: &mut TerminalOutputDispatch) { + dispatch.remote.append(&mut self.deferred_remote); + } + + pub(crate) fn is_empty(&self) -> bool { + self.pending.is_empty() && self.deferred_local.is_empty() && self.deferred_remote.is_empty() + } +} + +pub(crate) fn push_payload_byte( + payloads: &mut Vec, + template: &TerminalStreamEventPayload, + byte: u8, + offset: u64, +) { + let end_offset = offset.saturating_add(1); + if let Some(last) = payloads.last_mut() { + if last.end_offset == offset + && last.session_id == template.session_id + && last.project_path_key == template.project_path_key + { + last.bytes.push(byte); + last.end_offset = end_offset; + return; + } + } + payloads.push(TerminalStreamEventPayload { + kind: template.kind.clone(), + session_id: template.session_id.clone(), + project_path_key: template.project_path_key.clone(), + start_offset: offset, + end_offset, + bytes: vec![byte], + }); +} + +pub(crate) fn terminal_input_echo_candidates( + data: &[u8], + origin: TerminalInputOrigin, +) -> Vec { + let mut bytes = Vec::new(); + let mut escape = TerminalEscapeParseState::None; + for byte in data.iter().copied() { + match escape { + TerminalEscapeParseState::None => { + if byte == 0x1b { + escape = TerminalEscapeParseState::Esc; + } else if terminal_input_echo_candidate(byte) { + bytes.push(PendingEchoByte { byte, origin }); + } + } + TerminalEscapeParseState::Esc => { + escape = if byte == b'[' { + TerminalEscapeParseState::Csi + } else { + TerminalEscapeParseState::None + }; + } + TerminalEscapeParseState::Csi => { + if (0x40..=0x7e).contains(&byte) { + escape = TerminalEscapeParseState::None; + } + } + } + } + bytes +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TerminalEscapeParseState { + None, + Esc, + Csi, +} + +pub(crate) fn terminal_input_echo_candidate(byte: u8) -> bool { + byte == b'\r' || byte == b'\n' || byte == b'\t' || (byte >= 0x20 && byte != 0x7f) +} + +pub(crate) fn terminal_line_end(byte: u8) -> bool { + byte == b'\r' || byte == b'\n' +} + +#[derive(Debug, Clone)] +pub(crate) struct TerminalOutputTail { + pub(crate) output: Vec, + pub(crate) truncated: bool, + pub(crate) output_start_offset: u64, + pub(crate) output_end_offset: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TerminalInputOrigin { + Local, + Remote, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct PendingEchoByte { + pub(crate) byte: u8, + pub(crate) origin: TerminalInputOrigin, +} + +#[derive(Debug, Default)] +pub(crate) struct TerminalEchoDispatchState { + pub(crate) pending: VecDeque, + pub(crate) deferred_local: Vec, + pub(crate) deferred_remote: Vec, +} + +pub(crate) fn read_output_tail( + entry: &TerminalSessionEntry, + max_bytes: usize, +) -> TerminalOutputTail { + let output = match entry.output.lock() { + Ok(output) => output, + Err(_) => { + return TerminalOutputTail { + output: Vec::new(), + truncated: false, + output_start_offset: 0, + output_end_offset: 0, + } + } + }; + read_output_chunks_tail(&output, max_bytes) +} + +pub(crate) fn read_output_chunks_tail( + output: &TerminalOutputBuffer, + max_bytes: usize, +) -> TerminalOutputTail { + let output_end_offset = output.next_offset; + if max_bytes == 0 { + return TerminalOutputTail { + output: Vec::new(), + truncated: output_end_offset > 0, + output_start_offset: output_end_offset, + output_end_offset, + }; + } + let mut remaining = max_bytes; + let mut chunks = VecDeque::new(); + let mut truncated = false; + for chunk in output.chunks.iter().rev() { + if remaining == 0 { + truncated = true; + break; + } + let len = chunk.data.len(); + if len > remaining { + let start = len.saturating_sub(remaining); + chunks.push_front(TerminalOutputChunk { + start_offset: chunk.start_offset.saturating_add(start as u64), + data: chunk.data[start..].to_vec(), + }); + truncated = true; + break; + } + remaining = remaining.saturating_sub(len); + chunks.push_front(chunk.clone()); + } + let output_start_offset = chunks + .front() + .map(|chunk| chunk.start_offset) + .unwrap_or(output_end_offset); + let mut output_bytes = Vec::new(); + for chunk in chunks { + output_bytes.extend_from_slice(&chunk.data); + } + TerminalOutputTail { + output: output_bytes, + truncated: truncated || output_start_offset > 0, + output_start_offset, + output_end_offset, + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs new file mode 100644 index 00000000..4ab2bc6f --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/registry.rs @@ -0,0 +1,654 @@ +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::sync::atomic::Ordering; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use tauri::AppHandle; + +use crate::runtime::project_path::{ + project_path_key as normalize_project_path_key, project_path_keys_equal, +}; + +use super::*; + +impl TerminalSessionRegistry { + pub fn attach_app_handle(&self, app_handle: AppHandle) { + if let Ok(mut slot) = self.app_handle.lock() { + *slot = Some(app_handle); + } + } + + pub fn subscribe(&self) -> (mpsc::Receiver, TerminalSubscriberGuard) { + let (tx, rx) = mpsc::channel(); + let id = self.next_subscriber_id.fetch_add(1, Ordering::SeqCst); + if let Ok(mut subscribers) = self.subscribers.lock() { + subscribers.insert(id, tx); + } + ( + rx, + TerminalSubscriberGuard { + id, + subscribers: Arc::clone(&self.subscribers), + }, + ) + } + + pub fn subscribe_stream( + &self, + ) -> ( + mpsc::Receiver, + TerminalStreamSubscriberGuard, + ) { + let (tx, rx) = mpsc::channel(); + let id = self.next_subscriber_id.fetch_add(1, Ordering::SeqCst); + if let Ok(mut subscribers) = self.stream_subscribers.lock() { + subscribers.insert(id, tx); + } + ( + rx, + TerminalStreamSubscriberGuard { + id, + subscribers: Arc::clone(&self.stream_subscribers), + }, + ) + } + + pub fn list(&self, project_path_key: Option) -> TerminalListResponse { + let project_key = project_path_key + .map(|value| normalize_project_path_key(&value)) + .filter(|value| !value.is_empty()); + let mut sessions = self + .sessions + .lock() + .expect("terminal session registry poisoned") + .values() + .filter_map(|entry| entry.record.lock().ok().map(|record| record.clone())) + .filter(|record| { + project_key + .as_ref() + .is_none_or(|wanted| project_path_keys_equal(&record.project_path_key, wanted)) + }) + .collect::>(); + sessions.sort_by(|a, b| { + a.project_path_key + .cmp(&b.project_path_key) + .then(a.created_at.cmp(&b.created_at)) + }); + TerminalListResponse { sessions } + } + + pub fn create( + self: &Arc, + cwd: String, + project_path_key: Option, + shell: Option, + title: Option, + cols: Option, + rows: Option, + ) -> Result { + let cwd = canonicalize_workdir(&cwd)?; + let project_key = project_path_key + .map(|value| normalize_project_path_key(&value)) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string())); + if project_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + + let shell_spec = resolve_shell(shell)?; + let size = TerminalSize { + cols: cols.unwrap_or(DEFAULT_COLS).clamp(20, 400), + rows: rows.unwrap_or(DEFAULT_ROWS).clamp(6, 200), + }; + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: size.rows, + cols: size.cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("failed to open terminal pty: {err}"))?; + + let mut cmd = CommandBuilder::new(&shell_spec.command); + for arg in &shell_spec.args { + cmd.arg(arg); + } + cmd.cwd(&cwd); + configure_terminal_shell_env(&mut cmd, &shell_spec.command); + + let child = pair + .slave + .spawn_command(cmd) + .map_err(|err| format!("failed to spawn terminal shell: {err}"))?; + let pid = child.process_id(); + let mut reader = pair + .master + .try_clone_reader() + .map_err(|err| format!("failed to open terminal reader: {err}"))?; + let writer = pair + .master + .take_writer() + .map_err(|err| format!("failed to open terminal writer: {err}"))?; + let (input_tx, input_rx) = mpsc::sync_channel::>(256); + thread::spawn(move || { + let mut writer = writer; + while let Ok(data) = input_rx.recv() { + if data.is_empty() { + continue; + } + if writer.write_all(&data).is_err() { + break; + } + } + }); + + let id = uuid::Uuid::new_v4().to_string(); + let title = title + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.next_terminal_title(&project_key)); + let now = now_ms(); + let record = TerminalSessionRecord { + id: id.clone(), + project_path_key: project_key, + cwd: cwd.display().to_string(), + shell: shell_spec.label, + title, + kind: "local".to_string(), + ssh: None, + pid, + cols: size.cols, + rows: size.rows, + created_at: now, + updated_at: now, + finished_at: None, + exit_code: None, + running: true, + }; + + let entry = Arc::new(TerminalSessionEntry { + backend: TerminalSessionBackend::Local { + master: Mutex::new(pair.master), + input_tx, + child: Mutex::new(child), + }, + record: Mutex::new(record), + output: Mutex::new(TerminalOutputBuffer::default()), + }); + self.sessions + .lock() + .expect("terminal session registry poisoned") + .insert(id.clone(), Arc::clone(&entry)); + self.broadcast("created", &entry, None, None, None); + + let registry = Arc::clone(self); + let reader_session_id = id.clone(); + thread::spawn(move || { + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(n) => { + registry.append_output(&reader_session_id, buffer[..n].to_vec()); + } + Err(_) => break, + } + } + registry.mark_finished(&reader_session_id); + }); + + self.snapshot(id, Some(MAX_TAIL_BYTES)) + } + + pub fn snapshot( + &self, + session_id: String, + max_bytes: Option, + ) -> Result { + let entry = self.entry(&session_id)?; + let session = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + let tail = read_output_tail(&entry, max_bytes.unwrap_or(MAX_TAIL_BYTES)); + Ok(TerminalSnapshotResponse { + session, + output: String::from_utf8_lossy(&tail.output).into_owned(), + output_bytes: tail.output, + truncated: tail.truncated, + output_start_offset: tail.output_start_offset, + output_end_offset: tail.output_end_offset, + }) + } + + pub fn stream_attach( + &self, + session_id: String, + max_bytes: Option, + ) -> Result { + let entry = self.entry(&session_id)?; + let session = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + let tail = read_output_tail(&entry, max_bytes.unwrap_or(MAX_TAIL_BYTES)); + Ok(TerminalStreamSnapshotResponse { + session, + bytes: tail.output, + truncated: tail.truncated, + output_start_offset: tail.output_start_offset, + output_end_offset: tail.output_end_offset, + }) + } + + pub fn session_record(&self, session_id: String) -> Result { + self.record(session_id) + } + + pub fn input_bytes(&self, session_id: String, data: Vec) -> Result<(), String> { + self.input_bytes_with_origin(session_id, data, TerminalInputOrigin::Local) + } + + pub fn input_bytes_from_remote(&self, session_id: String, data: Vec) -> Result<(), String> { + self.input_bytes_with_origin(session_id, data, TerminalInputOrigin::Remote) + } + + pub(crate) fn input_bytes_with_origin( + &self, + session_id: String, + data: Vec, + origin: TerminalInputOrigin, + ) -> Result<(), String> { + if data.is_empty() { + return Ok(()); + } + let entry = self.entry(&session_id)?; + let running = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .running; + if !running { + return Err("terminal session is not running".to_string()); + } + let echo_bytes = terminal_input_echo_candidates(&data, origin); + match &entry.backend { + TerminalSessionBackend::Local { input_tx, .. } => { + input_tx + .try_send(data) + .map_err(|err| format!("failed to enqueue terminal input: {err}"))?; + } + TerminalSessionBackend::Ssh { runtime } => { + runtime + .input_sender() + .ok_or_else(|| "SSH connection is not connected".to_string())? + .try_send(SshSessionInput::Data(data)) + .map_err(|err| format!("failed to enqueue ssh terminal input: {err}"))?; + } + } + self.record_input_echo_candidates(&session_id, echo_bytes); + self.touch(&entry); + Ok(()) + } + + pub fn stream_resize(&self, session_id: String, cols: u16, rows: u16) -> Result<(), String> { + self.resize(session_id, cols, rows).map(|_| ()) + } + + pub fn resize( + &self, + session_id: String, + cols: u16, + rows: u16, + ) -> Result { + let entry = self.entry(&session_id)?; + let cols = cols.clamp(20, 400); + let rows = rows.clamp(6, 200); + match &entry.backend { + TerminalSessionBackend::Local { master, .. } => { + master + .lock() + .map_err(|_| "terminal master lock poisoned".to_string())? + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|err| format!("failed to resize terminal: {err}"))?; + } + TerminalSessionBackend::Ssh { runtime } => { + if let Some(input_tx) = runtime.input_sender() { + input_tx + .try_send(SshSessionInput::Resize(u32::from(cols), u32::from(rows))) + .map_err(|err| format!("failed to resize ssh terminal: {err}"))?; + } + } + } + { + let mut record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())?; + record.cols = cols; + record.rows = rows; + record.updated_at = now_ms(); + } + self.broadcast("resized", &entry, None, None, None); + self.record(session_id) + } + + pub fn rename( + &self, + session_id: String, + title: String, + ) -> Result { + let entry = self.entry(&session_id)?; + let next_title = title.trim(); + if next_title.is_empty() { + return Err("terminal title cannot be empty".to_string()); + } + { + let mut record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())?; + record.title = next_title.to_string(); + record.updated_at = now_ms(); + } + self.broadcast("renamed", &entry, None, None, None); + self.record(session_id) + } + + pub fn close(&self, session_id: String) -> Result { + let entry = self.entry(&session_id)?; + terminate_terminal_entry(&entry); + let (session, tab_snapshots) = { + let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; + self.mark_finished(&session_id); + self.sessions + .lock() + .expect("terminal session registry poisoned") + .remove(session_id.trim()); + let session = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + let tab_snapshots = self.prune_ssh_terminal_tabs_for_session_locked(&session.id); + (session, tab_snapshots) + }; + self.broadcast("closed", &entry, None, None, None); + for snapshot in tab_snapshots { + self.broadcast_ssh_tabs_snapshot(snapshot); + } + Ok(session) + } + + pub fn close_all(&self) -> Result { + let ids = self + .sessions + .lock() + .expect("terminal session registry poisoned") + .keys() + .cloned() + .collect::>(); + self.close_ids(ids) + } + + pub fn close_project(&self, project_path_key: String) -> Result { + let project_key = normalize_project_path_key(&project_path_key); + if project_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + let ids = self + .sessions + .lock() + .expect("terminal session registry poisoned") + .iter() + .filter_map(|(id, entry)| { + entry + .record + .lock() + .ok() + .filter(|record| { + project_path_keys_equal(&record.project_path_key, &project_key) + }) + .map(|_| id.clone()) + }) + .collect::>(); + self.close_ids(ids) + } + + pub fn running_session_count(&self) -> usize { + self.sessions + .lock() + .ok() + .map(|sessions| { + sessions + .values() + .filter_map(|entry| entry.record.lock().ok()) + .filter(|record| record.running) + .count() + }) + .unwrap_or(0) + } + + pub fn read_tail( + &self, + project_path_key: String, + session_id: Option, + max_bytes: Option, + ) -> Result { + let project_key = normalize_project_path_key(&project_path_key); + if project_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + let sessions = self.list(Some(project_key.clone())).sessions; + if sessions.is_empty() { + return Ok(TerminalReadTailResponse { + sessions: Vec::new(), + selected_session: None, + output: String::new(), + truncated: false, + }); + } + let requested_session_id = session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if requested_session_id.is_none() && sessions.len() > 1 { + return Ok(TerminalReadTailResponse { + sessions, + selected_session: None, + output: String::new(), + truncated: false, + }); + } + let selected_id = requested_session_id.unwrap_or_else(|| sessions[0].id.clone()); + let snapshot = self.snapshot(selected_id, max_bytes)?; + if !project_path_keys_equal(&snapshot.session.project_path_key, &project_key) { + return Err("terminal session is outside the current project".to_string()); + } + Ok(TerminalReadTailResponse { + sessions, + selected_session: Some(snapshot.session), + output: snapshot.output, + truncated: snapshot.truncated, + }) + } + + pub(crate) fn close_ids(&self, ids: Vec) -> Result { + let mut sessions = Vec::new(); + for id in ids { + sessions.push(self.close(id)?); + } + Ok(TerminalListResponse { sessions }) + } + + pub(crate) fn next_terminal_title(&self, project_path_key: &str) -> String { + let count = self + .sessions + .lock() + .ok() + .map(|sessions| { + sessions + .values() + .filter_map(|entry| entry.record.lock().ok()) + .filter(|record| { + project_path_keys_equal(&record.project_path_key, project_path_key) + }) + .count() + }) + .unwrap_or(0); + format!("Terminal {}", count + 1) + } + + pub(crate) fn next_ssh_title(&self, project_path_key: &str, host_name: &str) -> String { + let base = host_name.trim(); + let base = if base.is_empty() { "SSH" } else { base }; + let count = self + .sessions + .lock() + .ok() + .map(|sessions| { + sessions + .values() + .filter_map(|entry| entry.record.lock().ok()) + .filter(|record| { + project_path_keys_equal(&record.project_path_key, project_path_key) + && record.kind == "ssh" + && record.title.starts_with(base) + }) + .count() + }) + .unwrap_or(0); + if count == 0 { + base.to_string() + } else { + format!("{base} {}", count + 1) + } + } + + pub(crate) fn entry(&self, session_id: &str) -> Result, String> { + let id = session_id.trim(); + if id.is_empty() { + return Err("terminal_id is required".to_string()); + } + self.sessions + .lock() + .expect("terminal session registry poisoned") + .get(id) + .cloned() + .ok_or_else(|| format!("terminal session not found: {id}")) + } + + pub(crate) fn record(&self, session_id: String) -> Result { + let entry = self.entry(&session_id)?; + entry + .record + .lock() + .map(|record| record.clone()) + .map_err(|_| "terminal session lock poisoned".to_string()) + } + + pub(crate) fn touch(&self, entry: &Arc) { + if let Ok(mut record) = entry.record.lock() { + record.updated_at = now_ms(); + } + } + + pub(crate) fn append_output(&self, session_id: &str, data: impl Into>) { + let Ok(entry) = self.entry(session_id) else { + return; + }; + let data = data.into(); + if data.is_empty() { + return; + } + let (output_start_offset, output_end_offset) = { + let mut output = match entry.output.lock() { + Ok(output) => output, + Err(_) => return, + }; + output.append(data.clone()) + }; + self.touch(&entry); + self.broadcast_output(&entry, data, output_start_offset, output_end_offset); + } + + pub(crate) fn record_input_echo_candidates( + &self, + session_id: &str, + echo_bytes: Vec, + ) { + if echo_bytes.is_empty() { + return; + } + let Ok(mut states) = self.echo_dispatch.lock() else { + return; + }; + let state = states.entry(session_id.to_string()).or_default(); + state.pending.extend(echo_bytes); + while state.pending.len() > MAX_TAIL_BYTES { + state.pending.pop_front(); + } + } + + pub(crate) fn mark_finished(&self, session_id: &str) { + let Ok(entry) = self.entry(session_id) else { + return; + }; + let mut exit_code = None; + if let TerminalSessionBackend::Local { child, .. } = &entry.backend { + if let Ok(mut child) = child.lock() { + if let Ok(status) = child.try_wait() { + exit_code = status.map(|status| status.exit_code() as i32); + } + } + } + { + let mut record = match entry.record.lock() { + Ok(record) => record, + Err(_) => return, + }; + if record.running { + record.running = false; + record.finished_at = Some(now_ms()); + record.exit_code = exit_code; + record.updated_at = now_ms(); + } + } + self.broadcast("exit", &entry, None, None, None); + } +} +pub struct TerminalSubscriberGuard { + id: usize, + subscribers: Arc>>>, +} + +impl Drop for TerminalSubscriberGuard { + fn drop(&mut self) { + if let Ok(mut subscribers) = self.subscribers.lock() { + subscribers.remove(&self.id); + } + } +} + +pub struct TerminalStreamSubscriberGuard { + id: usize, + subscribers: Arc>>>, +} + +impl Drop for TerminalStreamSubscriberGuard { + fn drop(&mut self) { + if let Ok(mut subscribers) = self.subscribers.lock() { + subscribers.remove(&self.id); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs new file mode 100644 index 00000000..e60e70c5 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/shell.rs @@ -0,0 +1,294 @@ +use portable_pty::CommandBuilder; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::runtime::platform::expand_tilde_path; +#[cfg(windows)] +use crate::runtime::process::configure_child_process_group; + +use super::*; + +pub(crate) fn terminate_terminal_entry(entry: &Arc) { + match &entry.backend { + TerminalSessionBackend::Local { child, .. } => { + let pid = entry.record.lock().ok().and_then(|record| record.pid); + terminate_process_tree_best_effort(pid); + if let Ok(mut child) = child.lock() { + let _ = child.kill(); + } + } + TerminalSessionBackend::Ssh { runtime } => { + if let Some(shutdown_tx) = runtime.close() { + let _ = shutdown_tx.try_send(()); + } + } + } +} + +pub(crate) fn terminate_process_tree_best_effort(pid: Option) { + let Some(pid) = pid else { + return; + }; + if pid == 0 { + return; + } + + #[cfg(windows)] + { + // `taskkill` is a console app; hide its window so app exit stays clean. + let mut command = std::process::Command::new("taskkill"); + configure_child_process_group(&mut command); + let _ = command + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + + #[cfg(unix)] + { + let _ = std::process::Command::new("kill") + .args(["-TERM", &format!("-{pid}")]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } +} + +pub(crate) fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() +} + +pub(crate) fn canonicalize_workdir(workdir: &str) -> Result { + let raw = workdir.trim(); + if raw.is_empty() { + return Err("workdir is required".to_string()); + } + let path = expand_tilde_path(raw); + if !path.is_absolute() { + return Err(format!("workdir must be absolute: {workdir}")); + } + let metadata = fs::metadata(&path).map_err(|_| format!("workdir does not exist: {workdir}"))?; + if !metadata.is_dir() { + return Err(format!("workdir must be a directory: {workdir}")); + } + let canonical = + fs::canonicalize(&path).map_err(|err| format!("failed to canonicalize workdir: {err}"))?; + Ok(strip_windows_unc_prefix(canonical)) +} + +pub(crate) fn strip_windows_unc_prefix(path: PathBuf) -> PathBuf { + #[cfg(windows)] + { + let s = path.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + return PathBuf::from(stripped); + } + } + path +} + +pub(crate) fn is_program_on_path(program: &str) -> bool { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path_var) { + if dir.join(program).is_file() { + return true; + } + } + false +} + +pub(crate) fn scrub_terminal_shell_env(cmd: &mut CommandBuilder) { + for key in ["npm_config_prefix", "NPM_CONFIG_PREFIX"] { + cmd.env_remove(key); + } +} + +pub(crate) fn configure_terminal_shell_env(cmd: &mut CommandBuilder, shell_command: &str) { + scrub_terminal_shell_env(cmd); + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + if is_zsh_shell(shell_command) { + configure_zsh_colored_prompt(cmd); + } +} + +pub(crate) fn configure_zsh_colored_prompt(cmd: &mut CommandBuilder) { + let colored_prompt = "%F{green}%n%f%F{yellow}@%f%F{blue}%m%f %F{magenta}%1~%f %F{cyan}%#%f "; + let zdotdir = create_zsh_prompt_overlay(colored_prompt); + if let Some(dir) = zdotdir { + cmd.env("ZDOTDIR", dir.to_string_lossy().as_ref()); + } +} + +pub(crate) fn create_zsh_prompt_overlay(prompt: &str) -> Option { + let base = dirs::cache_dir() + .or_else(dirs::home_dir) + .unwrap_or_else(|| PathBuf::from("/tmp")); + let zdotdir = base.join("liveagent-zsh"); + if fs::create_dir_all(&zdotdir).is_err() { + return None; + } + + let home = dirs::home_dir().unwrap_or_default(); + let user_zshrc = home.join(".zshrc"); + let user_zshenv = home.join(".zshenv"); + + let zshenv_content = format!( + "export _LIVEAGENT_REAL_ZDOTDIR=\"$HOME\"\n\ + [[ -f \"{}\" ]] && source \"{}\"\n", + user_zshenv.display(), + user_zshenv.display(), + ); + let zshrc_content = format!( + "[[ -f \"{}\" ]] && source \"{}\"\n\ + PROMPT='{}'\n\ + unset ZDOTDIR\n", + user_zshrc.display(), + user_zshrc.display(), + prompt, + ); + + if fs::write(zdotdir.join(".zshenv"), zshenv_content).is_err() { + return None; + } + if fs::write(zdotdir.join(".zshrc"), zshrc_content).is_err() { + return None; + } + Some(zdotdir) +} + +pub(crate) struct ShellSpec { + pub(crate) label: String, + pub(crate) command: String, + pub(crate) args: Vec, +} + +pub(crate) fn resolve_shell(shell: Option) -> Result { + let requested = shell + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "default".to_string()); + + if cfg!(windows) { + let powershell_args = vec![ + "-NoLogo".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + ]; + match requested.as_str() { + "pwsh" => Ok(ShellSpec { + label: "PowerShell 7".to_string(), + command: "pwsh.exe".to_string(), + args: powershell_args, + }), + "powershell" | "default" => Ok(ShellSpec { + label: "PowerShell".to_string(), + command: "powershell.exe".to_string(), + args: powershell_args, + }), + "cmd" => Ok(ShellSpec { + label: "Cmd".to_string(), + command: "cmd.exe".to_string(), + args: Vec::new(), + }), + other => Err(format!("unsupported Windows terminal shell: {other}")), + } + } else { + let command = std::env::var("SHELL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && Path::new(value).is_absolute()) + .or_else(resolve_unix_shell_fallback) + .ok_or_else(|| "failed to resolve login shell".to_string())?; + let label = Path::new(&command) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("shell") + .to_string(); + Ok(ShellSpec { + label, + args: unix_shell_args(&command), + command, + }) + } +} + +pub(crate) fn unix_shell_args(command: &str) -> Vec { + if is_zsh_shell(command) { + return vec!["-o".to_string(), "NO_PROMPT_SP".to_string()]; + } + Vec::new() +} + +pub(crate) fn is_zsh_shell(command: &str) -> bool { + Path::new(command) + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.eq_ignore_ascii_case("zsh")) + .unwrap_or(false) +} + +pub(crate) fn resolve_unix_shell_fallback() -> Option { + let candidates: &[&str] = if cfg!(target_os = "macos") { + &["/bin/zsh", "/bin/bash", "/bin/sh"] + } else { + &["/bin/bash", "/bin/zsh", "/bin/sh"] + }; + candidates + .iter() + .find(|candidate| Path::new(candidate).exists()) + .map(|value| (*value).to_string()) +} + +pub fn terminal_shell_options() -> TerminalShellOptionsResponse { + if cfg!(windows) { + let mut options = vec![ + TerminalShellOption { + id: "powershell".to_string(), + label: "PowerShell".to_string(), + command: "powershell.exe".to_string(), + }, + TerminalShellOption { + id: "cmd".to_string(), + label: "Cmd".to_string(), + command: "cmd.exe".to_string(), + }, + ]; + if is_program_on_path("pwsh.exe") { + options.insert( + 0, + TerminalShellOption { + id: "pwsh".to_string(), + label: "PowerShell 7".to_string(), + command: "pwsh.exe".to_string(), + }, + ); + } + TerminalShellOptionsResponse { + default_shell: "powershell".to_string(), + options, + } + } else { + let shell = resolve_shell(None).unwrap_or_else(|_| ShellSpec { + label: "sh".to_string(), + command: "/bin/sh".to_string(), + args: Vec::new(), + }); + TerminalShellOptionsResponse { + default_shell: "default".to_string(), + options: vec![TerminalShellOption { + id: "default".to_string(), + label: shell.label, + command: shell.command, + }], + } + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_auth.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_auth.rs new file mode 100644 index 00000000..1073b39e --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_auth.rs @@ -0,0 +1,503 @@ +use russh::client; +use russh::keys::agent::client::{AgentClient, AgentStream}; +use russh::keys::agent::AgentIdentity; +use russh::keys::ssh_key::HashAlg; +use russh::keys::PrivateKeyWithHashAlg; +use russh::MethodKind; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::commands::settings::RuntimeSshHostConfig; + +use super::*; + +pub(crate) fn resolve_ssh_auth_material( + host: &RuntimeSshHostConfig, +) -> Result { + if host.auth_type == "agent" { + Ok(ResolvedSshAuth::Agent) + } else if host.auth_type == "privateKey" { + let key = if !host.private_key.trim().is_empty() { + host.private_key.trim().to_string() + } else { + let path = host.private_key_path.trim(); + if path.is_empty() { + return Err("SSH private key is not configured".to_string()); + } + let expanded = expand_ssh_private_key_path(path); + fs::read_to_string(&expanded) + .map_err(|error| { + format!( + "failed to read SSH private key {}: {error}", + expanded.display() + ) + })? + .trim() + .to_string() + }; + if key.is_empty() { + return Err("SSH private key is empty".to_string()); + } + let passphrase = host.private_key_passphrase.trim().to_string(); + Ok(ResolvedSshAuth::PrivateKey { + key, + passphrase: (!passphrase.is_empty()).then_some(passphrase), + }) + } else { + let password = host.password.trim().to_string(); + if password.is_empty() { + return Err("SSH password is not configured".to_string()); + } + Ok(ResolvedSshAuth::Password(password)) + } +} + +pub(crate) fn expand_ssh_private_key_path(path: &str) -> PathBuf { + let home = dirs::home_dir() + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + let profile = if cfg!(windows) { + SshPathProfile::Windows + } else { + SshPathProfile::Posix + }; + let expanded = expand_ssh_identity_path_for_profile(&home, path, profile); + PathBuf::from(expanded) +} + +pub(crate) fn expand_ssh_identity_path_for_profile( + home_path: &str, + path: &str, + profile: SshPathProfile, +) -> String { + expand_ssh_identity_path_for_profile_with_env(home_path, path, profile, |key| { + std::env::var(key).ok() + }) +} + +pub(crate) fn expand_ssh_identity_path_for_profile_with_env( + home_path: &str, + path: &str, + profile: SshPathProfile, + env: F, +) -> String +where + F: Fn(&str) -> Option, +{ + let trimmed = strip_wrapping_quotes(path); + if trimmed.is_empty() { + return String::new(); + } + match profile { + SshPathProfile::Windows => expand_windows_ssh_identity_path(home_path, &trimmed, env), + SshPathProfile::Posix => expand_posix_ssh_identity_path(home_path, &trimmed), + } +} + +pub(crate) fn strip_wrapping_quotes(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.len() >= 2 { + let first = trimmed.as_bytes()[0] as char; + let last = trimmed.as_bytes()[trimmed.len() - 1] as char; + if (first == '"' && last == '"') || (first == '\'' && last == '\'') { + return trimmed[1..trimmed.len() - 1].to_string(); + } + } + trimmed.to_string() +} + +pub(crate) fn expand_windows_ssh_identity_path(home_path: &str, path: &str, env: F) -> String +where + F: Fn(&str) -> Option, +{ + if is_windows_absolute_path(path) { + return path.to_string(); + } + if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { + return join_windows_identity_path(home_path, rest); + } + if let Some(rest) = path + .strip_prefix("$HOME/") + .or_else(|| path.strip_prefix("$HOME\\")) + { + return join_windows_identity_path(home_path, rest); + } + if let Some(rest) = path + .strip_prefix("${HOME}/") + .or_else(|| path.strip_prefix("${HOME}\\")) + { + return join_windows_identity_path(home_path, rest); + } + if let Some(rest) = strip_prefix_ci(path, "%USERPROFILE%") { + if rest.starts_with('\\') || rest.starts_with('/') { + let user_profile = env("USERPROFILE").unwrap_or_else(|| home_path.to_string()); + return join_windows_identity_path(&user_profile, rest); + } + } + if let Some(rest) = strip_prefix_ci(path, "%HOMEDRIVE%%HOMEPATH%") { + if rest.starts_with('\\') || rest.starts_with('/') { + let home_drive = env("HOMEDRIVE").unwrap_or_default(); + let home_path_env = env("HOMEPATH").unwrap_or_default(); + let home = if home_drive.is_empty() && home_path_env.is_empty() { + home_path.to_string() + } else { + format!("{home_drive}{home_path_env}") + }; + return join_windows_identity_path(&home, rest); + } + } + if path.starts_with('\\') || path.starts_with('/') { + return path.to_string(); + } + join_windows_identity_path(home_path, path) +} + +pub(crate) fn expand_posix_ssh_identity_path(home_path: &str, path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + return join_posix_identity_path(home_path, rest); + } + if let Some(rest) = path.strip_prefix("$HOME/") { + return join_posix_identity_path(home_path, rest); + } + if let Some(rest) = path.strip_prefix("${HOME}/") { + return join_posix_identity_path(home_path, rest); + } + if path.starts_with('/') { + return trim_trailing_posix_slashes(path); + } + join_posix_identity_path(home_path, path) +} + +pub(crate) fn is_windows_absolute_path(path: &str) -> bool { + if path.starts_with(r"\\?\") || path.starts_with(r"//?/") { + return true; + } + if path.len() >= 3 + && path.as_bytes()[1] == b':' + && path.as_bytes()[0].is_ascii_alphabetic() + && matches!(path.as_bytes()[2], b'\\' | b'/') + { + return true; + } + path.starts_with(r"\\") || path.starts_with("//") +} + +pub(crate) fn strip_prefix_ci<'a>(value: &'a str, prefix: &str) -> Option<&'a str> { + value + .get(..prefix.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(prefix)) + .then(|| &value[prefix.len()..]) +} + +pub(crate) fn join_windows_identity_path(base: &str, child: &str) -> String { + let separator = if base.contains('\\') { '\\' } else { '/' }; + let base = base.trim_end_matches(['\\', '/']); + let child = child.trim_start_matches(['\\', '/']); + if child.is_empty() { + base.to_string() + } else if base.is_empty() { + child.to_string() + } else { + format!("{base}{separator}{child}") + } +} + +pub(crate) fn join_posix_identity_path(base: &str, child: &str) -> String { + let base = base.trim_end_matches('/'); + let child = child.trim_start_matches('/'); + if child.is_empty() { + base.to_string() + } else if base.is_empty() { + child.to_string() + } else { + format!("{base}/{child}") + } +} + +pub(crate) fn trim_trailing_posix_slashes(path: &str) -> String { + let mut next = path.to_string(); + while next.len() > 1 && next.ends_with('/') { + next.pop(); + } + next +} + +pub(crate) async fn authenticate_ssh_handle( + handle: &mut client::Handle, + host: &RuntimeSshHostConfig, + auth: ResolvedSshAuth, +) -> Result { + match auth { + ResolvedSshAuth::Password(password) => { + let result = handle + .authenticate_password(host.username.as_str(), password.clone()) + .await + .map_err(|error| format!("SSH password authentication failed: {error}"))?; + if result.success() { + return Ok(SshAuthOutcome::Authenticated); + } + if auth_result_can_continue_with_kbi(&result) { + let response = handle + .authenticate_keyboard_interactive_start(host.username.as_str(), None::) + .await + .map_err(|error| { + format!("SSH keyboard-interactive authentication failed: {error}") + })?; + return continue_keyboard_interactive_auth(handle, response, Some(password)).await; + } + Err("SSH authentication failed".to_string()) + } + ResolvedSshAuth::PrivateKey { key, passphrase } => { + let key_pair = russh::keys::decode_secret_key(&key, passphrase.as_deref()) + .map_err(|error| format!("Invalid SSH private key: {error}"))?; + let key = PrivateKeyWithHashAlg::new(Arc::new(key_pair), Some(HashAlg::Sha256)); + let result = handle + .authenticate_publickey(host.username.as_str(), key) + .await + .map_err(|error| format!("SSH private key authentication failed: {error}"))?; + if result.success() { + return Ok(SshAuthOutcome::Authenticated); + } + if auth_result_can_continue_with_kbi(&result) { + let response = handle + .authenticate_keyboard_interactive_start(host.username.as_str(), None::) + .await + .map_err(|error| { + format!("SSH keyboard-interactive authentication failed: {error}") + })?; + return continue_keyboard_interactive_auth(handle, response, None).await; + } + Err("SSH authentication failed".to_string()) + } + ResolvedSshAuth::Agent => authenticate_ssh_handle_with_agent(handle, host).await, + } +} + +pub(crate) async fn authenticate_ssh_handle_with_agent( + handle: &mut client::Handle, + host: &RuntimeSshHostConfig, +) -> Result { + let mut agent = connect_ssh_agent().await?; + let identities = agent + .request_identities() + .await + .map_err(|error| format!("SSH agent identity lookup failed: {error}"))?; + if identities.is_empty() { + return Err("SSH agent has no identities".to_string()); + } + + let mut can_continue_with_kbi = false; + let mut last_error = String::new(); + for identity in identities { + let result = + authenticate_ssh_agent_identity(handle, host.username.as_str(), &identity, &mut agent) + .await; + let result = match result { + Ok(result) => result, + Err(error) => { + last_error = error; + continue; + } + }; + if result.success() { + return Ok(SshAuthOutcome::Authenticated); + } + can_continue_with_kbi |= auth_result_can_continue_with_kbi(&result); + } + + if can_continue_with_kbi { + let response = handle + .authenticate_keyboard_interactive_start(host.username.as_str(), None::) + .await + .map_err(|error| format!("SSH keyboard-interactive authentication failed: {error}"))?; + return continue_keyboard_interactive_auth(handle, response, None).await; + } + + if last_error.is_empty() { + Err("SSH agent authentication failed".to_string()) + } else { + Err(format!("SSH agent authentication failed: {last_error}")) + } +} + +pub(crate) async fn authenticate_ssh_agent_identity( + handle: &mut client::Handle, + username: &str, + identity: &AgentIdentity, + agent: &mut AgentClient>, +) -> Result { + match identity { + AgentIdentity::PublicKey { key, .. } => handle + .authenticate_publickey_with(username, key.clone(), Some(HashAlg::Sha256), agent) + .await + .map_err(|error| error.to_string()), + AgentIdentity::Certificate { certificate, .. } => handle + .authenticate_certificate_with( + username, + certificate.clone(), + Some(HashAlg::Sha256), + agent, + ) + .await + .map_err(|error| error.to_string()), + } +} + +pub(crate) async fn connect_ssh_agent( +) -> Result>, String> { + #[cfg(windows)] + { + let mut errors = Vec::new(); + match AgentClient::connect_pageant().await { + Ok(agent) => return Ok(agent.dynamic()), + Err(error) => errors.push(format!("Pageant: {error}")), + } + if let Ok(sock) = std::env::var("SSH_AUTH_SOCK") { + let sock = sock.trim(); + if !sock.is_empty() { + match AgentClient::connect_named_pipe(sock).await { + Ok(agent) => return Ok(agent.dynamic()), + Err(error) => errors.push(format!("SSH_AUTH_SOCK named pipe: {error}")), + } + } + } + match AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await { + Ok(agent) => return Ok(agent.dynamic()), + Err(error) => errors.push(format!("OpenSSH named pipe: {error}")), + } + Err(format!( + "SSH agent is not available ({})", + errors.join("; ") + )) + } + + #[cfg(unix)] + { + AgentClient::connect_env() + .await + .map(|agent| agent.dynamic()) + .map_err(|error| format!("SSH agent is not available: {error}")) + } + + #[cfg(not(any(unix, windows)))] + { + Err("SSH agent is not supported on this platform".to_string()) + } +} + +pub(crate) fn auth_result_can_continue_with_kbi(result: &client::AuthResult) -> bool { + matches!( + result, + client::AuthResult::Failure { + remaining_methods, + .. + } if remaining_methods.contains(&MethodKind::KeyboardInteractive) + ) +} + +pub(crate) fn prompt_looks_like_password(prompt: &str) -> bool { + let normalized = prompt.trim().to_ascii_lowercase(); + normalized.contains("password") || prompt.contains("密码") +} + +pub(crate) fn classify_password_kbi_prompts( + prompts: &[client::Prompt], + password_prompt_consumed: bool, +) -> PasswordKbiPromptAction { + if prompts.is_empty() { + PasswordKbiPromptAction::RespondEmpty + } else if !password_prompt_consumed + && prompts.len() == 1 + && !prompts[0].echo + && prompt_looks_like_password(&prompts[0].prompt) + { + PasswordKbiPromptAction::SendPassword + } else { + PasswordKbiPromptAction::PromptUser + } +} + +pub(crate) async fn continue_keyboard_interactive_auth( + handle: &mut client::Handle, + mut response: client::KeyboardInteractiveAuthResponse, + auto_password: Option, +) -> Result { + let mut password_prompt_consumed = false; + for _ in 0..5 { + match response { + client::KeyboardInteractiveAuthResponse::Success => { + return Ok(SshAuthOutcome::Authenticated); + } + client::KeyboardInteractiveAuthResponse::Failure { .. } => { + return Err("SSH keyboard-interactive authentication failed".to_string()); + } + client::KeyboardInteractiveAuthResponse::InfoRequest { + name, + instructions, + prompts, + } => match classify_password_kbi_prompts(&prompts, password_prompt_consumed) { + PasswordKbiPromptAction::RespondEmpty => { + response = handle + .authenticate_keyboard_interactive_respond(Vec::new()) + .await + .map_err(|error| { + format!("SSH keyboard-interactive response failed: {error}") + })?; + } + PasswordKbiPromptAction::SendPassword if auto_password.is_some() => { + password_prompt_consumed = true; + response = handle + .authenticate_keyboard_interactive_respond(vec![auto_password + .clone() + .unwrap_or_default()]) + .await + .map_err(|error| { + format!("SSH keyboard-interactive response failed: {error}") + })?; + } + PasswordKbiPromptAction::SendPassword | PasswordKbiPromptAction::PromptUser => { + if prompts.len() != 1 { + return Err( + "SSH keyboard-interactive requested multiple prompts, which is not supported in V1." + .to_string(), + ); + } + let prompt = prompts + .into_iter() + .next() + .ok_or_else(|| "SSH keyboard-interactive prompt is empty".to_string())?; + return Ok(SshAuthOutcome::KeyboardInteractivePrompt( + KeyboardInteractivePromptData { + name, + instructions, + prompt: prompt.prompt, + echo: prompt.echo, + }, + )); + } + }, + } + } + Err("SSH keyboard-interactive exceeded maximum prompt rounds".to_string()) +} + +pub(crate) fn ssh_keyboard_interactive_message( + prompt_data: &KeyboardInteractivePromptData, +) -> String { + let mut parts = Vec::new(); + if !prompt_data.name.trim().is_empty() { + parts.push(prompt_data.name.trim().to_string()); + } + if !prompt_data.instructions.trim().is_empty() { + parts.push(prompt_data.instructions.trim().to_string()); + } + if !prompt_data.prompt.trim().is_empty() { + parts.push(prompt_data.prompt.trim().to_string()); + } + if parts.is_empty() { + "SSH keyboard-interactive authentication requires input.".to_string() + } else { + parts.join("\n") + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_channel.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_channel.rs new file mode 100644 index 00000000..b11e46a3 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_channel.rs @@ -0,0 +1,201 @@ +use russh::client; +use russh::ChannelMsg; +use std::sync::Arc; +use std::time::Duration; + +use crate::commands::settings::load_runtime_ssh_host; + +use super::*; + +pub(crate) async fn open_ssh_shell_channel( + handle: &client::Handle, + size: TerminalSize, +) -> Result, String> { + let channel = handle + .channel_open_session() + .await + .map_err(|error| format!("SSH channel open failed: {error}"))?; + channel + .request_pty( + false, + "xterm-256color", + u32::from(size.cols), + u32::from(size.rows), + 0, + 0, + &[], + ) + .await + .map_err(|error| format!("SSH PTY request failed: {error}"))?; + channel + .request_shell(false) + .await + .map_err(|error| format!("SSH shell request failed: {error}"))?; + Ok(channel) +} + +pub(crate) async fn open_sftp_connection_for_host( + ssh_host_id: &str, +) -> Result { + let host_config = load_runtime_ssh_host(ssh_host_id)? + .ok_or_else(|| format!("SSH host not found: {}", ssh_host_id.trim()))?; + if host_config.host.trim().is_empty() { + return Err("SSH host is required".to_string()); + } + if host_config.username.trim().is_empty() { + return Err("SSH username is required".to_string()); + } + + let auth = resolve_ssh_auth_material(&host_config)?; + let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); + let mut handle = match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { + Ok(handle) => handle, + Err(error) => { + if captured_host_key.lock().await.is_some() { + return Err("SSH host key requires confirmation before opening SFTP".to_string()); + } + return Err(error); + } + }; + + match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { + SshAuthOutcome::Authenticated => {} + SshAuthOutcome::KeyboardInteractivePrompt(_) => { + let _ = handle + .disconnect( + russh::Disconnect::ByApplication, + "Keyboard-interactive SFTP authentication requires Bash prompt first", + "en", + ) + .await; + return Err( + "SSH keyboard-interactive authentication requires opening Bash first".to_string(), + ); + } + } + + let channel = handle + .channel_open_session() + .await + .map_err(|error| format!("SFTP channel open failed: {error}"))?; + channel + .request_subsystem(true, "sftp") + .await + .map_err(|error| format!("SFTP subsystem request failed: {error}"))?; + let session = russh_sftp::client::SftpSession::new(channel.into_stream()) + .await + .map_err(|error| format!("SFTP session failed: {error}"))?; + Ok(TerminalSftpConnection { + _handle: handle, + session, + }) +} + +pub(crate) async fn run_ssh_exec_channel( + runtime: &Arc, + command: String, + max_bytes: usize, +) -> Result { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut stdout_truncated = false; + let mut stderr_truncated = false; + let mut exit_code = None; + let mut exit_signal = None; + + let channel = { + let handle = runtime.handle.lock().await; + let Some(handle) = handle.as_ref() else { + return Err("SSH connection is not connected".to_string()); + }; + handle + .channel_open_session() + .await + .map_err(|error| format!("SSH exec channel open failed: {error}"))? + }; + channel + .exec(true, command.into_bytes()) + .await + .map_err(|error| format!("SSH exec request failed: {error}"))?; + let (mut read_half, _write_half) = channel.split(); + + loop { + match read_half.wait().await { + Some(ChannelMsg::Data { data }) => { + append_limited(&mut stdout, data.as_ref(), max_bytes, &mut stdout_truncated); + } + Some(ChannelMsg::ExtendedData { data, .. }) => { + append_limited(&mut stderr, data.as_ref(), max_bytes, &mut stderr_truncated); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + exit_code = Some(exit_status); + } + Some(ChannelMsg::ExitSignal { signal_name, .. }) => { + exit_signal = Some(format!("{signal_name:?}")); + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, + _ => {} + } + } + + Ok(TerminalSshExecResponse { + session_id: String::new(), + command: String::new(), + cwd: None, + exit_code, + exit_signal, + stdout: String::from_utf8_lossy(&stdout).to_string(), + stderr: String::from_utf8_lossy(&stderr).to_string(), + stdout_truncated, + stderr_truncated, + timed_out: false, + duration_ms: 0, + }) +} + +pub(crate) fn normalize_ssh_exec_timeout(timeout_ms: Option) -> Duration { + let requested = timeout_ms + .filter(|value| *value > 0) + .map(Duration::from_millis) + .unwrap_or(SSH_EXEC_DEFAULT_TIMEOUT); + requested.clamp(Duration::from_secs(1), SSH_EXEC_MAX_TIMEOUT) +} + +pub(crate) fn normalize_ssh_exec_max_bytes(max_bytes: Option) -> usize { + max_bytes + .filter(|value| *value > 0) + .unwrap_or(SSH_EXEC_DEFAULT_MAX_BYTES) + .clamp(4 * 1024, SSH_EXEC_MAX_BYTES) +} + +pub(crate) fn append_limited( + buffer: &mut Vec, + data: &[u8], + max_bytes: usize, + truncated: &mut bool, +) { + if buffer.len() >= max_bytes { + if !data.is_empty() { + *truncated = true; + } + return; + } + let remaining = max_bytes - buffer.len(); + if data.len() > remaining { + buffer.extend_from_slice(&data[..remaining]); + *truncated = true; + } else { + buffer.extend_from_slice(data); + } +} + +pub(crate) fn wrap_ssh_exec_command(command: &str, cwd: Option<&str>) -> String { + match cwd.map(str::trim).filter(|value| !value.is_empty()) { + Some(cwd) => format!("cd {} && {}", shell_single_quote(cwd), command), + None => command.to_string(), + } +} + +pub(crate) fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_connect.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_connect.rs new file mode 100644 index 00000000..8dacff78 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_connect.rs @@ -0,0 +1,463 @@ +use base64::Engine; +use russh::client; +use russh::keys::ssh_key::HashAlg; +use russh::keys::{PublicKey, PublicKeyBase64}; +use std::net::{IpAddr, Ipv6Addr}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::commands::settings::{ + check_runtime_ssh_known_host, RuntimeSshHostConfig, RuntimeSshKnownHostKey, + RuntimeSshKnownHostStatus, +}; + +use super::*; + +pub(crate) struct TerminalSftpConnection { + pub(crate) _handle: client::Handle, + pub(crate) session: russh_sftp::client::SftpSession, +} + +pub(crate) struct LiveAgentSshClient { + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) captured_host_key: Arc>>, +} + +impl client::Handler for LiveAgentSshClient { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + server_public_key: &PublicKey, + ) -> Result { + let key_base64 = + base64::engine::general_purpose::STANDARD.encode(server_public_key.public_key_bytes()); + let key = RuntimeSshKnownHostKey { + host: self.host.clone(), + port: self.port, + key_type: server_public_key.algorithm().as_str().to_string(), + key_base64, + fingerprint_sha256: server_public_key.fingerprint(HashAlg::Sha256).to_string(), + }; + match check_runtime_ssh_known_host(&key) { + Ok(RuntimeSshKnownHostStatus::Known) => Ok(true), + Ok(status) => { + *self.captured_host_key.lock().await = Some(CapturedHostKey { key, status }); + Ok(false) + } + Err(error) => { + *self.captured_host_key.lock().await = Some(CapturedHostKey { + key, + status: RuntimeSshKnownHostStatus::Changed { + stored_fingerprint: error, + }, + }); + Ok(false) + } + } + } +} + +pub(crate) enum ResolvedSshAuth { + Password(String), + PrivateKey { + key: String, + passphrase: Option, + }, + Agent, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SshProxyKind { + Socks5, + Http, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedSshProxy { + pub(crate) kind: SshProxyKind, + pub(crate) host: String, + pub(crate) port: u16, + pub(crate) username: String, + pub(crate) password: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SshPathProfile { + Windows, + Posix, +} + +pub(crate) fn ssh_proxy_configured(host: &RuntimeSshHostConfig) -> bool { + !host.proxy.url.trim().is_empty() + || host.proxy.port > 0 + || !host.proxy.username.trim().is_empty() + || host.proxy.password_configured +} + +pub(crate) async fn connect_ssh_handle( + host_config: &RuntimeSshHostConfig, + captured_host_key: Arc>>, +) -> Result, String> { + let ssh_client = LiveAgentSshClient { + host: host_config.host.clone(), + port: host_config.port, + captured_host_key, + }; + let config = Arc::new(ssh_client_config()); + let stream = open_ssh_transport(host_config).await?; + client::connect_stream(config, stream, ssh_client) + .await + .map_err(|error| format!("SSH connection failed: {error}")) +} + +pub(crate) fn ssh_client_config() -> client::Config { + client::Config { + keepalive_interval: Some(SSH_KEEPALIVE_INTERVAL), + keepalive_max: SSH_KEEPALIVE_MAX_MISSES, + nodelay: true, + ..Default::default() + } +} + +pub(crate) async fn open_ssh_transport( + host_config: &RuntimeSshHostConfig, +) -> Result { + if !ssh_proxy_configured(host_config) { + let stream = TcpStream::connect((host_config.host.as_str(), host_config.port)) + .await + .map_err(|error| { + format!( + "SSH TCP connection to {}:{} failed: {error}", + host_config.host, host_config.port + ) + })?; + configure_ssh_transport_stream(&stream); + return Ok(stream); + } + + let proxy = resolve_ssh_proxy(host_config)?; + let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)) + .await + .map_err(|error| { + format!( + "SSH proxy connection to {}:{} failed: {error}", + proxy.host, proxy.port + ) + })?; + match proxy.kind { + SshProxyKind::Http => { + http_connect_proxy( + &mut stream, + host_config.host.as_str(), + host_config.port, + &proxy, + ) + .await?; + } + SshProxyKind::Socks5 => { + socks5_connect_proxy( + &mut stream, + host_config.host.as_str(), + host_config.port, + &proxy, + ) + .await?; + } + } + configure_ssh_transport_stream(&stream); + Ok(stream) +} + +pub(crate) fn configure_ssh_transport_stream(stream: &TcpStream) { + let _ = stream.set_nodelay(true); +} + +pub(crate) fn resolve_ssh_proxy( + host_config: &RuntimeSshHostConfig, +) -> Result { + let raw_url = host_config.proxy.url.trim(); + if raw_url.is_empty() { + return Err("SSH proxy host is required".to_string()); + } + let (scheme, authority) = split_proxy_scheme(raw_url); + let kind = resolve_proxy_kind(host_config.proxy.proxy_type.as_str(), scheme)?; + let authority = authority + .split(['/', '?', '#']) + .next() + .unwrap_or(authority) + .trim(); + let authority = authority.rsplit('@').next().unwrap_or(authority); + let (proxy_host, url_port) = split_host_port(authority); + if proxy_host.trim().is_empty() { + return Err("SSH proxy host is required".to_string()); + } + let configured_port = u16::try_from(host_config.proxy.port) + .ok() + .filter(|port| *port >= 1); + let default_port = match kind { + SshProxyKind::Socks5 => 1080, + SshProxyKind::Http => 8080, + }; + Ok(ResolvedSshProxy { + kind, + host: proxy_host, + port: configured_port.or(url_port).unwrap_or(default_port), + username: host_config.proxy.username.trim().to_string(), + password: host_config.proxy.password.trim().to_string(), + }) +} + +pub(crate) fn split_proxy_scheme(input: &str) -> (Option<&str>, &str) { + if let Some(index) = input.find("://") { + let (scheme, rest) = input.split_at(index); + return (Some(scheme), &rest[3..]); + } + (None, input) +} + +pub(crate) fn resolve_proxy_kind( + raw_type: &str, + scheme: Option<&str>, +) -> Result { + let source = scheme.unwrap_or(raw_type).trim().to_ascii_lowercase(); + match source.as_str() { + "http" => Ok(SshProxyKind::Http), + "" | "socks5" | "socks" => Ok(SshProxyKind::Socks5), + other => Err(format!("SSH proxy type is not supported: {other}")), + } +} + +pub(crate) fn split_host_port(authority: &str) -> (String, Option) { + let authority = authority.trim(); + if let Some(rest) = authority.strip_prefix('[') { + if let Some(end) = rest.find(']') { + let host = rest[..end].to_string(); + let port = rest[end + 1..].strip_prefix(':').and_then(parse_u16_port); + return (host, port); + } + } + if let Some((host, port)) = authority.rsplit_once(':') { + if !host.contains(':') { + return (host.to_string(), parse_u16_port(port)); + } + } + (authority.to_string(), None) +} + +pub(crate) fn parse_u16_port(value: &str) -> Option { + value.trim().parse::().ok().filter(|port| *port >= 1) +} + +pub(crate) async fn http_connect_proxy( + stream: &mut TcpStream, + target_host: &str, + target_port: u16, + proxy: &ResolvedSshProxy, +) -> Result<(), String> { + let target = host_port_authority(target_host, target_port); + let mut request = + format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\nProxy-Connection: Keep-Alive\r\n"); + if !proxy.username.is_empty() || !proxy.password.is_empty() { + let token = base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", proxy.username, proxy.password)); + request.push_str(&format!("Proxy-Authorization: Basic {token}\r\n")); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .await + .map_err(|error| format!("SSH HTTP proxy CONNECT request failed: {error}"))?; + + let mut response = Vec::with_capacity(512); + let mut byte = [0u8; 1]; + while !response.ends_with(b"\r\n\r\n") { + if response.len() >= 16 * 1024 { + return Err("SSH HTTP proxy CONNECT response is too large".to_string()); + } + let n = stream + .read(&mut byte) + .await + .map_err(|error| format!("SSH HTTP proxy CONNECT response failed: {error}"))?; + if n == 0 { + return Err("SSH HTTP proxy closed before CONNECT completed".to_string()); + } + response.push(byte[0]); + } + let text = String::from_utf8_lossy(&response); + let status_line = text.lines().next().unwrap_or_default(); + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if !(200..300).contains(&status_code) { + return Err(format!( + "SSH HTTP proxy CONNECT failed: {}", + status_line.trim() + )); + } + Ok(()) +} + +pub(crate) async fn socks5_connect_proxy( + stream: &mut TcpStream, + target_host: &str, + target_port: u16, + proxy: &ResolvedSshProxy, +) -> Result<(), String> { + let wants_auth = !proxy.username.is_empty() || !proxy.password.is_empty(); + if wants_auth + && (proxy.username.len() > u8::MAX as usize || proxy.password.len() > u8::MAX as usize) + { + return Err("SSH SOCKS5 proxy username/password is too long".to_string()); + } + let greeting: &[u8] = if wants_auth { + &[0x05, 0x02, 0x00, 0x02] + } else { + &[0x05, 0x01, 0x00] + }; + stream + .write_all(greeting) + .await + .map_err(|error| format!("SSH SOCKS5 proxy greeting failed: {error}"))?; + let mut method = [0u8; 2]; + stream + .read_exact(&mut method) + .await + .map_err(|error| format!("SSH SOCKS5 proxy method response failed: {error}"))?; + if method[0] != 0x05 { + return Err("SSH SOCKS5 proxy returned an invalid version".to_string()); + } + match method[1] { + 0x00 => {} + 0x02 => { + let mut auth = Vec::with_capacity(3 + proxy.username.len() + proxy.password.len()); + auth.push(0x01); + auth.push(proxy.username.len() as u8); + auth.extend_from_slice(proxy.username.as_bytes()); + auth.push(proxy.password.len() as u8); + auth.extend_from_slice(proxy.password.as_bytes()); + stream + .write_all(&auth) + .await + .map_err(|error| format!("SSH SOCKS5 proxy auth request failed: {error}"))?; + let mut auth_response = [0u8; 2]; + stream + .read_exact(&mut auth_response) + .await + .map_err(|error| format!("SSH SOCKS5 proxy auth response failed: {error}"))?; + if auth_response != [0x01, 0x00] { + return Err("SSH SOCKS5 proxy authentication failed".to_string()); + } + } + 0xff => return Err("SSH SOCKS5 proxy has no acceptable auth method".to_string()), + other => { + return Err(format!( + "SSH SOCKS5 proxy selected unsupported auth method: {other}" + )) + } + } + + let mut request = Vec::new(); + request.extend_from_slice(&[0x05, 0x01, 0x00]); + write_socks5_address(&mut request, target_host)?; + request.extend_from_slice(&target_port.to_be_bytes()); + stream + .write_all(&request) + .await + .map_err(|error| format!("SSH SOCKS5 proxy CONNECT request failed: {error}"))?; + + let mut response = [0u8; 4]; + stream + .read_exact(&mut response) + .await + .map_err(|error| format!("SSH SOCKS5 proxy CONNECT response failed: {error}"))?; + if response[0] != 0x05 { + return Err("SSH SOCKS5 proxy returned an invalid CONNECT version".to_string()); + } + if response[1] != 0x00 { + return Err(format!( + "SSH SOCKS5 proxy CONNECT failed: {}", + socks5_reply_label(response[1]) + )); + } + let address_len = match response[3] { + 0x01 => 4, + 0x03 => { + let mut len = [0u8; 1]; + stream + .read_exact(&mut len) + .await + .map_err(|error| format!("SSH SOCKS5 proxy response failed: {error}"))?; + usize::from(len[0]) + } + 0x04 => 16, + other => { + return Err(format!( + "SSH SOCKS5 proxy returned unsupported address type: {other}" + )) + } + }; + let mut discard = vec![0u8; address_len + 2]; + stream + .read_exact(&mut discard) + .await + .map_err(|error| format!("SSH SOCKS5 proxy response failed: {error}"))?; + Ok(()) +} + +pub(crate) fn write_socks5_address(out: &mut Vec, host: &str) -> Result<(), String> { + let normalized_host = strip_ipv6_brackets(host.trim()); + if let Ok(ip) = normalized_host.parse::() { + match ip { + IpAddr::V4(ip) => { + out.push(0x01); + out.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + out.push(0x04); + out.extend_from_slice(&ip.octets()); + } + } + return Ok(()); + } + if normalized_host.is_empty() || normalized_host.len() > u8::MAX as usize { + return Err("SSH SOCKS5 target host is empty or too long".to_string()); + } + out.push(0x03); + out.push(normalized_host.len() as u8); + out.extend_from_slice(normalized_host.as_bytes()); + Ok(()) +} + +pub(crate) fn strip_ipv6_brackets(host: &str) -> &str { + host.strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host) +} + +pub(crate) fn host_port_authority(host: &str, port: u16) -> String { + let host = host.trim(); + if strip_ipv6_brackets(host).parse::().is_ok() && !host.starts_with('[') { + format!("[{host}]:{port}") + } else { + format!("{host}:{port}") + } +} + +pub(crate) fn socks5_reply_label(code: u8) -> &'static str { + match code { + 0x01 => "general failure", + 0x02 => "connection not allowed", + 0x03 => "network unreachable", + 0x04 => "host unreachable", + 0x05 => "connection refused", + 0x06 => "TTL expired", + 0x07 => "command not supported", + 0x08 => "address type not supported", + _ => "unknown error", + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs new file mode 100644 index 00000000..57c2fbac --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_io.rs @@ -0,0 +1,177 @@ +use russh::client; +use russh::ChannelMsg; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncWriteExt; +use tokio::time::timeout; + +use super::*; + +pub(crate) async fn run_ssh_session_io( + registry: Arc, + session_id: String, + runtime: Arc, + connection_id: usize, + channel: russh::Channel, + mut input_rx: tokio::sync::mpsc::Receiver, + mut shutdown_rx: tokio::sync::mpsc::Receiver<()>, +) { + let (mut read_half, write_half) = channel.split(); + let (writer_end_tx, mut writer_end_rx) = tokio::sync::mpsc::channel::(1); + let writer_runtime = Arc::clone(&runtime); + tauri::async_runtime::spawn(async move { + let mut writer = write_half.make_writer(); + let reason = loop { + tokio::select! { + _ = shutdown_rx.recv() => { + let handle = writer_runtime.handle.lock().await; + if let Some(handle) = handle.as_ref() { + let _ = handle.disconnect(russh::Disconnect::ByApplication, "User disconnected", "en").await; + } + break SshSessionIoEndReason::Shutdown; + } + input = input_rx.recv() => { + match input { + Some(SshSessionInput::Data(data)) => { + if writer.write_all(&data).await.is_err() { + break SshSessionIoEndReason::WriteFailed; + } + } + Some(SshSessionInput::Resize(cols, rows)) => { + let _ = write_half.window_change(cols, rows, 0, 0).await; + } + None => { + break SshSessionIoEndReason::InputClosed; + }, + } + } + } + }; + let _ = writer_end_tx.send(reason).await; + }); + let mut remote_exit_reason: Option = None; + let end_reason = loop { + tokio::select! { + reason = writer_end_rx.recv() => { + break reason.unwrap_or(SshSessionIoEndReason::InputClosed); + } + message = read_half.wait() => { + match message { + Some(ChannelMsg::Data { data }) | Some(ChannelMsg::ExtendedData { data, .. }) => { + registry.append_output(&session_id, data.as_ref().to_vec()); + } + Some(ChannelMsg::ExitStatus { exit_status }) => { + remote_exit_reason = Some(SshSessionIoEndReason::RemoteExitStatus(exit_status)); + } + Some(ChannelMsg::ExitSignal { signal_name, .. }) => { + remote_exit_reason = Some(SshSessionIoEndReason::RemoteExitSignal(format!("{signal_name:?}"))); + } + Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) => { + break remote_exit_reason.unwrap_or(SshSessionIoEndReason::RemoteClosed); + } + None => { + break remote_exit_reason.unwrap_or(SshSessionIoEndReason::ConnectionLost); + } + _ => {} + } + } + } + }; + + finish_ssh_session_io(registry, session_id, runtime, connection_id, end_reason).await; +} + +pub(crate) async fn finish_ssh_session_io( + registry: Arc, + session_id: String, + runtime: Arc, + connection_id: usize, + end_reason: SshSessionIoEndReason, +) { + if runtime.is_closing() { + return; + } + match end_reason { + SshSessionIoEndReason::Shutdown | SshSessionIoEndReason::InputClosed => {} + SshSessionIoEndReason::RemoteExitStatus(status) => { + registry + .mark_ssh_shell_ended( + session_id, + runtime, + connection_id, + format!("\r\n[SSH] Remote shell exited with status {status}.\r\n"), + ) + .await; + } + SshSessionIoEndReason::RemoteExitSignal(signal) => { + registry + .mark_ssh_shell_ended( + session_id, + runtime, + connection_id, + format!("\r\n[SSH] Remote shell exited after signal {signal}.\r\n"), + ) + .await; + } + SshSessionIoEndReason::RemoteClosed => { + registry + .mark_ssh_shell_ended( + session_id, + runtime, + connection_id, + "\r\n[SSH] Remote shell closed.\r\n".to_string(), + ) + .await; + } + SshSessionIoEndReason::ConnectionLost => { + if ssh_connection_alive(&runtime, connection_id).await { + registry + .mark_ssh_shell_ended( + session_id, + runtime, + connection_id, + "\r\n[SSH] Remote shell closed.\r\n".to_string(), + ) + .await; + } else { + spawn_ssh_reconnect_runner(registry, session_id, runtime, connection_id); + } + } + SshSessionIoEndReason::WriteFailed => { + spawn_ssh_reconnect_runner(registry, session_id, runtime, connection_id); + } + } +} + +pub(crate) async fn ssh_connection_alive( + runtime: &Arc, + connection_id: usize, +) -> bool { + if runtime.current_connection_id() != connection_id || runtime.is_closing() { + return false; + } + let ping = timeout(Duration::from_secs(2), async { + let handle = runtime.handle.lock().await; + let Some(handle) = handle.as_ref() else { + return Err(russh::Error::Disconnect); + }; + handle.send_ping().await + }) + .await; + matches!(ping, Ok(Ok(()))) +} + +pub(crate) fn spawn_ssh_reconnect_runner( + registry: Arc, + session_id: String, + runtime: Arc, + connection_id: usize, +) { + // russh drives each session on the current Tokio runtime, so reconnects must + // live on Tauri's long-running runtime rather than a short-lived thread runtime. + tauri::async_runtime::spawn(async move { + registry + .handle_ssh_unexpected_disconnect(session_id, runtime, connection_id) + .await; + }); +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs new file mode 100644 index 00000000..eb0860b1 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/ssh_session.rs @@ -0,0 +1,779 @@ +use russh::client; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::time::timeout; + +use crate::commands::settings::{ + load_runtime_ssh_host, trust_runtime_ssh_known_host, RuntimeSshHostConfig, + RuntimeSshKnownHostStatus, +}; +use crate::runtime::project_path::project_path_key as normalize_project_path_key; + +use super::*; + +impl TerminalSessionRegistry { + pub async fn create_ssh( + self: &Arc, + cwd: String, + project_path_key: Option, + ssh_host_id: String, + title: Option, + cols: Option, + rows: Option, + sftp_enabled: bool, + ) -> Result { + let cwd = canonicalize_workdir(&cwd)?; + let project_key = project_path_key + .map(|value| normalize_project_path_key(&value)) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| normalize_project_path_key(&cwd.display().to_string())); + if project_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + let request = PendingSshConnectRequest { + cwd: cwd.display().to_string(), + project_path_key: project_key, + ssh_host_id, + title, + cols, + rows, + sftp_enabled, + }; + self.create_ssh_from_request(request).await + } + + pub async fn answer_ssh_prompt( + self: &Arc, + prompt_id: String, + answer: Option, + trust_host_key: bool, + ) -> Result { + let prompt_id = prompt_id.trim().to_string(); + if prompt_id.is_empty() { + return Err("prompt_id is required".to_string()); + } + let pending = self + .pending_ssh_prompts + .lock() + .map_err(|_| "ssh prompt registry poisoned".to_string())? + .remove(&prompt_id) + .ok_or_else(|| format!("ssh prompt not found: {prompt_id}"))?; + match pending { + PendingSshPrompt::HostKey { request, host_key } => { + if !trust_host_key { + return Err("SSH host key trust was cancelled".to_string()); + } + trust_runtime_ssh_known_host(&host_key)?; + self.create_ssh_from_request(request).await + } + PendingSshPrompt::KeyboardInteractive { + request, + host_config, + title, + size, + mut handle, + } => { + let response = handle + .authenticate_keyboard_interactive_respond(vec![answer.unwrap_or_default()]) + .await + .map_err(|error| { + format!("SSH keyboard-interactive response failed: {error}") + })?; + self.continue_ssh_keyboard_interactive( + request, + host_config, + title, + size, + handle, + response, + None, + ) + .await + } + } + } + + pub fn cancel_ssh_prompt(&self, prompt_id: String) -> Result<(), String> { + let prompt_id = prompt_id.trim().to_string(); + if prompt_id.is_empty() { + return Err("prompt_id is required".to_string()); + } + let pending = self + .pending_ssh_prompts + .lock() + .map_err(|_| "ssh prompt registry poisoned".to_string())? + .remove(&prompt_id); + if let Some(PendingSshPrompt::KeyboardInteractive { handle, .. }) = pending { + tokio::spawn(async move { + let _ = handle + .disconnect( + russh::Disconnect::ByApplication, + "Authentication cancelled", + "en", + ) + .await; + }); + } + Ok(()) + } + + pub(crate) async fn create_ssh_from_request( + self: &Arc, + request: PendingSshConnectRequest, + ) -> Result { + let host_config = load_runtime_ssh_host(&request.ssh_host_id)? + .ok_or_else(|| format!("SSH host not found: {}", request.ssh_host_id.trim()))?; + if host_config.host.trim().is_empty() { + return Err("SSH host is required".to_string()); + } + if host_config.username.trim().is_empty() { + return Err("SSH username is required".to_string()); + } + + let size = TerminalSize { + cols: request.cols.unwrap_or(DEFAULT_COLS).clamp(20, 400), + rows: request.rows.unwrap_or(DEFAULT_ROWS).clamp(6, 200), + }; + let title = request + .title + .as_ref() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| self.next_ssh_title(&request.project_path_key, &host_config.name)); + + let auth = resolve_ssh_auth_material(&host_config)?; + let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); + let mut handle = + match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { + Ok(handle) => handle, + Err(error) => { + if let Some(captured) = captured_host_key.lock().await.clone() { + return self.ssh_host_key_response(request, &host_config, captured); + } + return Err(error); + } + }; + + match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { + SshAuthOutcome::Authenticated => { + self.finish_create_ssh_session(request, host_config, title, size, handle) + .await + } + SshAuthOutcome::KeyboardInteractivePrompt(prompt_data) => self + .ssh_keyboard_interactive_response( + request, + host_config, + title, + size, + handle, + prompt_data, + ), + } + } + + pub(crate) async fn finish_create_ssh_session( + self: &Arc, + request: PendingSshConnectRequest, + host_config: RuntimeSshHostConfig, + title: String, + size: TerminalSize, + handle: client::Handle, + ) -> Result { + let channel = open_ssh_shell_channel(&handle, size).await?; + + let id = uuid::Uuid::new_v4().to_string(); + let now = now_ms(); + let ssh = TerminalSshMetadata { + host_id: host_config.id.clone(), + host_name: host_config.name.clone(), + username: host_config.username.clone(), + host: host_config.host.clone(), + port: host_config.port, + auth_type: host_config.auth_type.clone(), + status: SSH_STATUS_CONNECTED.to_string(), + reconnect_attempt: 0, + reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS, + sftp_enabled: request.sftp_enabled, + }; + let record = TerminalSessionRecord { + id: id.clone(), + project_path_key: request.project_path_key.clone(), + cwd: request.cwd.clone(), + shell: "ssh".to_string(), + title, + kind: "ssh".to_string(), + ssh: Some(ssh), + pid: None, + cols: size.cols, + rows: size.rows, + created_at: now, + updated_at: now, + finished_at: None, + exit_code: None, + running: true, + }; + + let runtime = Arc::new(SshSessionRuntime::new()); + let (input_tx, input_rx) = tokio::sync::mpsc::channel::(256); + let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); + let connection_id = runtime + .install_connection(handle, input_tx, shutdown_tx) + .await; + let entry = Arc::new(TerminalSessionEntry { + backend: TerminalSessionBackend::Ssh { + runtime: Arc::clone(&runtime), + }, + record: Mutex::new(record), + output: Mutex::new(TerminalOutputBuffer::default()), + }); + self.sessions + .lock() + .expect("terminal session registry poisoned") + .insert(id.clone(), Arc::clone(&entry)); + self.broadcast("created", &entry, None, None, None); + + let registry = Arc::clone(self); + tauri::async_runtime::spawn(run_ssh_session_io( + registry, + id.clone(), + Arc::clone(&runtime), + connection_id, + channel, + input_rx, + shutdown_rx, + )); + + self.snapshot(id, Some(MAX_TAIL_BYTES)) + .map(terminal_ssh_create_response_from_snapshot) + } + + pub(crate) async fn reconnect_ssh_session( + self: &Arc, + entry: Arc, + attempt: u8, + ) -> Result<(), String> { + let record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + let ssh = record + .ssh + .clone() + .ok_or_else(|| "SSH session metadata is missing".to_string())?; + let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { + return Err("terminal session is not an SSH connection".to_string()); + }; + if runtime.is_closing() { + return Err("SSH session is closing".to_string()); + } + let host_config = load_runtime_ssh_host(&ssh.host_id)? + .ok_or_else(|| format!("SSH host not found: {}", ssh.host_id.trim()))?; + + let auth = resolve_ssh_auth_material(&host_config)?; + let captured_host_key = Arc::new(tokio::sync::Mutex::new(None::)); + let mut handle = + match connect_ssh_handle(&host_config, Arc::clone(&captured_host_key)).await { + Ok(handle) => handle, + Err(error) => { + if captured_host_key.lock().await.is_some() { + return Err( + "SSH host key requires confirmation before reconnecting".to_string() + ); + } + return Err(error); + } + }; + + match authenticate_ssh_handle(&mut handle, &host_config, auth).await? { + SshAuthOutcome::Authenticated => {} + SshAuthOutcome::KeyboardInteractivePrompt(_) => { + let _ = handle + .disconnect( + russh::Disconnect::ByApplication, + "Keyboard-interactive reconnect requires user input", + "en", + ) + .await; + return Err( + "SSH reconnect requires keyboard-interactive input from the user".to_string(), + ); + } + } + + let size = TerminalSize { + cols: record.cols, + rows: record.rows, + }; + let channel = open_ssh_shell_channel(&handle, size).await?; + let (input_tx, input_rx) = tokio::sync::mpsc::channel::(256); + let (shutdown_tx, shutdown_rx) = tokio::sync::mpsc::channel::<()>(1); + let connection_id = runtime + .install_connection(handle, input_tx, shutdown_tx) + .await; + { + let mut record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())?; + record.running = true; + record.finished_at = None; + record.exit_code = None; + record.updated_at = now_ms(); + if let Some(ssh) = record.ssh.as_mut() { + ssh.status = SSH_STATUS_CONNECTED.to_string(); + ssh.reconnect_attempt = 0; + ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; + } + } + self.append_output( + &record.id, + format!("\r\n[SSH] Reconnected after attempt {attempt}.\r\n"), + ); + self.broadcast("reconnected", &entry, None, None, None); + + let registry = Arc::clone(self); + tauri::async_runtime::spawn(run_ssh_session_io( + registry, + record.id, + Arc::clone(runtime), + connection_id, + channel, + input_rx, + shutdown_rx, + )); + Ok(()) + } + + pub(crate) async fn handle_ssh_unexpected_disconnect( + self: Arc, + session_id: String, + runtime: Arc, + connection_id: usize, + ) { + if !runtime.begin_reconnect_runner() { + return; + } + if runtime.current_connection_id() != connection_id { + runtime.finish_reconnect_runner(); + return; + } + runtime.clear_connection_if_current(connection_id).await; + if runtime.is_closing() { + runtime.finish_reconnect_runner(); + return; + } + let Ok(entry) = self.entry(&session_id) else { + runtime.finish_reconnect_runner(); + return; + }; + for attempt in 1..=SSH_RECONNECT_MAX_ATTEMPTS { + if runtime.is_closing() { + runtime.finish_reconnect_runner(); + return; + } + self.mark_ssh_reconnecting(&entry, attempt); + self.append_output( + &session_id, + format!( + "\r\n[SSH] Connection lost. Reconnecting ({attempt}/{SSH_RECONNECT_MAX_ATTEMPTS})...\r\n" + ), + ); + let delay = SSH_RECONNECT_DELAYS + .get(usize::from(attempt.saturating_sub(1))) + .copied() + .unwrap_or_else(|| Duration::from_secs(10)); + tokio::time::sleep(delay).await; + if runtime.is_closing() { + runtime.finish_reconnect_runner(); + return; + } + let reconnect_result = match timeout( + SSH_RECONNECT_ATTEMPT_TIMEOUT, + self.reconnect_ssh_session(Arc::clone(&entry), attempt), + ) + .await + { + Ok(result) => result, + Err(_) => Err(format!( + "SSH reconnect timed out after {} seconds", + SSH_RECONNECT_ATTEMPT_TIMEOUT.as_secs() + )), + }; + match reconnect_result { + Ok(()) => { + runtime.finish_reconnect_runner(); + return; + } + Err(error) => { + self.append_output( + &session_id, + format!( + "[SSH] Reconnect attempt {attempt}/{SSH_RECONNECT_MAX_ATTEMPTS} failed: {error}\r\n" + ), + ); + } + } + } + self.mark_ssh_disconnected(&entry); + self.append_output( + &session_id, + format!("[SSH] Reconnect failed after {SSH_RECONNECT_MAX_ATTEMPTS} attempts.\r\n"), + ); + runtime.finish_reconnect_runner(); + } + + pub(crate) async fn continue_ssh_keyboard_interactive( + self: &Arc, + request: PendingSshConnectRequest, + host_config: RuntimeSshHostConfig, + title: String, + size: TerminalSize, + mut handle: client::Handle, + response: client::KeyboardInteractiveAuthResponse, + auto_password: Option, + ) -> Result { + match continue_keyboard_interactive_auth(&mut handle, response, auto_password).await? { + SshAuthOutcome::Authenticated => { + self.finish_create_ssh_session(request, host_config, title, size, handle) + .await + } + SshAuthOutcome::KeyboardInteractivePrompt(prompt_data) => self + .ssh_keyboard_interactive_response( + request, + host_config, + title, + size, + handle, + prompt_data, + ), + } + } + + pub(crate) fn ssh_keyboard_interactive_response( + self: &Arc, + request: PendingSshConnectRequest, + host_config: RuntimeSshHostConfig, + title: String, + size: TerminalSize, + handle: client::Handle, + prompt_data: KeyboardInteractivePromptData, + ) -> Result { + let prompt_id = uuid::Uuid::new_v4().to_string(); + let message = ssh_keyboard_interactive_message(&prompt_data); + let prompt = TerminalSshPrompt { + id: prompt_id.clone(), + kind: "keyboardInteractive".to_string(), + host_id: host_config.id.clone(), + host_name: host_config.name.clone(), + host: host_config.host.clone(), + port: host_config.port, + message, + fingerprint_sha256: String::new(), + key_type: String::new(), + answer_echo: prompt_data.echo, + }; + self.pending_ssh_prompts + .lock() + .map_err(|_| "ssh prompt registry poisoned".to_string())? + .insert( + prompt_id.clone(), + PendingSshPrompt::KeyboardInteractive { + request, + host_config, + title, + size, + handle, + }, + ); + self.schedule_ssh_prompt_timeout(prompt_id); + Ok(TerminalSshCreateResponse { + session: None, + output: String::new(), + output_bytes: Vec::new(), + truncated: false, + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: Some(prompt), + }) + } + + pub(crate) fn ssh_host_key_response( + self: &Arc, + request: PendingSshConnectRequest, + host_config: &RuntimeSshHostConfig, + captured: CapturedHostKey, + ) -> Result { + match captured.status { + RuntimeSshKnownHostStatus::Known => { + Err("SSH host key check failed unexpectedly".to_string()) + } + RuntimeSshKnownHostStatus::Changed { stored_fingerprint } => Err(format!( + "SSH host key changed for {}:{}. Stored fingerprint: {}. Received fingerprint: {}.", + host_config.host, + host_config.port, + stored_fingerprint, + captured.key.fingerprint_sha256 + )), + RuntimeSshKnownHostStatus::Unknown => { + let prompt_id = uuid::Uuid::new_v4().to_string(); + let prompt = TerminalSshPrompt { + id: prompt_id.clone(), + kind: "hostKey".to_string(), + host_id: host_config.id.clone(), + host_name: host_config.name.clone(), + host: host_config.host.clone(), + port: host_config.port, + message: format!( + "Trust SSH host key for {}:{}?", + host_config.host, host_config.port + ), + fingerprint_sha256: captured.key.fingerprint_sha256.clone(), + key_type: captured.key.key_type.clone(), + answer_echo: false, + }; + self.pending_ssh_prompts + .lock() + .map_err(|_| "ssh prompt registry poisoned".to_string())? + .insert( + prompt_id.clone(), + PendingSshPrompt::HostKey { + request, + host_key: captured.key, + }, + ); + self.schedule_ssh_prompt_timeout(prompt_id); + Ok(TerminalSshCreateResponse { + session: None, + output: String::new(), + output_bytes: Vec::new(), + truncated: false, + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: Some(prompt), + }) + } + } + } + + pub(crate) fn schedule_ssh_prompt_timeout(self: &Arc, prompt_id: String) { + let registry = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(SSH_PROMPT_TIMEOUT).await; + let pending = registry + .pending_ssh_prompts + .lock() + .ok() + .and_then(|mut prompts| prompts.remove(&prompt_id)); + if let Some(PendingSshPrompt::KeyboardInteractive { handle, .. }) = pending { + let _ = handle + .disconnect( + russh::Disconnect::ByApplication, + "Authentication prompt timed out", + "en", + ) + .await; + } + }); + } + + pub fn ssh_session_info(&self, session_id: &str) -> Result { + let record = self.record(session_id.to_string())?; + if record.kind.trim() != "ssh" { + return Err("terminal session is not an SSH connection".to_string()); + } + let ssh = record + .ssh + .ok_or_else(|| "SSH session metadata is missing".to_string())?; + Ok(TerminalSshSessionInfo { + project_path_key: record.project_path_key, + cwd: record.cwd, + running: record.running, + host_id: ssh.host_id, + sftp_enabled: ssh.sftp_enabled, + }) + } + + pub async fn ssh_latency( + self: &Arc, + session_id: String, + ) -> Result { + let entry = self.entry(&session_id)?; + let record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + if record.kind.trim() != "ssh" { + return Err("terminal session is not an SSH connection".to_string()); + } + if !record.running { + return Err("SSH connection is not running".to_string()); + } + let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { + return Err("terminal session is not an SSH connection".to_string()); + }; + let start = Instant::now(); + let ping = timeout(Duration::from_secs(3), async { + let handle = runtime.handle.lock().await; + let Some(handle) = handle.as_ref() else { + return Err(russh::Error::Disconnect); + }; + handle.send_ping().await + }) + .await; + match ping { + Ok(Ok(())) => {} + Ok(Err(error)) => return Err(format!("SSH latency check failed: {error}")), + Err(_) => return Err("SSH latency check timed out".to_string()), + } + let elapsed = start.elapsed().as_millis().clamp(1, u128::from(u32::MAX)) as u32; + Ok(TerminalSshLatencyResponse { + session_id: record.id, + latency_ms: elapsed, + }) + } + + pub async fn ssh_exec( + self: &Arc, + session_id: String, + command: String, + cwd: Option, + timeout_ms: Option, + max_bytes: Option, + ) -> Result { + let command = command.trim().to_string(); + if command.is_empty() { + return Err("command is required".to_string()); + } + let entry = self.entry(&session_id)?; + let record = entry + .record + .lock() + .map_err(|_| "terminal session lock poisoned".to_string())? + .clone(); + if record.kind.trim() != "ssh" { + return Err("terminal session is not an SSH connection".to_string()); + } + if !record.running { + return Err("SSH connection is not running".to_string()); + } + let TerminalSessionBackend::Ssh { runtime } = &entry.backend else { + return Err("terminal session is not an SSH connection".to_string()); + }; + + let cwd = cwd + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let wrapped_command = wrap_ssh_exec_command(&command, cwd.as_deref()); + let timeout_duration = normalize_ssh_exec_timeout(timeout_ms); + let capture_limit = normalize_ssh_exec_max_bytes(max_bytes); + let start = Instant::now(); + let result = timeout( + timeout_duration, + run_ssh_exec_channel(runtime, wrapped_command, capture_limit), + ) + .await; + let duration_ms = start.elapsed().as_millis(); + + match result { + Ok(Ok(mut response)) => { + response.session_id = record.id; + response.command = command; + response.cwd = cwd; + response.duration_ms = duration_ms; + Ok(response) + } + Ok(Err(error)) => { + spawn_ssh_reconnect_runner( + Arc::clone(self), + record.id, + Arc::clone(runtime), + runtime.current_connection_id(), + ); + Err(format!("SSH exec failed: {error}")) + } + Err(_) => Ok(TerminalSshExecResponse { + session_id: record.id, + command, + cwd, + exit_code: None, + exit_signal: None, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + timed_out: true, + duration_ms, + }), + } + } + + pub(crate) fn mark_ssh_reconnecting(&self, entry: &Arc, attempt: u8) { + { + let mut record = match entry.record.lock() { + Ok(record) => record, + Err(_) => return, + }; + record.running = false; + record.finished_at = None; + record.exit_code = None; + record.updated_at = now_ms(); + if let Some(ssh) = record.ssh.as_mut() { + ssh.status = SSH_STATUS_RECONNECTING.to_string(); + ssh.reconnect_attempt = attempt; + ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; + } + } + self.broadcast("reconnecting", entry, None, None, None); + } + + pub(crate) fn mark_ssh_disconnected(&self, entry: &Arc) { + let tab_snapshots = { + let Ok(_tabs_tx) = self.lock_ssh_terminal_tabs_tx() else { + return; + }; + let session_id = { + let mut record = match entry.record.lock() { + Ok(record) => record, + Err(_) => return, + }; + let session_id = record.id.clone(); + record.running = false; + record.finished_at = Some(now_ms()); + record.exit_code = None; + record.updated_at = now_ms(); + if let Some(ssh) = record.ssh.as_mut() { + ssh.status = SSH_STATUS_DISCONNECTED.to_string(); + ssh.reconnect_attempt = SSH_RECONNECT_MAX_ATTEMPTS; + ssh.reconnect_max_attempts = SSH_RECONNECT_MAX_ATTEMPTS; + } + session_id + }; + self.prune_ssh_terminal_tabs_for_session_locked(&session_id) + }; + self.broadcast("exit", entry, None, None, None); + for snapshot in tab_snapshots { + self.broadcast_ssh_tabs_snapshot(snapshot); + } + } + + pub(crate) async fn mark_ssh_shell_ended( + self: Arc, + session_id: String, + runtime: Arc, + connection_id: usize, + message: String, + ) { + if runtime.current_connection_id() != connection_id || runtime.is_closing() { + return; + } + runtime.clear_connection_if_current(connection_id).await; + if message.trim().len() > 0 { + self.append_output(&session_id, message); + } + if let Ok(entry) = self.entry(&session_id) { + self.mark_ssh_disconnected(&entry); + } + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/state.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/state.rs new file mode 100644 index 00000000..41a799d7 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/state.rs @@ -0,0 +1,192 @@ +use portable_pty::{Child, MasterPty}; +use russh::client; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; + +use crate::commands::settings::{ + RuntimeSshHostConfig, RuntimeSshKnownHostKey, RuntimeSshKnownHostStatus, +}; + +use super::*; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TerminalSize { + pub(crate) cols: u16, + pub(crate) rows: u16, +} + +pub(crate) struct TerminalSessionEntry { + pub(crate) backend: TerminalSessionBackend, + pub(crate) record: Mutex, + pub(crate) output: Mutex, +} + +pub(crate) enum TerminalSessionBackend { + Local { + master: Mutex>, + input_tx: mpsc::SyncSender>, + child: Mutex>, + }, + Ssh { + runtime: Arc, + }, +} + +pub(crate) struct SshSessionRuntime { + pub(crate) handle: tokio::sync::Mutex>>, + pub(crate) input_tx: Mutex>>, + pub(crate) shutdown_tx: Mutex>>, + pub(crate) connection_id: AtomicUsize, + pub(crate) closing: AtomicBool, + pub(crate) reconnect_runner_active: AtomicBool, +} + +impl SshSessionRuntime { + pub(crate) fn new() -> Self { + Self { + handle: tokio::sync::Mutex::new(None), + input_tx: Mutex::new(None), + shutdown_tx: Mutex::new(None), + connection_id: AtomicUsize::new(0), + closing: AtomicBool::new(false), + reconnect_runner_active: AtomicBool::new(false), + } + } + + pub(crate) async fn install_connection( + &self, + handle: client::Handle, + input_tx: tokio::sync::mpsc::Sender, + shutdown_tx: tokio::sync::mpsc::Sender<()>, + ) -> usize { + let connection_id = self.connection_id.fetch_add(1, Ordering::SeqCst) + 1; + *self.handle.lock().await = Some(handle); + if let Ok(mut slot) = self.input_tx.lock() { + *slot = Some(input_tx); + } + if let Ok(mut slot) = self.shutdown_tx.lock() { + *slot = Some(shutdown_tx); + } + connection_id + } + + pub(crate) async fn clear_connection_if_current(&self, connection_id: usize) { + if self.connection_id.load(Ordering::SeqCst) != connection_id { + return; + } + *self.handle.lock().await = None; + if let Ok(mut slot) = self.input_tx.lock() { + *slot = None; + } + if let Ok(mut slot) = self.shutdown_tx.lock() { + *slot = None; + } + } + + pub(crate) fn input_sender(&self) -> Option> { + self.input_tx.lock().ok().and_then(|slot| slot.clone()) + } + + pub(crate) fn shutdown_sender(&self) -> Option> { + self.shutdown_tx.lock().ok().and_then(|slot| slot.clone()) + } + + pub(crate) fn close(&self) -> Option> { + self.closing.store(true, Ordering::SeqCst); + self.shutdown_sender() + } + + pub(crate) fn is_closing(&self) -> bool { + self.closing.load(Ordering::SeqCst) + } + + pub(crate) fn current_connection_id(&self) -> usize { + self.connection_id.load(Ordering::SeqCst) + } + + pub(crate) fn begin_reconnect_runner(&self) -> bool { + !self.reconnect_runner_active.swap(true, Ordering::SeqCst) + } + + pub(crate) fn finish_reconnect_runner(&self) { + self.reconnect_runner_active.store(false, Ordering::SeqCst); + } +} + +pub(crate) enum SshSessionInput { + Data(Vec), + Resize(u32, u32), +} + +pub(crate) enum SshSessionIoEndReason { + Shutdown, + InputClosed, + WriteFailed, + RemoteClosed, + RemoteExitStatus(u32), + RemoteExitSignal(String), + ConnectionLost, +} + +#[derive(Debug, Clone)] +pub(crate) struct PendingSshConnectRequest { + pub(crate) cwd: String, + pub(crate) project_path_key: String, + pub(crate) ssh_host_id: String, + pub(crate) title: Option, + pub(crate) cols: Option, + pub(crate) rows: Option, + pub(crate) sftp_enabled: bool, +} + +pub(crate) enum PendingSshPrompt { + HostKey { + request: PendingSshConnectRequest, + host_key: RuntimeSshKnownHostKey, + }, + KeyboardInteractive { + request: PendingSshConnectRequest, + host_config: RuntimeSshHostConfig, + title: String, + size: TerminalSize, + handle: client::Handle, + }, +} + +#[derive(Debug, Clone)] +pub(crate) struct KeyboardInteractivePromptData { + pub(crate) name: String, + pub(crate) instructions: String, + pub(crate) prompt: String, + pub(crate) echo: bool, +} + +pub(crate) enum SshAuthOutcome { + Authenticated, + KeyboardInteractivePrompt(KeyboardInteractivePromptData), +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum PasswordKbiPromptAction { + RespondEmpty, + SendPassword, + PromptUser, +} + +#[derive(Debug, Clone)] +pub(crate) struct CapturedHostKey { + pub(crate) key: RuntimeSshKnownHostKey, + pub(crate) status: RuntimeSshKnownHostStatus, +} + +#[derive(Debug, Default)] +pub(crate) struct TerminalOutputDispatch { + pub(crate) local: Vec, + pub(crate) remote: Vec, +} + +#[derive(Debug, Default)] +pub(crate) struct SshTerminalTabsState { + pub(crate) tabs: Vec, + pub(crate) revision: u64, +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/tabs.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/tabs.rs new file mode 100644 index 00000000..68749d99 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/tabs.rs @@ -0,0 +1,231 @@ +use std::collections::HashMap; +use std::sync::MutexGuard; + +use crate::runtime::project_path::project_path_keys_equal; + +use super::*; + +impl TerminalSessionRegistry { + pub fn ssh_terminal_tabs_list( + &self, + project_path_key: String, + ) -> Result { + let project_key = required_project_key(project_path_key)?; + let (snapshot, should_broadcast) = { + let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; + if let Some(snapshot) = self.prune_invalid_ssh_terminal_tabs_for_project(&project_key) { + (snapshot, true) + } else { + (self.ssh_terminal_tabs_snapshot(&project_key), false) + } + }; + if should_broadcast { + self.broadcast_ssh_tabs_snapshot(snapshot.clone()); + } + Ok(snapshot) + } + + pub fn ssh_terminal_tab_open( + &self, + session_id: String, + kind: String, + ) -> Result { + let kind = normalize_ssh_terminal_tab_kind(&kind)?; + let (snapshot, should_broadcast) = { + let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; + let session = self.valid_ssh_terminal_tab_session(&session_id, &kind)?; + let tab_id = ssh_terminal_tab_id(&session.id, &kind); + let now = now_ms(); + let mut tabs_by_project = self + .ssh_terminal_tabs + .lock() + .map_err(|_| "ssh terminal tabs registry poisoned".to_string())?; + let state = tabs_by_project + .entry(session.project_path_key.clone()) + .or_default(); + if state.tabs.iter().any(|tab| tab.id == tab_id) { + return Ok(ssh_terminal_tabs_snapshot_from_state( + &session.project_path_key, + state, + )); + } else { + state.tabs.push(SshTerminalTabRecord { + id: tab_id.clone(), + session_id: session.id.clone(), + project_path_key: session.project_path_key.clone(), + kind, + created_at: now, + updated_at: now, + }); + } + state.revision = state.revision.saturating_add(1); + ( + ssh_terminal_tabs_snapshot_from_state(&session.project_path_key, state), + true, + ) + }; + if should_broadcast { + self.broadcast_ssh_tabs_snapshot(snapshot.clone()); + } + Ok(snapshot) + } + + pub fn ssh_terminal_tab_close( + &self, + tab_id: String, + ) -> Result { + let tab_id = tab_id.trim(); + if tab_id.is_empty() { + return Err("tab_id is required".to_string()); + } + let snapshot = { + let _tabs_tx = self.lock_ssh_terminal_tabs_tx()?; + let mut tabs_by_project = self + .ssh_terminal_tabs + .lock() + .map_err(|_| "ssh terminal tabs registry poisoned".to_string())?; + let Some(project_key) = tabs_by_project.iter().find_map(|(project_key, state)| { + state + .tabs + .iter() + .any(|tab| tab.id == tab_id) + .then(|| project_key.clone()) + }) else { + return Err(format!("ssh terminal tab not found: {tab_id}")); + }; + let state = tabs_by_project + .get_mut(&project_key) + .ok_or_else(|| format!("ssh terminal tab not found: {tab_id}"))?; + let Some(index) = state.tabs.iter().position(|tab| tab.id == tab_id) else { + return Err(format!("ssh terminal tab not found: {tab_id}")); + }; + state.tabs.remove(index); + state.revision = state.revision.saturating_add(1); + ssh_terminal_tabs_snapshot_from_state(&project_key, state) + }; + self.broadcast_ssh_tabs_snapshot(snapshot.clone()); + Ok(snapshot) + } + + pub(crate) fn lock_ssh_terminal_tabs_tx(&self) -> Result, String> { + self.ssh_terminal_tabs_tx + .lock() + .map_err(|_| "ssh terminal tabs transaction lock poisoned".to_string()) + } + + pub(crate) fn valid_ssh_terminal_tab_session( + &self, + session_id: &str, + kind: &str, + ) -> Result { + let session = self.record(session_id.trim().to_string())?; + if session.kind.trim() != "ssh" { + return Err("terminal session is not an SSH connection".to_string()); + } + let ssh = session + .ssh + .as_ref() + .ok_or_else(|| "SSH session metadata is missing".to_string())?; + if ssh.status == SSH_STATUS_DISCONNECTED { + return Err("SSH connection is disconnected".to_string()); + } + if kind == "sftp" && !ssh.sftp_enabled { + return Err("SFTP is not enabled for this SSH session".to_string()); + } + Ok(session) + } + + pub(crate) fn ssh_terminal_tabs_snapshot( + &self, + project_path_key: &str, + ) -> SshTerminalTabsSnapshot { + self.ssh_terminal_tabs + .lock() + .ok() + .and_then(|tabs_by_project| { + tabs_by_project + .get(project_path_key) + .map(|state| ssh_terminal_tabs_snapshot_from_state(project_path_key, state)) + }) + .unwrap_or_else(|| SshTerminalTabsSnapshot { + project_path_key: project_path_key.to_string(), + tabs: Vec::new(), + revision: 0, + }) + } + + pub(crate) fn prune_invalid_ssh_terminal_tabs_for_project( + &self, + project_path_key: &str, + ) -> Option { + let valid_sessions = self + .sessions + .lock() + .ok() + .map(|sessions| { + sessions + .values() + .filter_map(|entry| entry.record.lock().ok().map(|record| record.clone())) + .filter(|record| { + project_path_keys_equal(&record.project_path_key, project_path_key) + && record.kind.trim() == "ssh" + && record + .ssh + .as_ref() + .map(|ssh| ssh.status != SSH_STATUS_DISCONNECTED) + .unwrap_or(false) + }) + .map(|record| { + ( + record.id, + record.ssh.map(|ssh| ssh.sftp_enabled).unwrap_or(false), + ) + }) + .collect::>() + }) + .unwrap_or_default(); + + let mut tabs_by_project = self.ssh_terminal_tabs.lock().ok()?; + let state = tabs_by_project.get_mut(project_path_key)?; + let before_len = state.tabs.len(); + state.tabs.retain(|tab| { + valid_sessions + .get(&tab.session_id) + .map(|sftp_enabled| tab.kind != "sftp" || *sftp_enabled) + .unwrap_or(false) + }); + if state.tabs.len() == before_len { + return None; + } + state.revision = state.revision.saturating_add(1); + Some(ssh_terminal_tabs_snapshot_from_state( + project_path_key, + state, + )) + } + + pub(crate) fn prune_ssh_terminal_tabs_for_session_locked( + &self, + session_id: &str, + ) -> Vec { + let session_id = session_id.trim(); + if session_id.is_empty() { + return Vec::new(); + } + let mut tabs_by_project = match self.ssh_terminal_tabs.lock() { + Ok(tabs_by_project) => tabs_by_project, + Err(_) => return Vec::new(), + }; + let mut snapshots = Vec::new(); + for (project_key, state) in tabs_by_project.iter_mut() { + let before_len = state.tabs.len(); + state.tabs.retain(|tab| tab.session_id != session_id); + if state.tabs.len() == before_len { + continue; + } + state.revision = state.revision.saturating_add(1); + snapshots.push(ssh_terminal_tabs_snapshot_from_state(project_key, state)); + } + snapshots + } +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs new file mode 100644 index 00000000..0837af54 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/tests.rs @@ -0,0 +1,757 @@ +use base64::Engine; +use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; +use russh::client; +use russh::keys::agent::client::{AgentClient, AgentStream}; +use russh::keys::agent::AgentIdentity; +use russh::keys::ssh_key::HashAlg; +use russh::keys::{PrivateKeyWithHashAlg, PublicKey, PublicKeyBase64}; +use russh::ChannelMsg; +use russh::MethodKind; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; +use std::fs; +use std::io::{Read, Write}; +use std::net::{IpAddr, Ipv6Addr}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex, MutexGuard}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tauri::{AppHandle, Emitter}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; + +use crate::commands::settings::{ + check_runtime_ssh_known_host, load_runtime_ssh_host, trust_runtime_ssh_known_host, + RuntimeSshHostConfig, RuntimeSshKnownHostKey, RuntimeSshKnownHostStatus, +}; +use crate::runtime::platform::expand_tilde_path; +use crate::runtime::project_path::{ + project_path_key as normalize_project_path_key, project_path_keys_equal, +}; + +use super::*; + +#[test] +fn shell_options_include_default() { + let options = terminal_shell_options(); + assert!(!options.default_shell.trim().is_empty()); + assert!(!options.options.is_empty()); +} + +#[test] +fn ssh_client_config_enables_interactive_keepalive() { + let config = ssh_client_config(); + + assert_eq!(config.keepalive_interval, Some(SSH_KEEPALIVE_INTERVAL)); + assert_eq!(config.keepalive_max, SSH_KEEPALIVE_MAX_MISSES); + assert!(config.nodelay); +} + +#[test] +fn output_tail_respects_byte_limit_inside_large_chunk() { + let mut output = TerminalOutputBuffer::default(); + output.append(b"prefix".to_vec()); + output.append(b"abcdefghijklmnopqrstuvwxyz".to_vec()); + + let tail = read_output_chunks_tail(&output, 8); + + assert_eq!(tail.output, b"stuvwxyz"); + assert_eq!(tail.output_start_offset, 24); + assert_eq!(tail.output_end_offset, 32); + assert!(tail.truncated); +} + +#[test] +fn output_tail_keeps_offsets_for_repeated_text() { + let mut output = TerminalOutputBuffer::default(); + output.append(b"uploads\n".to_vec()); + output.append(b"uploads\n".to_vec()); + + let tail = read_output_chunks_tail(&output, MAX_TAIL_BYTES); + + assert_eq!(tail.output, b"uploads\nuploads\n"); + assert_eq!(tail.output_start_offset, 0); + assert_eq!(tail.output_end_offset, 16); + assert!(!tail.truncated); +} + +#[test] +fn remote_input_echo_is_delayed_for_local_until_enter() { + let mut state = TerminalEchoDispatchState::default(); + state + .pending + .extend(b"echo hi\r".iter().copied().map(|byte| PendingEchoByte { + byte, + origin: TerminalInputOrigin::Remote, + })); + + let first = state.dispatch(test_stream_payload(0, b"echo")); + assert_eq!(collect_payload_bytes(&first.remote), b"echo"); + assert!(first.local.is_empty()); + + let second = state.dispatch(test_stream_payload(4, b" hi\r\n")); + assert_eq!(collect_payload_bytes(&second.remote), b" hi\r\n"); + assert_eq!(collect_payload_bytes(&second.local), b"echo hi\r\n"); + assert!(state.is_empty()); +} + +#[test] +fn local_input_echo_is_delayed_for_remote_until_enter() { + let mut state = TerminalEchoDispatchState::default(); + state + .pending + .extend(b"pwd\r".iter().copied().map(|byte| PendingEchoByte { + byte, + origin: TerminalInputOrigin::Local, + })); + + let first = state.dispatch(test_stream_payload(10, b"pw")); + assert_eq!(collect_payload_bytes(&first.local), b"pw"); + assert!(first.remote.is_empty()); + + let second = state.dispatch(test_stream_payload(12, b"d\r\n")); + assert_eq!(collect_payload_bytes(&second.local), b"d\r\n"); + assert_eq!(collect_payload_bytes(&second.remote), b"pwd\r\n"); + assert!(state.is_empty()); +} + +#[test] +fn no_echo_password_input_does_not_leak_to_other_side() { + let mut state = TerminalEchoDispatchState::default(); + state + .pending + .extend(b"secret\r".iter().copied().map(|byte| PendingEchoByte { + byte, + origin: TerminalInputOrigin::Remote, + })); + + let dispatch = state.dispatch(test_stream_payload(30, b"\r\n")); + + assert_eq!(collect_payload_bytes(&dispatch.local), b"\r\n"); + assert_eq!(collect_payload_bytes(&dispatch.remote), b"\r\n"); + assert!(state.is_empty()); +} + +#[test] +fn non_echo_output_stays_visible_to_both_sides() { + let mut state = TerminalEchoDispatchState::default(); + state.pending.push_back(PendingEchoByte { + byte: b'a', + origin: TerminalInputOrigin::Remote, + }); + + let dispatch = state.dispatch(test_stream_payload(50, b"build\n")); + + assert_eq!(collect_payload_bytes(&dispatch.local), b"build\n"); + assert_eq!(collect_payload_bytes(&dispatch.remote), b"build\n"); + assert!(!state.is_empty()); +} + +#[test] +fn input_echo_candidates_skip_escape_sequences() { + let candidates = + terminal_input_echo_candidates(b"a\x1b[A\x1b[1;5Cb\r", TerminalInputOrigin::Remote); + let bytes = candidates + .iter() + .map(|candidate| candidate.byte) + .collect::>(); + + assert_eq!(bytes, b"ab\r"); +} + +#[test] +fn failed_input_enqueue_does_not_record_pending_echo() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + + let result = registry.input_bytes_from_remote("ssh-1".to_string(), b"secret\r".to_vec()); + + assert!(result.is_err()); + let states = registry + .echo_dispatch + .lock() + .expect("terminal echo dispatch lock"); + assert!(!states.contains_key("ssh-1")); +} + +fn insert_test_ssh_session( + registry: &TerminalSessionRegistry, + id: &str, + project_path_key: &str, + sftp_enabled: bool, + status: &str, +) { + let now = now_ms(); + let record = TerminalSessionRecord { + id: id.to_string(), + project_path_key: normalize_project_path_key(project_path_key), + cwd: project_path_key.to_string(), + shell: "ssh".to_string(), + title: id.to_string(), + kind: "ssh".to_string(), + ssh: Some(TerminalSshMetadata { + host_id: format!("host-{id}"), + host_name: format!("Host {id}"), + username: "tester".to_string(), + host: "127.0.0.1".to_string(), + port: 22, + auth_type: "password".to_string(), + status: status.to_string(), + reconnect_attempt: 0, + reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS, + sftp_enabled, + }), + pid: None, + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + created_at: now, + updated_at: now, + finished_at: None, + exit_code: None, + running: status == SSH_STATUS_CONNECTED, + }; + let entry = Arc::new(TerminalSessionEntry { + backend: TerminalSessionBackend::Ssh { + runtime: Arc::new(SshSessionRuntime::new()), + }, + record: Mutex::new(record), + output: Mutex::new(TerminalOutputBuffer::default()), + }); + registry + .sessions + .lock() + .expect("terminal session registry poisoned") + .insert(id.to_string(), entry); +} + +fn test_stream_payload(start_offset: u64, bytes: &[u8]) -> TerminalStreamEventPayload { + TerminalStreamEventPayload { + kind: "output".to_string(), + session_id: "terminal-1".to_string(), + project_path_key: "/tmp/project".to_string(), + start_offset, + end_offset: start_offset + bytes.len() as u64, + bytes: bytes.to_vec(), + } +} + +fn collect_payload_bytes(payloads: &[TerminalStreamEventPayload]) -> Vec { + payloads + .iter() + .flat_map(|payload| payload.bytes.iter().copied()) + .collect() +} + +#[test] +fn ssh_terminal_tab_open_is_idempotent_without_shared_active() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + + let first = registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect("open bash tab"); + let second = registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect("reopen bash tab"); + let sftp = registry + .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) + .expect("open sftp tab"); + + assert_eq!(first.tabs.len(), 1); + assert_eq!(second.tabs.len(), 1); + assert_eq!(sftp.tabs.len(), 2); + assert_eq!(first.revision, second.revision); +} + +#[test] +fn ssh_terminal_tab_close_is_global_without_closing_session() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + insert_test_ssh_session( + ®istry, + "ssh-2", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect("open first tab"); + registry + .ssh_terminal_tab_open("ssh-2".to_string(), "bash".to_string()) + .expect("open second tab"); + + let snapshot = registry + .ssh_terminal_tab_close("bash:ssh-1".to_string()) + .expect("close first tab"); + + assert_eq!(snapshot.tabs.len(), 1); + assert!(registry.session_record("ssh-1".to_string()).is_ok()); +} + +#[test] +fn ssh_terminal_tab_open_rejects_disabled_sftp() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + false, + SSH_STATUS_CONNECTED, + ); + + let error = registry + .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) + .expect_err("sftp tab should be rejected"); + + assert!(error.contains("SFTP is not enabled")); +} + +#[test] +fn ssh_terminal_tabs_prune_when_session_closes() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect("open bash tab"); + registry + .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) + .expect("open sftp tab"); + + registry + .close("ssh-1".to_string()) + .expect("close ssh session"); + let snapshot = registry + .ssh_terminal_tabs_list("/tmp/project".to_string()) + .expect("list tabs"); + + assert!(snapshot.tabs.is_empty()); +} + +#[test] +fn ssh_terminal_tabs_prune_when_ssh_disconnects() { + let registry = TerminalSessionRegistry::default(); + insert_test_ssh_session( + ®istry, + "ssh-1", + "/tmp/project", + true, + SSH_STATUS_CONNECTED, + ); + registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect("open bash tab"); + registry + .ssh_terminal_tab_open("ssh-1".to_string(), "sftp".to_string()) + .expect("open sftp tab"); + let entry = registry + .sessions + .lock() + .expect("terminal session registry poisoned") + .get("ssh-1") + .cloned() + .expect("ssh session entry"); + + registry.mark_ssh_disconnected(&entry); + + let snapshot = registry + .ssh_terminal_tabs_list("/tmp/project".to_string()) + .expect("list tabs"); + assert!(snapshot.tabs.is_empty()); + let error = registry + .ssh_terminal_tab_open("ssh-1".to_string(), "bash".to_string()) + .expect_err("disconnected ssh tab should be rejected"); + assert!(error.contains("disconnected")); +} + +#[test] +fn ssh_auth_result_detects_keyboard_interactive_continuation() { + let mut methods = russh::MethodSet::empty(); + methods.push(MethodKind::KeyboardInteractive); + assert!(auth_result_can_continue_with_kbi( + &client::AuthResult::Failure { + remaining_methods: methods, + partial_success: false, + } + )); + + let mut password_only = russh::MethodSet::empty(); + password_only.push(MethodKind::Password); + assert!(!auth_result_can_continue_with_kbi( + &client::AuthResult::Failure { + remaining_methods: password_only, + partial_success: false, + }, + )); + assert!(!auth_result_can_continue_with_kbi( + &client::AuthResult::Success + )); +} + +#[test] +fn ssh_password_kbi_prompt_classification_uses_saved_password_once() { + let prompts = vec![client::Prompt { + prompt: "Password:".to_string(), + echo: false, + }]; + assert_eq!( + classify_password_kbi_prompts(&prompts, false), + PasswordKbiPromptAction::SendPassword + ); + assert_eq!( + classify_password_kbi_prompts(&prompts, true), + PasswordKbiPromptAction::PromptUser + ); + assert_eq!( + classify_password_kbi_prompts(&[], false), + PasswordKbiPromptAction::RespondEmpty + ); + assert_eq!( + classify_password_kbi_prompts( + &[client::Prompt { + prompt: "OTP:".to_string(), + echo: false, + }], + false, + ), + PasswordKbiPromptAction::PromptUser + ); +} + +#[test] +fn ssh_keyboard_interactive_message_combines_server_fields() { + let message = ssh_keyboard_interactive_message(&KeyboardInteractivePromptData { + name: "Verification".to_string(), + instructions: "Enter code".to_string(), + prompt: "OTP:".to_string(), + echo: false, + }); + + assert_eq!(message, "Verification\nEnter code\nOTP:"); +} + +#[test] +fn ssh_identity_path_expands_windows_profile_without_posix_rewrites() { + let env = |key: &str| match key { + "USERPROFILE" => Some(r"C:\Users\Alice".to_string()), + "HOMEDRIVE" => Some("C:".to_string()), + "HOMEPATH" => Some(r"\Users\Alice".to_string()), + _ => None, + }; + + assert_eq!( + expand_ssh_identity_path_for_profile_with_env( + r"C:\Users\Alice", + r"~\.ssh\id_ed25519", + SshPathProfile::Windows, + env, + ), + r"C:\Users\Alice\.ssh\id_ed25519" + ); + assert_eq!( + expand_ssh_identity_path_for_profile_with_env( + r"C:\Users\Alice", + r"%USERPROFILE%\.ssh\id_rsa", + SshPathProfile::Windows, + env, + ), + r"C:\Users\Alice\.ssh\id_rsa" + ); + assert_eq!( + expand_ssh_identity_path_for_profile_with_env( + r"C:\Users\Alice", + r"%HOMEDRIVE%%HOMEPATH%\.ssh\id_rsa", + SshPathProfile::Windows, + env, + ), + r"C:\Users\Alice\.ssh\id_rsa" + ); + assert_eq!( + expand_ssh_identity_path_for_profile_with_env( + r"C:\Users\Alice", + r"C:Keys\id_rsa", + SshPathProfile::Windows, + env, + ), + r"C:\Users\Alice\C:Keys\id_rsa" + ); + assert_eq!( + expand_ssh_identity_path_for_profile_with_env( + r"C:\Users\Alice", + r"\\?\C:\Keys\id_rsa", + SshPathProfile::Windows, + env, + ), + r"\\?\C:\Keys\id_rsa" + ); +} + +#[test] +fn ssh_identity_path_preserves_posix_backslash_semantics() { + assert_eq!( + expand_ssh_identity_path_for_profile( + "/Users/alice", + "~/keys/id_ed25519", + SshPathProfile::Posix + ), + "/Users/alice/keys/id_ed25519" + ); + assert_eq!( + expand_ssh_identity_path_for_profile( + "/Users/alice", + "$HOME/.ssh/id_rsa", + SshPathProfile::Posix + ), + "/Users/alice/.ssh/id_rsa" + ); + assert_eq!( + expand_ssh_identity_path_for_profile( + "/Users/alice", + "${HOME}/.ssh/id_rsa", + SshPathProfile::Posix + ), + "/Users/alice/.ssh/id_rsa" + ); + assert_eq!( + expand_ssh_identity_path_for_profile("/Users/alice", r"dir\key", SshPathProfile::Posix), + r"/Users/alice/dir\key" + ); +} + +#[test] +fn ssh_proxy_parser_resolves_http_and_socks5_endpoints() { + let mut host = RuntimeSshHostConfig { + id: "prod".to_string(), + name: "Production".to_string(), + host: "prod.example.com".to_string(), + port: 22, + username: "deploy".to_string(), + auth_type: "agent".to_string(), + password: String::new(), + private_key: String::new(), + private_key_path: String::new(), + private_key_passphrase: String::new(), + proxy: crate::commands::settings::RuntimeSshProxyConfig { + proxy_type: "socks5".to_string(), + url: "socks5://127.0.0.1:1081".to_string(), + port: 0, + username: "proxy-user".to_string(), + password: "proxy-pass".to_string(), + password_configured: true, + }, + }; + + let proxy = resolve_ssh_proxy(&host).expect("resolve socks proxy"); + assert_eq!(proxy.kind, SshProxyKind::Socks5); + assert_eq!(proxy.host, "127.0.0.1"); + assert_eq!(proxy.port, 1081); + assert_eq!(proxy.username, "proxy-user"); + assert_eq!(proxy.password, "proxy-pass"); + + host.proxy.url = "http://proxy.local".to_string(); + host.proxy.port = 8080; + let proxy = resolve_ssh_proxy(&host).expect("resolve http proxy"); + assert_eq!(proxy.kind, SshProxyKind::Http); + assert_eq!(proxy.host, "proxy.local"); + assert_eq!(proxy.port, 8080); +} + +#[test] +fn socks5_address_writer_encodes_domain_and_ip_targets() { + let mut domain = Vec::new(); + write_socks5_address(&mut domain, "prod.example.com").expect("domain target"); + assert_eq!( + domain, + [&[0x03, 16][..], b"prod.example.com".as_slice(),].concat() + ); + + let mut ipv4 = Vec::new(); + write_socks5_address(&mut ipv4, "127.0.0.1").expect("ipv4 target"); + assert_eq!(ipv4, vec![0x01, 127, 0, 0, 1]); + + assert_eq!(host_port_authority("::1", 22), "[::1]:22"); +} + +#[test] +fn terminal_shell_env_scrubs_npm_prefix() { + let mut command = CommandBuilder::new("/bin/sh"); + command.env("npm_config_prefix", "/tmp/npm-prefix"); + command.env("NPM_CONFIG_PREFIX", "/tmp/npm-prefix"); + command.env("TERM", "dumb"); + + configure_terminal_shell_env(&mut command, "/bin/sh"); + + assert!(command.get_env("npm_config_prefix").is_none()); + assert!(command.get_env("NPM_CONFIG_PREFIX").is_none()); + assert_eq!( + command.get_env("TERM").and_then(|value| value.to_str()), + Some("xterm-256color") + ); + assert_eq!( + command + .get_env("COLORTERM") + .and_then(|value| value.to_str()), + Some("truecolor") + ); + assert!(command.get_env("PROMPT_EOL_MARK").is_none()); +} + +#[test] +fn zsh_terminal_shell_disables_prompt_sp() { + assert_eq!( + unix_shell_args("/bin/zsh"), + vec!["-o".to_string(), "NO_PROMPT_SP".to_string()] + ); + assert!(unix_shell_args("/bin/bash").is_empty()); +} + +#[test] +fn registry_creates_lists_renames_and_closes_session() { + let registry = Arc::new(TerminalSessionRegistry::default()); + let tempdir = tempfile::tempdir().expect("tempdir"); + let cwd = tempdir.path().display().to_string(); + + let created = registry + .create( + cwd.clone(), + Some(cwd.clone()), + None, + Some("Test Terminal".to_string()), + Some(80), + Some(24), + ) + .expect("create terminal session"); + assert!(created.session.running); + assert_eq!(created.session.title, "Test Terminal"); + + let listed = registry.list(Some(cwd.clone())).sessions; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, created.session.id); + + let resized = registry + .resize(created.session.id.clone(), 100, 30) + .expect("resize terminal session"); + assert_eq!(resized.cols, 100); + assert_eq!(resized.rows, 30); + + let renamed = registry + .rename(created.session.id.clone(), "Renamed Terminal".to_string()) + .expect("rename terminal session"); + assert_eq!(renamed.title, "Renamed Terminal"); + + let closed = registry + .close(created.session.id.clone()) + .expect("close terminal session"); + assert!(!closed.running); + assert!(registry.list(Some(cwd)).sessions.is_empty()); +} + +#[test] +fn registry_closes_project_sessions() { + let registry = Arc::new(TerminalSessionRegistry::default()); + let project_a = tempfile::tempdir().expect("project a"); + let project_b = tempfile::tempdir().expect("project b"); + let cwd_a = project_a.path().display().to_string(); + let cwd_b = project_b.path().display().to_string(); + + registry + .create( + cwd_a.clone(), + Some(cwd_a.clone()), + None, + Some("A".to_string()), + Some(80), + Some(24), + ) + .expect("create project a terminal"); + registry + .create( + cwd_b.clone(), + Some(cwd_b.clone()), + None, + Some("B".to_string()), + Some(80), + Some(24), + ) + .expect("create project b terminal"); + assert_eq!(registry.running_session_count(), 2); + + let closed = registry + .close_project(cwd_a.clone()) + .expect("close project a terminals"); + assert_eq!(closed.sessions.len(), 1); + assert!(registry.list(Some(cwd_a)).sessions.is_empty()); + assert_eq!(registry.list(Some(cwd_b)).sessions.len(), 1); + + registry.close_all().expect("close remaining terminals"); + assert_eq!(registry.running_session_count(), 0); +} + +#[test] +fn read_tail_requires_terminal_id_when_project_has_multiple_sessions() { + let registry = Arc::new(TerminalSessionRegistry::default()); + let tempdir = tempfile::tempdir().expect("tempdir"); + let cwd = tempdir.path().display().to_string(); + + let first = registry + .create( + cwd.clone(), + Some(cwd.clone()), + None, + Some("First".to_string()), + Some(80), + Some(24), + ) + .expect("create first terminal session"); + registry + .create( + cwd.clone(), + Some(cwd.clone()), + None, + Some("Second".to_string()), + Some(80), + Some(24), + ) + .expect("create second terminal session"); + + let ambiguous = registry + .read_tail(cwd.clone(), None, Some(1024)) + .expect("read ambiguous terminal tail"); + assert_eq!(ambiguous.sessions.len(), 2); + assert!(ambiguous.selected_session.is_none()); + assert!(ambiguous.output.is_empty()); + + let selected = registry + .read_tail(cwd, Some(first.session.id), Some(1024)) + .expect("read selected terminal tail"); + assert!(selected.selected_session.is_some()); + assert_eq!(selected.sessions.len(), 2); + + registry.close_all().expect("close terminal sessions"); +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/types.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/types.rs new file mode 100644 index 00000000..4bea3653 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/types.rs @@ -0,0 +1,210 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSessionRecord { + pub id: String, + pub project_path_key: String, + pub cwd: String, + pub shell: String, + pub title: String, + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh: Option, + pub pid: Option, + pub cols: u16, + pub rows: u16, + pub created_at: u128, + pub updated_at: u128, + pub finished_at: Option, + pub exit_code: Option, + pub running: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSshMetadata { + pub host_id: String, + pub host_name: String, + pub username: String, + pub host: String, + pub port: u16, + pub auth_type: String, + pub status: String, + pub reconnect_attempt: u8, + pub reconnect_max_attempts: u8, + pub sftp_enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSshPrompt { + pub id: String, + pub kind: String, + pub host_id: String, + pub host_name: String, + pub host: String, + pub port: u16, + pub message: String, + pub fingerprint_sha256: String, + pub key_type: String, + pub answer_echo: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalListResponse { + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSnapshotResponse { + pub session: TerminalSessionRecord, + pub output: String, + pub output_bytes: Vec, + pub truncated: bool, + pub output_start_offset: u64, + pub output_end_offset: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSshCreateResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + pub output: String, + pub output_bytes: Vec, + pub truncated: bool, + pub output_start_offset: u64, + pub output_end_offset: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_prompt: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSshLatencyResponse { + pub session_id: String, + pub latency_ms: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshTerminalTabRecord { + pub id: String, + pub session_id: String, + pub project_path_key: String, + pub kind: String, + pub created_at: u128, + pub updated_at: u128, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshTerminalTabsSnapshot { + pub project_path_key: String, + pub tabs: Vec, + pub revision: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalSshExecResponse { + pub session_id: String, + pub command: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_signal: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub timed_out: bool, + pub duration_ms: u128, +} + +#[derive(Debug, Clone)] +pub struct TerminalSshSessionInfo { + pub project_path_key: String, + pub cwd: String, + pub running: bool, + pub host_id: String, + pub sftp_enabled: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalShellOption { + pub id: String, + pub label: String, + pub command: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalShellOptionsResponse { + pub options: Vec, + pub default_shell: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalEventPayload { + pub kind: String, + pub session_id: String, + pub project_path_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_start_offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_end_offset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_tabs: Option, +} + +#[derive(Debug, Clone)] +pub struct TerminalEvent { + pub payload: TerminalEventPayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalStreamEventPayload { + pub kind: String, + pub session_id: String, + pub project_path_key: String, + pub start_offset: u64, + pub end_offset: u64, + pub bytes: Vec, +} + +#[derive(Debug, Clone)] +pub struct TerminalStreamEvent { + pub payload: TerminalStreamEventPayload, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalStreamSnapshotResponse { + pub session: TerminalSessionRecord, + pub bytes: Vec, + pub truncated: bool, + pub output_start_offset: u64, + pub output_end_offset: u64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalReadTailResponse { + pub sessions: Vec, + pub selected_session: Option, + pub output: String, + pub truncated: bool, +} diff --git a/crates/agent-gui/src-tauri/src/runtime/terminal/util.rs b/crates/agent-gui/src-tauri/src/runtime/terminal/util.rs new file mode 100644 index 00000000..57e70d7c --- /dev/null +++ b/crates/agent-gui/src-tauri/src/runtime/terminal/util.rs @@ -0,0 +1,49 @@ +use crate::runtime::project_path::project_path_key as normalize_project_path_key; + +use super::*; + +pub(crate) fn terminal_ssh_create_response_from_snapshot( + snapshot: TerminalSnapshotResponse, +) -> TerminalSshCreateResponse { + TerminalSshCreateResponse { + session: Some(snapshot.session), + output: snapshot.output, + output_bytes: snapshot.output_bytes, + truncated: snapshot.truncated, + output_start_offset: snapshot.output_start_offset, + output_end_offset: snapshot.output_end_offset, + ssh_prompt: None, + } +} + +pub(crate) fn required_project_key(project_path_key: String) -> Result { + let project_key = normalize_project_path_key(&project_path_key); + if project_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + Ok(project_key) +} + +pub(crate) fn normalize_ssh_terminal_tab_kind(kind: &str) -> Result { + match kind.trim().to_ascii_lowercase().as_str() { + "bash" => Ok("bash".to_string()), + "sftp" => Ok("sftp".to_string()), + "" => Err("tab kind is required".to_string()), + other => Err(format!("unsupported ssh terminal tab kind: {other}")), + } +} + +pub(crate) fn ssh_terminal_tab_id(session_id: &str, kind: &str) -> String { + format!("{}:{}", kind.trim(), session_id.trim()) +} + +pub(crate) fn ssh_terminal_tabs_snapshot_from_state( + project_path_key: &str, + state: &SshTerminalTabsState, +) -> SshTerminalTabsSnapshot { + SshTerminalTabsSnapshot { + project_path_key: project_path_key.to_string(), + tabs: state.tabs.clone(), + revision: state.revision, + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway.rs b/crates/agent-gui/src-tauri/src/services/gateway.rs deleted file mode 100644 index 55472b8d..00000000 --- a/crates/agent-gui/src-tauri/src/services/gateway.rs +++ /dev/null @@ -1,5771 +0,0 @@ -use std::collections::HashMap; -use std::future::Future; -use std::sync::{Arc, Mutex, Once}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use reqwest::Url; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use tauri::Emitter; -use tokio::sync::{mpsc, oneshot, watch}; -use tokio_stream::wrappers::ReceiverStream; -use tonic::metadata::MetadataValue; -use tonic::transport::{ClientTlsConfig, Endpoint}; -use uuid::Uuid; - -use crate::commands::chat_history::{self, ChatHistorySummary}; -use crate::commands::settings::{ - apply_ssh_patch_with_conn, load_gateway_settings_sync_snapshot, load_remote_settings, - normalize_remote_settings_payload, open_db, redact_gateway_settings_sync_payload, - reset_runtime_ssh_known_host, RemoteSettingsPayload, PROVIDER_API_KEY_UPDATES_FIELD, - SSH_PATCH_FIELD, SSH_SECRET_UPDATES_FIELD, -}; -use crate::runtime::project_path::{ - project_path_key as normalize_project_path_key, project_path_keys_equal, -}; -use crate::runtime::sftp::{ - SftpActionResponse, SftpEntry, SftpEventPayload, SftpListResponse, SftpSessionRegistry, - SftpStatResponse, SftpTransferResponse, SftpTransferState, -}; -use crate::runtime::terminal::{ - terminal_shell_options, SshTerminalTabRecord, SshTerminalTabsSnapshot, TerminalEventPayload, - TerminalSessionRecord, TerminalSessionRegistry, TerminalShellOption, TerminalSnapshotResponse, - TerminalSshCreateResponse, TerminalStreamEventPayload, TerminalStreamSnapshotResponse, -}; -use crate::services::chat_run_ledger::{ChatRunLedger, ChatRunLedgerEntry, ChatRunLedgerState}; -use crate::services::cron::CronManager; -use crate::services::gateway_bridge; -use crate::services::memory::MemoryStore; -use crate::services::tunnel::{TunnelProxy, TunnelStore}; - -pub mod proto { - tonic::include_proto!("liveagent.gateway.v1"); -} - -const UI_ONLY_SETTINGS_SYNC_FIELDS: &[&str] = &[ - "skills", - "chatRuntimeControls", - "customSettings", - "selectedModel", - "theme", - "locale", -]; -const GATEWAY_GRPC_MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; -const GATEWAY_RECONNECT_DELAY: Duration = Duration::from_secs(5); -const GATEWAY_TERMINAL_STREAM_RECONNECT_MIN: Duration = Duration::from_millis(250); -const GATEWAY_TERMINAL_STREAM_RECONNECT_MAX: Duration = Duration::from_secs(5); -const GATEWAY_TERMINAL_STREAM_STABLE_AFTER: Duration = Duration::from_secs(30); -const GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); -const GATEWAY_CHAT_LEASE_MS: u64 = 15_000; -const GATEWAY_CHAT_RUNNING_LEASE_MS: u64 = 30 * 60_000; -const GATEWAY_CHAT_LEASE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayStatusSnapshot { - pub online: bool, - pub enabled: bool, - pub configured: bool, - pub gateway_url: String, - pub agent_id: String, - pub session_id: Option, - pub connected_since: Option, - pub last_heartbeat: Option, - pub last_error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewaySelectedModelEvent { - pub custom_provider_id: String, - pub model: String, - pub provider_type: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatRuntimeControlsEvent { - pub thinking_enabled: bool, - pub native_web_search_enabled: bool, - pub reasoning: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayUploadedFileEvent { - pub relative_path: String, - pub absolute_path: String, - pub file_name: String, - pub kind: String, - pub size_bytes: i64, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatMessageRefEvent { - pub segment_index: i32, - pub message_index: i32, - pub segment_id: String, - pub message_id: String, - pub role: String, - pub content_hash: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatRequestEvent { - pub request_id: String, - pub conversation_id: String, - pub client_request_id: String, - pub message: String, - pub rebased: bool, - pub base_message_ref: Option, - pub selected_model: Option, - pub runtime_controls: Option, - pub execution_mode: String, - pub workdir: String, - pub selected_system_tools: Vec, - pub uploaded_files: Vec, - pub queue_policy: String, -} - -fn is_complete_user_chat_message_ref(ref_value: &proto::ChatMessageRef) -> bool { - ref_value.segment_index >= 0 - && ref_value.message_index >= 0 - && !ref_value.segment_id.trim().is_empty() - && !ref_value.message_id.trim().is_empty() - && ref_value.role.trim() == "user" - && !ref_value.content_hash.trim().is_empty() -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct GatewayChatCancelEvent { - request_id: String, - conversation_id: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatQueueRequestEvent { - pub request_id: String, - pub action: String, - pub conversation_id: String, - pub item_id: String, - pub direction: String, - pub revision: u64, - pub draft_json: String, - pub uploaded_files_json: String, - pub request_json: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatQueueResponseInput { - pub request_id: String, - #[serde(default)] - pub accepted: bool, - #[serde(default)] - pub message: String, - #[serde(default)] - pub snapshot_json: String, - #[serde(default)] - pub item_json: String, - #[serde(default)] - pub error_code: String, - #[serde(default)] - pub revision: u64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatQueueEventInput { - pub conversation_id: String, - pub snapshot_json: String, - #[serde(default)] - pub revision: u64, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatRuntimeSnapshot { - pub conversation_id: String, - pub run_id: String, - #[serde(default)] - pub client_request_id: Option, - #[serde(default)] - pub worker_id: Option, - pub state: String, - #[serde(default)] - pub cwd: Option, - #[serde(default)] - pub updated_at: i64, - #[serde(default)] - pub revision: i64, - #[serde(default)] - pub entries_json: String, - #[serde(default)] - pub tool_status: Option, - #[serde(default)] - pub tool_status_is_compaction: bool, -} - -#[derive(Debug, Clone)] -struct RemoteChatInboxRecord { - request: GatewayChatRequestEvent, - state: String, - lease_owner: Option, - lease_expires_at: Option, - attempt: u32, - started: bool, - last_error: Option, - created_at: Instant, - updated_at: Instant, -} - -#[derive(Debug, Clone)] -struct RemoteChatEnqueueOutcome { - request_id: String, - conversation_id: String, - control_type: &'static str, - should_wake_runtime: bool, - inserted: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayChatClaimedRequest { - pub request_id: String, - pub client_request_id: String, - pub conversation_id: String, - pub state: String, - pub attempt: u32, - pub lease_ms: u64, - pub request: GatewayChatRequestEvent, -} - -pub const CHAT_HISTORY_SYNC_EVENT: &str = "chat-history:changed"; -pub const GATEWAY_SETTINGS_SYNC_EVENT: &str = "gateway:settings-sync"; - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayHistorySyncConversation { - pub id: String, - pub title: String, - pub provider_id: Option, - pub model: Option, - pub session_id: Option, - pub cwd: Option, - pub created_at: i64, - pub updated_at: i64, - pub message_count: i64, - pub is_pinned: bool, - pub pinned_at: Option, - pub is_shared: bool, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct GatewayHistorySyncEvent { - pub kind: String, - pub conversation_id: String, - pub conversation: Option, -} - -pub struct GatewayController { - app_handle: tauri::AppHandle, - cron_manager: Arc, - memory_store: Arc, - terminal_registry: Arc, - sftp_registry: Arc, - config_tx: watch::Sender, - runner_task: Mutex>>, - status: Mutex, - outbound_tx: Mutex>>, - terminal_stream_tx: Mutex>>, - settings_snapshot: Mutex>, - remote_chat_inbox: Mutex>, - chat_run_ledger: Mutex, - pub(crate) tunnel_store: TunnelStore, - pub(crate) tunnel_proxy: TunnelProxy, - pending_chat_queue_requests: Mutex>>, - terminal_forwarder_once: Once, - terminal_stream_forwarder_once: Once, - sftp_forwarder_once: Once, - remote_chat_inbox_sweeper_once: Once, - pub(crate) tunnel_store_once: Once, -} - -impl GatewayController { - pub fn new( - app_handle: tauri::AppHandle, - cron_manager: Arc, - memory_store: Arc, - terminal_registry: Arc, - sftp_registry: Arc, - ) -> Self { - let initial_config = RemoteSettingsPayload::default(); - let (config_tx, _) = watch::channel(initial_config); - let tunnel_store = TunnelStore::new(app_handle.clone()); - Self { - app_handle, - cron_manager, - memory_store, - terminal_registry, - sftp_registry, - config_tx, - runner_task: Mutex::new(None), - status: Mutex::new(GatewayStatusSnapshot { - online: false, - enabled: false, - configured: false, - gateway_url: String::new(), - agent_id: fallback_agent_id(), - session_id: None, - connected_since: None, - last_heartbeat: None, - last_error: None, - }), - outbound_tx: Mutex::new(None), - terminal_stream_tx: Mutex::new(None), - settings_snapshot: Mutex::new(None), - remote_chat_inbox: Mutex::new(HashMap::new()), - chat_run_ledger: Mutex::new(ChatRunLedger::new()), - tunnel_store, - tunnel_proxy: TunnelProxy::new(), - pending_chat_queue_requests: Mutex::new(HashMap::new()), - terminal_forwarder_once: Once::new(), - terminal_stream_forwarder_once: Once::new(), - sftp_forwarder_once: Once::new(), - remote_chat_inbox_sweeper_once: Once::new(), - tunnel_store_once: Once::new(), - } - } - - pub fn start(self: &Arc) -> Result<(), String> { - self.start_terminal_forwarder(); - self.start_terminal_stream_forwarder(); - self.start_sftp_forwarder(); - self.start_remote_chat_inbox_sweeper(); - self.start_tunnel_store(); - self.ensure_runner() - } - - fn start_terminal_forwarder(self: &Arc) { - let controller = Arc::clone(self); - self.terminal_forwarder_once.call_once(move || { - let (receiver, guard) = controller.terminal_registry.subscribe(); - thread::spawn(move || { - let _guard = guard; - while let Ok(event) = receiver.recv() { - let envelope = build_terminal_event_envelope(event.payload); - let Ok(sender) = controller.current_outbound_sender() else { - continue; - }; - if let Err(error) = sender.blocking_send(envelope) { - eprintln!("send gateway terminal event failed: {error}"); - } - } - }); - }); - } - - fn start_terminal_stream_forwarder(self: &Arc) { - let controller = Arc::clone(self); - self.terminal_stream_forwarder_once.call_once(move || { - let (receiver, guard) = controller.terminal_registry.subscribe_stream(); - thread::spawn(move || { - let _guard = guard; - while let Ok(event) = receiver.recv() { - let frame = build_terminal_stream_output_frame(event.payload); - let Ok(sender) = controller.current_terminal_stream_sender() else { - continue; - }; - if let Err(error) = sender.blocking_send(frame) { - eprintln!("send gateway terminal stream frame failed: {error}"); - } - } - }); - }); - } - - fn start_sftp_forwarder(self: &Arc) { - let controller = Arc::clone(self); - self.sftp_forwarder_once.call_once(move || { - let (receiver, guard) = controller.sftp_registry.subscribe(); - thread::spawn(move || { - let _guard = guard; - while let Ok(event) = receiver.recv() { - let envelope = build_sftp_event_envelope(event.payload); - let Ok(sender) = controller.current_outbound_sender() else { - continue; - }; - if let Err(error) = sender.blocking_send(envelope) { - eprintln!("send gateway SFTP event failed: {error}"); - } - } - }); - }); - } - - fn start_remote_chat_inbox_sweeper(self: &Arc) { - let controller = Arc::clone(self); - self.remote_chat_inbox_sweeper_once.call_once(move || { - tauri::async_runtime::spawn(async move { - loop { - tokio::time::sleep(GATEWAY_CHAT_LEASE_SWEEP_INTERVAL).await; - if let Err(error) = controller.expire_remote_chat_leases().await { - eprintln!("expire gateway remote chat leases failed: {error}"); - } - if let Err(error) = controller.flush_unsent_chat_run_terminals().await { - eprintln!("flush gateway chat run terminals failed: {error}"); - } - } - }); - }); - } - - fn spawn_runner( - self: &Arc, - runner_task: &mut Option>, - ) { - let receiver = self.config_tx.subscribe(); - let controller = Arc::clone(self); - *runner_task = Some(tauri::async_runtime::spawn(async move { - controller.run(receiver).await; - })); - } - - fn ensure_runner(self: &Arc) -> Result<(), String> { - let mut runner_task = self - .runner_task - .lock() - .map_err(|_| "gateway runner task lock poisoned".to_string())?; - let should_spawn = runner_task - .as_ref() - .map(|task| task.inner().is_finished()) - .unwrap_or(true); - if !should_spawn { - return Ok(()); - } - - self.spawn_runner(&mut runner_task); - Ok(()) - } - - fn restart_runner(self: &Arc) -> Result<(), String> { - self.set_outbound_sender(None); - self.set_terminal_stream_sender(None); - let mut runner_task = self - .runner_task - .lock() - .map_err(|_| "gateway runner task lock poisoned".to_string())?; - if let Some(task) = runner_task.take() { - task.abort(); - } - self.spawn_runner(&mut runner_task); - Ok(()) - } - - pub async fn reload_from_db(self: &Arc) -> Result<(), String> { - let config = tauri::async_runtime::spawn_blocking(move || { - let conn = open_db()?; - load_remote_settings(&conn) - }) - .await - .map_err(|e| format!("reload remote settings join failed: {e}"))??; - self.apply_config(config) - } - - pub fn apply_config(self: &Arc, config: RemoteSettingsPayload) -> Result<(), String> { - let normalized = normalize_remote_settings_payload(config); - let previous = self.config_tx.borrow().clone(); - let config_changed = previous != normalized; - let should_run_remote = normalized.enabled && is_remote_configured(&normalized); - self.config_tx.send_replace(normalized.clone()); - self.publish_status(|status| { - status.enabled = normalized.enabled; - status.configured = is_remote_configured(&normalized); - status.gateway_url = normalized.gateway_url.clone(); - status.agent_id = effective_agent_id(&normalized); - if !normalized.enabled { - set_disconnected_status(status, &normalized, None); - } else if config_changed { - set_disconnected_status(status, &normalized, None); - } - }); - if should_run_remote { - self.restart_runner()?; - } else { - self.ensure_runner()?; - } - Ok(()) - } - - pub fn disconnect_runtime(self: &Arc) -> Result<(), String> { - let mut config = self.config_tx.borrow().clone(); - config.enabled = false; - self.apply_config(config) - } - - pub fn status(&self) -> GatewayStatusSnapshot { - self.status - .lock() - .map(|status| status.clone()) - .unwrap_or(GatewayStatusSnapshot { - online: false, - enabled: false, - configured: false, - gateway_url: String::new(), - agent_id: fallback_agent_id(), - session_id: None, - connected_since: None, - last_heartbeat: None, - last_error: Some("gateway status lock poisoned".to_string()), - }) - } - - pub async fn send_chat_event( - &self, - request_id: String, - event: Value, - worker_id: Option, - ) -> Result<(), String> { - // Terminal events must bypass the lease-freshness check: an expired but - // still-owned lease may no longer be "current", yet dropping the run's - // done/error signal here would leave the WebUI streaming forever. - let is_terminal = chat_event_is_terminal(&event); - if !self.renew_remote_chat_request_lease(&request_id, worker_id.as_deref(), !is_terminal)? { - return Ok(()); - } - let conversation_id = chat_event_conversation_id(&event); - if is_terminal { - let state = if chat_event_type(&event) == Some("done") { - ChatRunLedgerState::Completed - } else { - ChatRunLedgerState::Failed - }; - // Carry the error text into the ledger so a retransmitted terminal - // control event still surfaces it after the original send failed. - let message = event - .get("message") - .and_then(Value::as_str) - .unwrap_or("") - .trim(); - // Record the terminal before attempting the send so a failed send - // is retransmitted by the ledger flush loop. - self.ledger_mark_run_terminal(&request_id, &conversation_id, state, "", message)?; - } else { - self.ledger_touch_run(&request_id, &conversation_id)?; - } - let envelope = build_chat_event_envelope(request_id.clone(), event)?; - let result = self.send_agent_envelope(envelope).await; - if is_terminal && result.is_ok() { - self.ledger_mark_run_terminal_sent(&request_id)?; - } - result - } - - pub async fn publish_history_sync(&self, event: GatewayHistorySyncEvent) { - if let Err(error) = self.app_handle.emit(CHAT_HISTORY_SYNC_EVENT, event.clone()) { - eprintln!("emit chat history sync failed: {error}"); - } - - if !self.status().online { - return; - } - - let envelope = match build_history_sync_envelope(event) { - Ok(envelope) => envelope, - Err(error) => { - eprintln!("build gateway history sync envelope failed: {error}"); - return; - } - }; - - if let Err(error) = self.send_agent_envelope(envelope).await { - eprintln!("send gateway history sync event failed: {error}"); - } - } - - pub async fn publish_chat_runtime_snapshot( - &self, - snapshot: GatewayChatRuntimeSnapshot, - ) -> Result<(), String> { - let run_id = snapshot.run_id.trim().to_string(); - let conversation_id = snapshot.conversation_id.trim().to_string(); - let terminal_state = match snapshot.state.trim() { - "completed" => Some(ChatRunLedgerState::Completed), - "failed" => Some(ChatRunLedgerState::Failed), - "cancelled" => Some(ChatRunLedgerState::Cancelled), - _ => None, - }; - if !run_id.is_empty() { - match terminal_state { - Some(state) => { - self.ledger_mark_run_terminal(&run_id, &conversation_id, state, "", "")?; - } - None if snapshot.state.trim() == "running" => { - self.ledger_touch_run(&run_id, &conversation_id)?; - } - None => {} - } - } - let envelope = build_chat_runtime_snapshot_envelope(snapshot)?; - let result = self.send_agent_envelope(envelope).await; - if terminal_state.is_some() && !run_id.is_empty() && result.is_ok() { - self.ledger_mark_run_terminal_sent(&run_id)?; - } - result - } - - pub async fn publish_settings_sync(&self, payload: Value) -> Result<(), String> { - let snapshot = self.store_settings_snapshot(payload)?; - - if !self.status().online { - return Ok(()); - } - - let envelope = build_settings_sync_envelope(snapshot)?; - self.send_agent_envelope(envelope).await - } - - async fn run(self: Arc, mut config_rx: watch::Receiver) { - loop { - let config = config_rx.borrow().clone(); - if !config.enabled || !is_remote_configured(&config) { - self.set_outbound_sender(None); - self.set_terminal_stream_sender(None); - self.publish_status(|status| { - set_disconnected_status(status, &config, None); - }); - if config_rx.changed().await.is_err() { - break; - } - continue; - } - - let current_config = config.clone(); - let connect_result = self - .connect_and_serve(current_config.clone(), &mut config_rx) - .await; - let latest_config = config_rx.borrow().clone(); - let reconfigured = latest_config != current_config; - - self.set_outbound_sender(None); - self.set_terminal_stream_sender(None); - if reconfigured { - self.publish_status(|status| { - set_disconnected_status(status, &latest_config, None); - }); - continue; - } - - self.publish_status(|status| match connect_result.as_ref() { - Ok(()) => set_disconnected_status(status, ¤t_config, None), - Err(error) => set_disconnected_status(status, ¤t_config, Some(error.clone())), - }); - - if config_rx.has_changed().unwrap_or(false) { - continue; - } - - if !current_config.auto_reconnect { - if config_rx.changed().await.is_err() { - break; - } - continue; - } - - tokio::select! { - changed = config_rx.changed() => { - if changed.is_err() { - break; - } - } - _ = tokio::time::sleep(GATEWAY_RECONNECT_DELAY) => {} - } - } - } - - async fn connect_and_serve( - self: &Arc, - config: RemoteSettingsPayload, - config_rx: &mut watch::Receiver, - ) -> Result<(), String> { - let grpc_url = build_grpc_url(&config)?; - // The heartbeat interval setting drives the h2 keepalive cadence; the - // lower bound stays above the gateway's keepalive enforcement MinTime. - let keepalive_interval = Duration::from_secs(config.heartbeat_interval.clamp(10, 60)); - let endpoint = build_endpoint(&grpc_url, keepalive_interval)?; - let channel = endpoint.connect_lazy(); - - let mut client = proto::agent_gateway_client::AgentGatewayClient::new(channel) - .max_decoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES) - .max_encoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES); - let mut auth_request = tonic::Request::new(proto::AuthRequest { - token: config.token.clone(), - agent_id: effective_agent_id(&config), - agent_version: crate::app_version().to_string(), - }); - insert_bearer_metadata(auth_request.metadata_mut(), &config.token)?; - - let auth_call = client.authenticate(auth_request); - let auth_response = match await_abortable_on_reconfigure(&config, config_rx, async move { - tokio::time::timeout(Duration::from_secs(10), auth_call) - .await - .map_err(|_| "gateway authenticate timed out".to_string())? - .map_err(|e| format!("gateway authenticate failed: {e}")) - .map(|response| response.into_inner()) - }) - .await? - { - Some(response) => response, - None => return Ok(()), - }; - if !auth_response.success { - return Err(if auth_response.message.trim().is_empty() { - "gateway authentication failed".to_string() - } else { - auth_response.message - }); - } - - let terminal_client = client.clone(); - - let (outbound_tx, outbound_rx) = mpsc::channel::(4096); - self.set_outbound_sender(Some(outbound_tx)); - let (terminal_stop_tx, terminal_stop_rx) = watch::channel(false); - let terminal_task = - self.spawn_terminal_stream(terminal_client, config.clone(), terminal_stop_rx); - - let serve_result = async { - let mut connect_request = tonic::Request::new(ReceiverStream::new(outbound_rx)); - insert_bearer_metadata(connect_request.metadata_mut(), &config.token)?; - - let connect_call = client.agent_connect(connect_request); - let response = match await_abortable_on_reconfigure(&config, config_rx, async move { - tokio::time::timeout(Duration::from_secs(10), connect_call) - .await - .map_err(|_| "open gateway stream timed out".to_string())? - .map_err(|e| format!("open gateway stream failed: {e}")) - }) - .await? - { - Some(response) => response, - None => return Ok(()), - }; - let mut inbound = response.into_inner(); - - let connected_at = now_unix_seconds(); - self.publish_status(|status| { - status.online = true; - status.enabled = true; - status.configured = true; - status.gateway_url = config.gateway_url.clone(); - status.agent_id = effective_agent_id(&config); - status.session_id = Some(auth_response.session_id.clone()); - status.connected_since = Some(connected_at); - status.last_heartbeat = Some(connected_at); - status.last_error = None; - }); - - if let Err(error) = self.publish_current_settings_sync().await { - eprintln!("publish gateway settings sync failed: {error}"); - } - if let Err(error) = self.publish_current_terminal_sessions().await { - eprintln!("publish gateway terminal sessions failed: {error}"); - } - if let Err(error) = self.publish_desired_tunnels().await { - eprintln!("publish gateway tunnel desired state failed: {error}"); - } - if let Err(error) = self.republish_chat_run_states().await { - eprintln!("republish gateway chat run states failed: {error}"); - } - self.spawn_tunnel_probes(None, false); - - // Dead links are detected by the transport-level HTTP/2 keepalive - // configured on the endpoint and surface as receive errors here. - loop { - tokio::select! { - changed = config_rx.changed() => { - if changed.is_err() { - return Ok(()); - } - let next = config_rx.borrow().clone(); - if next != config { - return Ok(()); - } - } - message = inbound.message() => { - match message { - Err(err) => return Err(format!("gateway stream receive failed: {err}")), - Ok(None) => return Err("gateway stream closed".to_string()), - Ok(Some(envelope)) => { - self.touch_heartbeat(); - self.handle_gateway_envelope(envelope).await?; - } - } - } - } - } - } - .await; - - let _ = terminal_stop_tx.send(true); - terminal_task.abort(); - self.set_terminal_stream_sender(None); - serve_result - } - - fn spawn_terminal_stream( - self: &Arc, - client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - stop_rx: watch::Receiver, - ) -> tauri::async_runtime::JoinHandle<()> { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - controller - .run_terminal_stream(client, config, stop_rx) - .await; - }) - } - - async fn run_terminal_stream( - self: Arc, - client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - mut stop_rx: watch::Receiver, - ) { - let mut reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; - - loop { - if *stop_rx.borrow() { - break; - } - - let attempt_started = Instant::now(); - let result = Arc::clone(&self) - .run_terminal_stream_once(client.clone(), config.clone(), stop_rx.clone()) - .await; - if *stop_rx.borrow() { - break; - } - self.set_terminal_stream_sender(None); - - if attempt_started.elapsed() >= GATEWAY_TERMINAL_STREAM_STABLE_AFTER { - reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; - } - match result { - Ok(()) => eprintln!("gateway terminal stream closed; reconnecting"), - Err(error) => eprintln!("gateway terminal stream stopped: {error}; reconnecting"), - } - - let delay = reconnect_delay; - reconnect_delay = - std::cmp::min(reconnect_delay * 2, GATEWAY_TERMINAL_STREAM_RECONNECT_MAX); - tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - break; - } - } - _ = tokio::time::sleep(delay) => {} - } - } - - self.set_terminal_stream_sender(None); - } - - async fn run_terminal_stream_once( - self: Arc, - mut client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - mut stop_rx: watch::Receiver, - ) -> Result<(), String> { - let (terminal_tx, terminal_rx) = mpsc::channel::(4096); - - let result = async { - queue_terminal_stream_handshake_frame(&terminal_tx)?; - let mut request = tonic::Request::new(ReceiverStream::new(terminal_rx)); - insert_bearer_metadata(request.metadata_mut(), &config.token)?; - let response = tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - return Ok(()); - } - return Ok(()); - } - response = client.agent_terminal_connect(request) => { - response.map_err(|error| { - format_gateway_terminal_stream_rpc_error("open", &error, &config) - })? - } - }; - self.set_terminal_stream_sender(Some(terminal_tx.clone())); - let mut inbound = response.into_inner(); - let mut keepalive = tokio::time::interval(GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - keepalive.tick().await; - loop { - tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - return Ok(()); - } - } - _ = keepalive.tick() => { - queue_terminal_stream_keepalive_frame(&terminal_tx).await?; - } - message = inbound.message() => { - match message { - Ok(Some(frame)) => { - if let Err(error) = self.handle_terminal_stream_frame(frame).await { - eprintln!("handle gateway terminal stream frame failed: {error}"); - } - } - Ok(None) => return Ok(()), - Err(error) => { - return Err(format_gateway_terminal_stream_rpc_error("receive", &error, &config)) - } - } - } - } - } - } - .await; - - self.clear_terminal_stream_sender_if_current(&terminal_tx); - result - } - - async fn handle_terminal_stream_frame( - &self, - frame: proto::TerminalStreamFrame, - ) -> Result<(), String> { - let kind = frame.kind.trim().to_ascii_lowercase(); - let stream_id = frame.stream_id.clone(); - let session_id = frame.session_id.clone(); - let project_path_key = frame.project_path_key.clone(); - let result = match kind.as_str() { - "attach" => { - self.ensure_terminal_stream_allowed(&frame)?; - let snapshot = self.terminal_registry.stream_attach( - frame.session_id.clone(), - optional_proto_usize(frame.max_bytes), - )?; - self.send_terminal_stream_frame(terminal_stream_snapshot_to_proto( - stream_id.clone(), - snapshot, - )) - .await - } - "input" => { - self.ensure_terminal_stream_allowed(&frame)?; - self.terminal_registry - .input_bytes_from_remote(frame.session_id.clone(), frame.data.clone())?; - Ok(()) - } - "resize" => { - self.ensure_terminal_stream_allowed(&frame)?; - self.terminal_registry.stream_resize( - frame.session_id.clone(), - optional_proto_u16(frame.cols).unwrap_or(80), - optional_proto_u16(frame.rows).unwrap_or(24), - )?; - Ok(()) - } - "detach" => Ok(()), - "" => Err("terminal stream frame kind is required".to_string()), - other => Err(format!("unsupported terminal stream frame: {other}")), - }; - - if let Err(error) = result { - let _ = self - .send_terminal_stream_frame(terminal_stream_error_frame( - stream_id, - session_id, - project_path_key, - error.clone(), - )) - .await; - return Err(error); - } - Ok(()) - } - - async fn send_terminal_stream_frame( - &self, - frame: proto::TerminalStreamFrame, - ) -> Result<(), String> { - let sender = self.current_terminal_stream_sender()?; - sender - .send(frame) - .await - .map_err(|error| format!("send terminal stream frame failed: {error}")) - } - - async fn handle_gateway_envelope( - self: &Arc, - envelope: proto::GatewayEnvelope, - ) -> Result<(), String> { - let request_id = envelope.request_id.clone(); - - match envelope.payload { - Some(proto::gateway_envelope::Payload::Ping(ping)) => { - // Never block the receive loop on outbound saturation: the - // gateway counts any inbound envelope as liveness, so a pong - // dropped under load is harmless. - if let Ok(sender) = self.current_outbound_sender() { - let _ = sender.try_send(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::Pong(proto::PongResponse { - timestamp: ping.timestamp, - })), - }); - } - Ok(()) - } - Some(proto::gateway_envelope::Payload::TunnelState(snapshot)) => { - self.handle_tunnel_state_snapshot(snapshot); - Ok(()) - } - Some(proto::gateway_envelope::Payload::TunnelMutation(mutation)) => { - self.handle_tunnel_mutation_request(request_id, mutation); - Ok(()) - } - Some(proto::gateway_envelope::Payload::TunnelFrame(frame)) => { - self.tunnel_proxy.handle_frame(self, frame) - } - Some(proto::gateway_envelope::Payload::ChatCommand(command)) => { - self.handle_chat_command(request_id, command).await - } - Some(proto::gateway_envelope::Payload::ChatQueue(request)) => { - self.handle_chat_queue_request(request_id, request).await - } - Some(proto::gateway_envelope::Payload::CronManage(request)) => { - let should_refresh_settings = - matches!(request.action.trim(), "create" | "update" | "delete"); - match gateway_bridge::handle_cron_manage(Arc::clone(&self.cron_manager), request) - .await - { - Ok(response) => { - let send_result = self - .send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::CronManageResp( - response, - )), - }) - .await; - if send_result.is_ok() && should_refresh_settings { - if let Err(error) = self.refresh_settings_sync_from_db().await { - eprintln!( - "refresh gateway settings sync after cron manage failed: {error}" - ); - } - } - send_result - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::HistoryList(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_list(request).await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryListResp( - response, - )), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.list handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryWorkdirs(_request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_workdirs().await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryWorkdirsResp( - response, - ), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.workdirs handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryGet(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_get(request).await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryGetResp( - response, - )), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.get handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryPrefix(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_prefix(request).await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryPrefixResp(response), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.prefix handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryRename(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_rename(request).await { - Ok(response) => { - if let Some(conversation) = response.conversation.as_ref() { - controller - .publish_history_sync(build_history_sync_upsert_from_proto( - conversation, - )) - .await; - } - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryRenameResp(response), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.rename handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryPin(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_pin(request).await { - Ok(response) => { - if let Some(conversation) = response.conversation.as_ref() { - controller - .publish_history_sync(build_history_sync_upsert_from_proto( - conversation, - )) - .await; - } - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistoryPinResp( - response, - )), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.pin handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryShareGet(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_share_get(request).await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryShareGetResp( - response, - ), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.share.get handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryShareSet(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_share_set(request).await { - Ok(response) => { - if let Some(share) = response.share.as_ref() { - match chat_history::chat_history_get_summary_inner( - share.conversation_id.clone(), - ) - .await - { - Ok(summary) => { - controller - .publish_history_sync(build_history_sync_upsert( - &summary, - )) - .await; - } - Err(error) => { - eprintln!( - "publish history share sync event failed: {error}" - ) - } - } - } - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryShareSetResp( - response, - ), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.share.set handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryShareResolve(request)) => { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_share_resolve(request).await { - Ok(response) => { - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryShareResolveResp( - response, - ), - ), - }) - .await - } - Err(error) => { - let code = history_share_resolve_error_code(&error); - controller - .send_error_response(request_id.clone(), code, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.share.resolve handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::HistoryDelete(request)) => { - let deleted_conversation_id = request.conversation_id.trim().to_string(); - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - let result = match gateway_bridge::handle_history_delete(request).await { - Ok(response) => { - controller - .publish_history_sync(build_history_sync_delete( - deleted_conversation_id, - )) - .await; - controller - .send_agent_envelope(proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::HistoryDeleteResp(response), - ), - }) - .await - } - Err(error) => { - controller - .send_error_response(request_id.clone(), 500, error) - .await - } - }; - if let Err(err) = result { - eprintln!("gateway history.delete handler failed: {err}"); - } - }); - Ok(()) - } - Some(proto::gateway_envelope::Payload::ProviderList(_request)) => { - match gateway_bridge::handle_provider_list().await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ProviderListResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SettingsGet(_request)) => { - match self.current_settings_snapshot().await { - Ok(snapshot) => { - let settings_json = match serialize_settings_sync_payload(&snapshot) { - Ok(settings_json) => settings_json, - Err(error) => { - return self.send_error_response(request_id, 500, error).await; - } - }; - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SettingsGetResp( - proto::SettingsGetResponse { settings_json }, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SettingsUpdate(request)) => { - match parse_settings_sync_payload(&request.settings_json) { - Ok(snapshot) => { - if snapshot.get(SSH_PATCH_FIELD).is_some() { - let patch_payload = snapshot.clone(); - let apply_response = - match tauri::async_runtime::spawn_blocking(move || { - let mut conn = open_db()?; - apply_ssh_patch_with_conn(&mut conn, patch_payload) - }) - .await - .map_err(|e| format!("settings ssh patch join failed: {e}")) - { - Ok(Ok(response)) => response, - Ok(Err(error)) | Err(error) => { - return self - .send_error_response(request_id, 500, error) - .await; - } - }; - if let Some(conflict) = apply_response.conflict { - return self - .send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::SettingsUpdateResp( - proto::SettingsUpdateResponse { - accepted: false, - message: conflict, - }, - ), - ), - }) - .await; - } - - let fresh_snapshot = match self.current_settings_snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - return self.send_error_response(request_id, 500, error).await; - } - }; - let merged_ssh = - fresh_snapshot.get("ssh").cloned().unwrap_or(Value::Null); - let event_payload = - match build_local_settings_update_event_payload_with_ssh( - snapshot.clone(), - merged_ssh, - ) { - Ok(payload) => payload, - Err(error) => { - return self - .send_error_response(request_id, 400, error) - .await; - } - }; - if let Err(error) = self - .app_handle - .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) - { - return self - .send_error_response( - request_id, - 500, - format!("emit gateway settings sync failed: {error}"), - ) - .await; - } - if let Err(error) = self.publish_settings_sync(fresh_snapshot).await { - eprintln!("publish gateway ssh settings sync failed: {error}"); - } - return self - .send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::SettingsUpdateResp( - proto::SettingsUpdateResponse { - accepted: true, - message: "ok".to_string(), - }, - ), - ), - }) - .await; - } - - let event_payload = - match build_local_settings_update_event_payload(snapshot.clone()) { - Ok(payload) => payload, - Err(error) => { - return self.send_error_response(request_id, 400, error).await; - } - }; - let public_update = match redact_gateway_settings_sync_payload(snapshot) { - Ok(payload) => payload, - Err(error) => { - return self.send_error_response(request_id, 400, error).await; - } - }; - // The update is a partial payload (only changed fields, e.g. - // {"theme":"dark"}). Overlay it onto the current full snapshot; - // storing it as-is would drop every other cached field and let - // rebuilt snapshots revert UI-only settings like theme. - let current_snapshot = match self.current_settings_snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - return self.send_error_response(request_id, 500, error).await; - } - }; - let merged_snapshot = match merge_settings_update_into_snapshot( - current_snapshot, - public_update, - ) { - Ok(payload) => payload, - Err(error) => { - return self.send_error_response(request_id, 400, error).await; - } - }; - if let Err(error) = self.store_settings_snapshot(merged_snapshot) { - return self.send_error_response(request_id, 500, error).await; - } - match self - .app_handle - .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) - { - Ok(()) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::SettingsUpdateResp( - proto::SettingsUpdateResponse { - accepted: true, - message: "ok".to_string(), - }, - ), - ), - }) - .await - } - Err(error) => { - self.send_error_response( - request_id, - 500, - format!("emit gateway settings sync failed: {error}"), - ) - .await - } - } - } - Err(error) => self.send_error_response(request_id, 400, error).await, - } - } - Some(proto::gateway_envelope::Payload::SettingsResetSshKnownHost(request)) => { - let host = request.host.trim().to_string(); - let port = match u16::try_from(request.port) { - Ok(port) if port > 0 => port, - _ => { - return self - .send_error_response( - request_id, - 400, - "SSH port must be between 1 and 65535".to_string(), - ) - .await; - } - }; - match reset_runtime_ssh_known_host(&host, port) { - Ok(deleted) => { - let deleted = u32::try_from(deleted).unwrap_or(u32::MAX); - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::SettingsResetSshKnownHostResp( - proto::SettingsResetSshKnownHostResponse { deleted }, - ), - ), - }) - .await - } - Err(error) => self.send_error_response(request_id, 400, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsRoots(_request)) => { - match gateway_bridge::handle_fs_roots().await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsRootsResp(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsListDirs(request)) => { - match gateway_bridge::handle_fs_list_dirs(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsListDirsResp(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsCreateProjectFolder(request)) => { - match gateway_bridge::handle_fs_create_project_folder(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::FsCreateProjectFolderResp(response), - ), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsList(request)) => { - match gateway_bridge::handle_fs_list(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsListResp(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsReadEditableText(request)) => { - match gateway_bridge::handle_fs_read_editable_text(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsReadEditableTextResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsReadWorkspaceImage(request)) => { - match gateway_bridge::handle_fs_read_workspace_image(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some( - proto::agent_envelope::Payload::FsReadWorkspaceImageResp(response), - ), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsWriteText(request)) => { - match gateway_bridge::handle_fs_write_text(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsWriteTextResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsCreateDir(request)) => { - match gateway_bridge::handle_fs_create_dir(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsCreateDirResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsRename(request)) => { - match gateway_bridge::handle_fs_rename(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsRenameResp(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FsDelete(request)) => { - match gateway_bridge::handle_fs_delete(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FsDeleteResp(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SkillFilesList(_request)) => { - match gateway_bridge::handle_skill_files_list().await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SkillFilesListResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::FileMentionList(request)) => { - match gateway_bridge::handle_file_mention_list(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::FileMentionListResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::UploadReadableFiles(request)) => { - match gateway_bridge::handle_upload_readable_files(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::UploadReadableFilesResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::UploadedImagePreview(request)) => { - self.spawn_uploaded_image_preview_response(request_id, request) - } - Some(proto::gateway_envelope::Payload::MemoryManage(request)) => { - match gateway_bridge::handle_memory_manage(Arc::clone(&self.memory_store), request) - .await - { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::MemoryManageResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SkillMetadataRead(request)) => { - match gateway_bridge::handle_skill_metadata_read(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SkillMetadataReadResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SkillTextRead(request)) => { - match gateway_bridge::handle_skill_text_read(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SkillTextReadResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SkillManage(request)) => { - match gateway_bridge::handle_skill_manage(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SkillManageResp( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::GitRequest(request)) => { - match gateway_bridge::handle_git_request(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::GitResponse(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::TerminalRequest(request)) => { - match self.handle_terminal_request(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TerminalResponse( - response, - )), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - Some(proto::gateway_envelope::Payload::SftpRequest(request)) => { - match self.handle_sftp_request(request).await { - Ok(response) => { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SftpResponse(response)), - }) - .await - } - Err(error) => self.send_error_response(request_id, 500, error).await, - } - } - None => Ok(()), - } - } - - async fn handle_sftp_request( - &self, - request: proto::SftpRequest, - ) -> Result { - if !self.config_tx.borrow().enable_web_ssh_terminal { - return Err("web SSH SFTP is disabled in desktop Remote settings".to_string()); - } - let action = request.action.trim().to_ascii_lowercase(); - match action.as_str() { - "list" => { - let side = if request.direction.trim().is_empty() { - "remote".to_string() - } else { - request.direction - }; - let path = sftp_side_path(&side, &request.local_path, &request.remote_path); - let response = self - .sftp_registry - .list( - request.session_id, - Some(request.project_path_key), - request.workdir, - side, - Some(path), - ) - .await?; - Ok(sftp_list_response_to_proto(action, response)) - } - "stat" | "probe" => { - let side = if request.direction.trim().is_empty() { - "remote".to_string() - } else { - request.direction - }; - let path = sftp_side_path(&side, &request.local_path, &request.remote_path); - let response = self - .sftp_registry - .stat( - request.session_id, - Some(request.project_path_key), - request.workdir, - side, - Some(path), - ) - .await?; - Ok(sftp_stat_response_to_proto(action, response)) - } - "mkdir" => { - let side = if request.direction.trim().is_empty() { - "remote".to_string() - } else { - request.direction - }; - let path = sftp_side_path(&side, &request.local_path, &request.remote_path); - let response = self - .sftp_registry - .mkdir( - request.session_id, - Some(request.project_path_key), - request.workdir, - side, - path, - ) - .await?; - Ok(sftp_action_response_to_proto(action, response)) - } - "rename" => { - let side = if request.direction.trim().is_empty() { - "remote".to_string() - } else { - request.direction - }; - let response = self - .sftp_registry - .rename( - request.session_id, - Some(request.project_path_key), - request.workdir, - side, - request.from_path, - request.to_path, - ) - .await?; - Ok(sftp_action_response_to_proto(action, response)) - } - "delete" => { - let side = if request.direction.trim().is_empty() { - "remote".to_string() - } else { - request.direction - }; - let path = sftp_side_path(&side, &request.local_path, &request.remote_path); - let response = self - .sftp_registry - .delete( - request.session_id, - Some(request.project_path_key), - request.workdir, - side, - path, - request.recursive, - ) - .await?; - Ok(sftp_action_response_to_proto(action, response)) - } - "transfer" => { - let response = self - .sftp_registry - .transfer( - request.session_id, - Some(request.project_path_key), - request.workdir, - request.direction, - request.from_path, - request.target_path, - request.recursive, - request.overwrite, - ) - .await?; - Ok(sftp_transfer_response_to_proto(action, response)) - } - "cancel" => { - self.sftp_registry - .cancel_transfer(request.session_id, request.from_path)?; - Ok(proto::SftpResponse { - action, - path: String::new(), - entries: Vec::new(), - entry: None, - exists: false, - transfer: None, - }) - } - _ => Err(format!("unsupported sftp action: {action}")), - } - } - - async fn handle_terminal_request( - &self, - request: proto::TerminalRequest, - ) -> Result { - let action = request.action.trim().to_ascii_lowercase(); - self.ensure_terminal_request_allowed(&action, &request)?; - match action.as_str() { - "shell_options" => { - let options = terminal_shell_options(); - Ok(proto::TerminalResponse { - action, - sessions: Vec::new(), - session: None, - output: Vec::new(), - truncated: false, - shell_options: options - .options - .into_iter() - .map(terminal_shell_option_to_proto) - .collect(), - default_shell: options.default_shell, - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - }) - } - "list" => { - let project_path_key = normalize_project_path_key(&request.project_path_key); - let project_filter = (!project_path_key.is_empty()).then_some(project_path_key); - let config = self.config_tx.borrow().clone(); - let sessions = self - .terminal_registry - .list(project_filter) - .sessions - .into_iter() - .filter(|session| { - if session.kind.trim() == "ssh" { - config.enable_web_ssh_terminal - } else { - config.enable_web_terminal - } - }) - .map(terminal_session_to_proto) - .collect(); - Ok(proto::TerminalResponse { - action, - sessions, - session: None, - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - }) - } - "create" => { - let project_path_key = - required_terminal_project_path_key(&request.project_path_key)?; - let snapshot = self.terminal_registry.create( - request.cwd, - Some(project_path_key), - optional_proto_text(request.shell), - optional_proto_text(request.title), - optional_proto_u16(request.cols), - optional_proto_u16(request.rows), - )?; - Ok(terminal_create_snapshot_response_to_proto(action, snapshot)) - } - "create_ssh" => { - let project_path_key = - required_terminal_project_path_key(&request.project_path_key)?; - let response = self - .terminal_registry - .clone() - .create_ssh( - request.cwd, - Some(project_path_key), - request.ssh_host_id, - optional_proto_text(request.title), - optional_proto_u16(request.cols), - optional_proto_u16(request.rows), - request.sftp_enabled, - ) - .await?; - Ok(terminal_ssh_create_response_to_proto(action, response)) - } - "answer_ssh_prompt" => { - let response = self - .terminal_registry - .clone() - .answer_ssh_prompt( - request.prompt_id, - optional_proto_text(request.prompt_answer), - request.trust_host_key, - ) - .await?; - Ok(terminal_ssh_create_response_to_proto(action, response)) - } - "ssh_latency" => { - self.ensure_terminal_session_in_project( - &request.session_id, - &request.project_path_key, - )?; - let latency = self - .terminal_registry - .ssh_latency(request.session_id) - .await?; - Ok(proto::TerminalResponse { - action, - sessions: Vec::new(), - session: None, - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: latency.latency_ms, - ssh_tabs: None, - }) - } - "cancel_ssh_prompt" => { - self.terminal_registry - .cancel_ssh_prompt(request.prompt_id)?; - Ok(proto::TerminalResponse { - action, - sessions: Vec::new(), - session: None, - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - }) - } - "ssh_tabs_list" => { - let project_path_key = - required_terminal_project_path_key(&request.project_path_key)?; - let snapshot = self - .terminal_registry - .ssh_terminal_tabs_list(project_path_key)?; - Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) - } - "ssh_tab_open" => { - let snapshot = self - .terminal_registry - .ssh_terminal_tab_open(request.session_id, request.tab_kind)?; - Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) - } - "ssh_tab_close" => { - let snapshot = self - .terminal_registry - .ssh_terminal_tab_close(request.tab_id)?; - Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) - } - "rename" => { - self.ensure_terminal_session_in_project( - &request.session_id, - &request.project_path_key, - )?; - let session = self - .terminal_registry - .rename(request.session_id, request.title)?; - Ok(terminal_record_response_to_proto(action, session)) - } - "close" => { - self.ensure_terminal_session_in_project( - &request.session_id, - &request.project_path_key, - )?; - let session = self.terminal_registry.close(request.session_id)?; - self.sftp_registry.close_session(&session.id); - Ok(terminal_record_response_to_proto(action, session)) - } - "close_project" => { - let project_path_key = - required_terminal_project_path_key(&request.project_path_key)?; - let config = self.config_tx.borrow().clone(); - let sessions: Vec = self - .terminal_registry - .list(Some(project_path_key)) - .sessions - .into_iter() - .filter(|session| { - if session.kind.trim() == "ssh" { - config.enable_web_ssh_terminal - } else { - config.enable_web_terminal - } - }) - .filter_map(|session| self.terminal_registry.close(session.id).ok()) - .collect(); - for session in &sessions { - self.sftp_registry.close_session(&session.id); - } - Ok(terminal_list_response_to_proto(action, sessions)) - } - "" => Err("terminal action is required".to_string()), - other => Err(format!("unsupported terminal action: {other}")), - } - } - - fn ensure_terminal_session_in_project( - &self, - session_id: &str, - project_path_key: &str, - ) -> Result<(), String> { - let project_path_key = required_terminal_project_path_key(project_path_key)?; - let session = self - .terminal_registry - .session_record(session_id.trim().to_string())?; - if !project_path_keys_equal(&session.project_path_key, &project_path_key) { - return Err("terminal session is outside the requested project".to_string()); - } - Ok(()) - } - - fn ensure_terminal_request_allowed( - &self, - action: &str, - request: &proto::TerminalRequest, - ) -> Result<(), String> { - let config = self.config_tx.borrow().clone(); - match action { - "create_ssh" | "answer_ssh_prompt" | "cancel_ssh_prompt" | "ssh_tabs_list" - | "ssh_tab_open" | "ssh_tab_close" => { - if config.enable_web_ssh_terminal { - Ok(()) - } else { - Err("web SSH terminal is disabled in desktop Remote settings".to_string()) - } - } - "list" => { - if config.enable_web_terminal || config.enable_web_ssh_terminal { - Ok(()) - } else { - Err("web terminal is disabled in desktop Remote settings".to_string()) - } - } - "attach" | "input" | "resize" | "rename" | "close" | "ssh_latency" => { - let session = self - .terminal_registry - .session_record(request.session_id.trim().to_string())?; - let allowed = if session.kind.trim() == "ssh" { - config.enable_web_ssh_terminal - } else { - config.enable_web_terminal - }; - if allowed { - Ok(()) - } else if session.kind.trim() == "ssh" { - Err("web SSH terminal is disabled in desktop Remote settings".to_string()) - } else { - Err("web terminal is disabled in desktop Remote settings".to_string()) - } - } - "close_project" => { - if config.enable_web_terminal || config.enable_web_ssh_terminal { - Ok(()) - } else { - Err("web terminal is disabled in desktop Remote settings".to_string()) - } - } - _ => { - if config.enable_web_terminal { - Ok(()) - } else { - Err("web terminal is disabled in desktop Remote settings".to_string()) - } - } - } - } - - fn ensure_terminal_stream_allowed( - &self, - frame: &proto::TerminalStreamFrame, - ) -> Result<(), String> { - let action = frame.kind.trim().to_ascii_lowercase(); - let request = proto::TerminalRequest { - action: action.clone(), - session_id: frame.session_id.clone(), - project_path_key: frame.project_path_key.clone(), - cols: frame.cols, - rows: frame.rows, - max_bytes: frame.max_bytes, - ..Default::default() - }; - match action.as_str() { - "attach" | "input" | "resize" => { - self.ensure_terminal_session_in_project( - &request.session_id, - &request.project_path_key, - )?; - self.ensure_terminal_request_allowed(&action, &request) - } - other => Err(format!("unsupported terminal stream frame: {other}")), - } - } - - pub(crate) async fn send_agent_envelope( - &self, - envelope: proto::AgentEnvelope, - ) -> Result<(), String> { - let sender = self.current_outbound_sender()?; - send_agent_envelope_to(sender, envelope).await - } - - fn current_outbound_sender(&self) -> Result, String> { - self.outbound_tx - .lock() - .map_err(|_| "gateway outbound sender lock poisoned".to_string())? - .clone() - .ok_or_else(|| "gateway outbound stream is offline".to_string()) - } - - fn current_terminal_stream_sender( - &self, - ) -> Result, String> { - self.terminal_stream_tx - .lock() - .map_err(|_| "gateway terminal stream sender lock poisoned".to_string())? - .clone() - .ok_or_else(|| "gateway terminal stream is offline".to_string()) - } - - fn spawn_uploaded_image_preview_response( - &self, - request_id: String, - request: proto::UploadedImagePreviewRequest, - ) -> Result<(), String> { - let sender = self.current_outbound_sender()?; - tauri::async_runtime::spawn(async move { - let envelope = match gateway_bridge::handle_uploaded_image_preview(request).await { - Ok(response) => proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::UploadedImagePreviewResp( - response, - )), - }, - Err(error) => build_error_response_envelope(request_id, 500, error), - }; - if let Err(error) = send_agent_envelope_to(sender, envelope).await { - eprintln!("send gateway uploaded image preview response failed: {error}"); - } - }); - Ok(()) - } - - async fn send_error_response( - &self, - request_id: String, - code: i32, - message: String, - ) -> Result<(), String> { - self.send_agent_envelope(build_error_response_envelope(request_id, code, message)) - .await - } - - fn set_outbound_sender(&self, sender: Option>) { - if let Ok(mut slot) = self.outbound_tx.lock() { - *slot = sender; - } - } - - fn set_terminal_stream_sender(&self, sender: Option>) { - if let Ok(mut slot) = self.terminal_stream_tx.lock() { - *slot = sender; - } - } - - fn clear_terminal_stream_sender_if_current( - &self, - sender: &mpsc::Sender, - ) { - if let Ok(mut slot) = self.terminal_stream_tx.lock() { - if slot - .as_ref() - .map(|current| current.same_channel(sender)) - .unwrap_or(false) - { - *slot = None; - } - } - } - - fn touch_heartbeat(&self) { - self.publish_status(|status| { - status.last_heartbeat = Some(now_unix_seconds()); - }); - } - - fn publish_status(&self, mutate: impl FnOnce(&mut GatewayStatusSnapshot)) { - let next = if let Ok(mut status) = self.status.lock() { - mutate(&mut status); - status.clone() - } else { - return; - }; - let _ = self.app_handle.emit("gateway:status", next); - } - - async fn publish_current_settings_sync(&self) -> Result<(), String> { - let snapshot = self.current_settings_snapshot().await?; - self.publish_settings_sync(snapshot).await - } - - async fn publish_current_terminal_sessions(&self) -> Result<(), String> { - let sessions = self.terminal_registry.list(None).sessions; - for session in sessions { - self.send_agent_envelope(build_terminal_event_envelope(TerminalEventPayload { - kind: "created".to_string(), - session_id: session.id.clone(), - project_path_key: session.project_path_key.clone(), - session: Some(session), - data: None, - output_start_offset: None, - output_end_offset: None, - ssh_tabs: None, - })) - .await?; - } - Ok(()) - } - - pub async fn refresh_settings_sync_from_db(&self) -> Result { - let snapshot = self.current_settings_snapshot().await?; - self.app_handle - .emit(GATEWAY_SETTINGS_SYNC_EVENT, snapshot.clone()) - .map_err(|e| format!("emit gateway settings sync failed: {e}"))?; - self.publish_settings_sync(snapshot.clone()).await?; - Ok(snapshot) - } - - async fn handle_chat_command( - self: &Arc, - request_id: String, - command: proto::ChatCommandRequest, - ) -> Result<(), String> { - match command.r#type.trim() { - "chat.submit" => { - let Some(request) = command.request else { - return self - .send_gateway_chat_control_event_with_details( - request_id, - String::new(), - "failed", - "invalid_chat_command".to_string(), - "chat.submit requires request payload".to_string(), - ) - .await; - }; - let event_payload = - Self::build_gateway_chat_request_event(request_id, request, false, None); - self.enqueue_gateway_chat_request(event_payload).await - } - "chat.edit_resend" => { - let Some(request) = command.request else { - return self - .send_gateway_chat_control_event_with_details( - request_id, - String::new(), - "failed", - "invalid_chat_command".to_string(), - "chat.edit_resend requires request payload".to_string(), - ) - .await; - }; - let conversation_id = request.conversation_id.trim().to_string(); - let Some(base_message_ref) = command.base_message_ref else { - return self - .send_gateway_chat_control_event_with_details( - request_id, - conversation_id, - "failed", - "invalid_chat_command".to_string(), - "chat.edit_resend requires base_message_ref".to_string(), - ) - .await; - }; - if !is_complete_user_chat_message_ref(&base_message_ref) { - return self - .send_gateway_chat_control_event_with_details( - request_id, - conversation_id, - "failed", - "invalid_chat_command".to_string(), - "chat.edit_resend requires a complete stable base_message_ref" - .to_string(), - ) - .await; - } - if conversation_id.is_empty() { - return self - .send_gateway_chat_control_event_with_details( - request_id, - String::new(), - "failed", - "invalid_chat_command".to_string(), - "chat.edit_resend requires conversation_id".to_string(), - ) - .await; - } - let event_payload = Self::build_gateway_chat_request_event( - request_id, - request, - true, - Some(base_message_ref), - ); - self.enqueue_gateway_chat_request(event_payload).await - } - "chat.cancel" => { - let conversation_id = command - .cancel - .map(|cancel| cancel.conversation_id) - .or_else(|| command.request.map(|request| request.conversation_id)) - .unwrap_or_default(); - self.cancel_remote_chat_request(&request_id, &conversation_id)?; - self.send_gateway_chat_control_event( - request_id.clone(), - conversation_id.clone(), - "cancelled", - ) - .await?; - self.app_handle - .emit( - "gateway:chat-cancel", - GatewayChatCancelEvent { - request_id, - conversation_id, - }, - ) - .map_err(|e| format!("emit gateway chat cancel failed: {e}")) - } - other => { - self.send_gateway_chat_control_event_with_details( - request_id, - command - .request - .map(|request| request.conversation_id) - .unwrap_or_default(), - "failed", - "unsupported_chat_command".to_string(), - format!("unsupported chat command: {other}"), - ) - .await - } - } - } - - async fn enqueue_gateway_chat_request( - &self, - event_payload: GatewayChatRequestEvent, - ) -> Result<(), String> { - let enqueue_outcome = self.enqueue_remote_chat_request(event_payload)?; - if let Err(error) = self - .send_gateway_chat_control_event( - enqueue_outcome.request_id.clone(), - enqueue_outcome.conversation_id.clone(), - enqueue_outcome.control_type, - ) - .await - { - if enqueue_outcome.inserted { - self.remove_remote_chat_request(&enqueue_outcome.request_id)?; - } - return Err(error); - } - if enqueue_outcome.should_wake_runtime { - self.app_handle - .emit( - "gateway:chat-request-ready", - json!({ "requestId": enqueue_outcome.request_id }), - ) - .map_err(|e| format!("emit gateway chat request ready failed: {e}"))?; - } - Ok(()) - } - - fn build_gateway_chat_request_event( - request_id: String, - request: proto::ChatRequest, - rebased: bool, - base_message_ref: Option, - ) -> GatewayChatRequestEvent { - let proto::ChatRequest { - conversation_id, - client_request_id, - message, - selected_model, - runtime_controls, - execution_mode, - workdir, - selected_system_tools, - uploaded_files, - queue_policy, - } = request; - let selected_model = selected_model.map(|selected_model| GatewaySelectedModelEvent { - custom_provider_id: selected_model.custom_provider_id, - model: selected_model.model, - provider_type: selected_model.provider_type, - }); - let runtime_controls = - runtime_controls.map(|runtime_controls| GatewayChatRuntimeControlsEvent { - thinking_enabled: runtime_controls.thinking_enabled, - native_web_search_enabled: runtime_controls.native_web_search_enabled, - reasoning: runtime_controls.reasoning, - }); - let base_message_ref = - base_message_ref.map(|base_message_ref| GatewayChatMessageRefEvent { - segment_index: base_message_ref.segment_index, - message_index: base_message_ref.message_index, - segment_id: base_message_ref.segment_id, - message_id: base_message_ref.message_id, - role: base_message_ref.role, - content_hash: base_message_ref.content_hash, - }); - GatewayChatRequestEvent { - request_id, - conversation_id, - client_request_id, - message, - rebased, - base_message_ref, - selected_model, - runtime_controls, - execution_mode, - workdir, - selected_system_tools, - uploaded_files: uploaded_files - .into_iter() - .map(|file| GatewayUploadedFileEvent { - relative_path: file.relative_path, - absolute_path: file.absolute_path, - file_name: file.file_name, - kind: file.kind, - size_bytes: file.size_bytes, - }) - .collect(), - queue_policy, - } - } - - async fn current_settings_snapshot(&self) -> Result { - let cached_snapshot = self - .settings_snapshot - .lock() - .map_err(|_| "gateway settings snapshot lock poisoned".to_string())? - .clone(); - - let db_snapshot = tauri::async_runtime::spawn_blocking(move || { - let conn = open_db()?; - load_gateway_settings_sync_snapshot(&conn) - }) - .await - .map_err(|e| format!("load gateway settings snapshot join failed: {e}"))??; - - let snapshot = merge_settings_sync_snapshot(db_snapshot, cached_snapshot.as_ref())?; - self.store_settings_snapshot(snapshot) - } - - fn store_settings_snapshot(&self, payload: Value) -> Result { - let snapshot = - redact_gateway_settings_sync_payload(normalize_settings_sync_payload(payload)?)?; - let mut guard = self - .settings_snapshot - .lock() - .map_err(|_| "gateway settings snapshot lock poisoned".to_string())?; - *guard = Some(snapshot.clone()); - Ok(snapshot) - } - - fn enqueue_remote_chat_request( - &self, - request: GatewayChatRequestEvent, - ) -> Result { - let request_id = request.request_id.trim(); - if request_id.is_empty() { - return Ok(RemoteChatEnqueueOutcome { - request_id: String::new(), - conversation_id: String::new(), - control_type: "delivered", - should_wake_runtime: false, - inserted: false, - }); - } - let request_id = request_id.to_string(); - let client_request_id = request.client_request_id.trim().to_string(); - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - - let existing_request_id = if inbox.contains_key(&request_id) { - Some(request_id.clone()) - } else if client_request_id.is_empty() { - None - } else { - inbox.iter().find_map(|(candidate_request_id, record)| { - if record.request.client_request_id.trim() == client_request_id { - Some(candidate_request_id.clone()) - } else { - None - } - }) - }; - - if let Some(existing_request_id) = existing_request_id { - let now = Instant::now(); - let record = inbox - .get_mut(&existing_request_id) - .ok_or_else(|| "remote chat request disappeared while enqueueing".to_string())?; - Self::merge_duplicate_remote_chat_request(record, request, now); - return Ok(RemoteChatEnqueueOutcome { - request_id: existing_request_id, - conversation_id: record.request.conversation_id.clone(), - control_type: Self::remote_chat_record_control_type(record), - should_wake_runtime: Self::remote_chat_record_should_wake_runtime(record, now), - inserted: false, - }); - } - - let now = Instant::now(); - inbox.insert( - request_id.clone(), - RemoteChatInboxRecord { - request, - state: "queued".to_string(), - lease_owner: None, - lease_expires_at: None, - attempt: 0, - started: false, - last_error: None, - created_at: now, - updated_at: now, - }, - ); - let conversation_id = inbox - .get(request_id.as_str()) - .map(|record| record.request.conversation_id.clone()) - .unwrap_or_default(); - Ok(RemoteChatEnqueueOutcome { - request_id, - conversation_id, - control_type: "delivered", - should_wake_runtime: true, - inserted: true, - }) - } - - fn remove_remote_chat_request(&self, request_id: &str) -> Result<(), String> { - let request_id = request_id.trim(); - if request_id.is_empty() { - return Ok(()); - } - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - inbox.remove(request_id); - Ok(()) - } - - fn cancel_remote_chat_request( - &self, - request_id: &str, - conversation_id: &str, - ) -> Result<(), String> { - let request_id = request_id.trim(); - let conversation_id = conversation_id.trim(); - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - if !request_id.is_empty() { - inbox.remove(request_id); - } - if !conversation_id.is_empty() { - inbox.retain(|_, record| { - record.request.conversation_id.trim() != conversation_id - || !Self::remote_chat_record_should_cancel_for_conversation(record) - }); - } - Ok(()) - } - - fn merge_duplicate_remote_chat_request( - record: &mut RemoteChatInboxRecord, - request: GatewayChatRequestEvent, - now: Instant, - ) { - // A reconnect can replay the same gateway request while the JS runner is - // already processing it. Preserve local lease/owner/started state and - // only fill metadata that may have been absent in the original payload. - if !record.started && record.state.trim() == "queued" { - let canonical_request_id = record.request.request_id.clone(); - record.request = request; - record.request.request_id = canonical_request_id; - record.updated_at = now; - return; - } - if record.request.client_request_id.trim().is_empty() - && !request.client_request_id.trim().is_empty() - { - record.request.client_request_id = request.client_request_id.clone(); - } - if record.request.conversation_id.trim().is_empty() - && !request.conversation_id.trim().is_empty() - { - record.request.conversation_id = request.conversation_id.clone(); - } - record.updated_at = now; - } - - fn remote_chat_record_control_type(record: &RemoteChatInboxRecord) -> &'static str { - if record.started { - return "started"; - } - match record.state.trim() { - "claimed" => "claimed", - "starting" => "starting", - "queued_in_gui" => "queued_in_gui", - "running" => "started", - "failed" => "failed", - "cancelled" => "cancelled", - "completed" => "completed", - _ => "delivered", - } - } - - fn remote_chat_record_should_wake_runtime( - record: &RemoteChatInboxRecord, - now: Instant, - ) -> bool { - if record.started { - return false; - } - match record.state.trim() { - "queued" | "delivered" => true, - "claimed" | "starting" => record - .lease_expires_at - .map(|expires_at| now >= expires_at) - .unwrap_or(true), - _ => false, - } - } - - fn remote_chat_record_should_cancel_for_conversation(record: &RemoteChatInboxRecord) -> bool { - if record.started { - return true; - } - matches!(record.state.trim(), "claimed" | "starting" | "running") - } - - fn remote_chat_record_has_current_lease( - record: &RemoteChatInboxRecord, - worker_id: &str, - now: Instant, - ) -> bool { - if worker_id.trim().is_empty() { - return false; - } - if record.lease_owner.as_deref() != Some(worker_id) { - return false; - } - record - .lease_expires_at - .map(|expires_at| now < expires_at) - .unwrap_or(false) - } - - fn remote_chat_record_is_owned_by_worker( - record: &RemoteChatInboxRecord, - worker_id: &str, - ) -> bool { - !worker_id.trim().is_empty() && record.lease_owner.as_deref() == Some(worker_id) - } - - fn remote_chat_record_lease_ms(record: &RemoteChatInboxRecord) -> u64 { - if record.started { - GATEWAY_CHAT_RUNNING_LEASE_MS - } else { - GATEWAY_CHAT_LEASE_MS - } - } - - fn renew_remote_chat_request_lease( - &self, - request_id: &str, - worker_id: Option<&str>, - require_current: bool, - ) -> Result { - let request_id = request_id.trim(); - if request_id.is_empty() { - return Ok(true); - } - let worker_id = worker_id.unwrap_or_default().trim(); - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let Some(record) = inbox.get(request_id) else { - return Ok(true); - }; - let now = Instant::now(); - if require_current && !Self::remote_chat_record_has_current_lease(record, worker_id, now) { - return Ok(false); - } - if !require_current && !Self::remote_chat_record_is_owned_by_worker(record, worker_id) { - return Ok(false); - } - let lease_ms = Self::remote_chat_record_lease_ms(record); - if let Some(record) = inbox.get_mut(request_id) { - record.lease_expires_at = Some(now + Duration::from_millis(lease_ms)); - record.updated_at = now; - } - Ok(true) - } - - pub async fn claim_next_chat_request( - &self, - worker_id: String, - lease_ms: Option, - ) -> Result, String> { - let worker_id = worker_id.trim().to_string(); - if worker_id.is_empty() { - return Err("worker_id is required".to_string()); - } - let lease_ms = lease_ms - .unwrap_or(GATEWAY_CHAT_LEASE_MS) - .clamp(1_000, 120_000); - let now = Instant::now(); - let claimed = { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let mut selected_request_id: Option = None; - let mut selected_created_at: Option = None; - for (request_id, record) in inbox.iter() { - let state = record.state.trim(); - let lease_expired = record - .lease_expires_at - .map(|expires_at| now >= expires_at) - .unwrap_or(true); - if state == "queued" - || ((state == "claimed" || state == "starting") - && lease_expired - && !record.started) - { - if selected_created_at - .map(|created_at| record.created_at < created_at) - .unwrap_or(true) - { - selected_request_id = Some(request_id.clone()); - selected_created_at = Some(record.created_at); - } - } - } - selected_request_id.and_then(|request_id| { - inbox.get_mut(&request_id).map(|record| { - record.state = "claimed".to_string(); - record.lease_owner = Some(worker_id.clone()); - record.lease_expires_at = Some(now + Duration::from_millis(lease_ms)); - record.attempt = record.attempt.saturating_add(1); - record.updated_at = now; - GatewayChatClaimedRequest { - request_id: record.request.request_id.clone(), - client_request_id: record.request.client_request_id.clone(), - conversation_id: record.request.conversation_id.clone(), - state: record.state.clone(), - attempt: record.attempt, - lease_ms, - request: record.request.clone(), - } - }) - }) - }; - if let Some(claimed) = claimed.as_ref() { - self.send_gateway_chat_control_event( - claimed.request_id.clone(), - claimed.conversation_id.clone(), - "claimed", - ) - .await?; - } - Ok(claimed) - } - - pub async fn mark_chat_request_started( - &self, - request_id: String, - conversation_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let conversation_id = conversation_id.trim().to_string(); - let worker_id = worker_id.trim().to_string(); - { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let now = Instant::now(); - let record = inbox - .get_mut(&request_id) - .ok_or_else(|| "remote chat request lease is no longer active".to_string())?; - let queued_in_gui = record.state.trim() == "queued_in_gui" && !record.started; - if !queued_in_gui - && !Self::remote_chat_record_has_current_lease(record, &worker_id, now) - { - return Err("remote chat request lease is no longer active".to_string()); - } - if record.started { - return Ok(()); - } - record.state = "running".to_string(); - record.started = true; - record.lease_owner = Some(worker_id); - if !conversation_id.is_empty() { - record.request.conversation_id = conversation_id.clone(); - } - record.lease_expires_at = - Some(now + Duration::from_millis(GATEWAY_CHAT_RUNNING_LEASE_MS)); - record.updated_at = now; - } - self.ledger_mark_run_running(&request_id, &conversation_id)?; - self.send_gateway_chat_control_event(request_id, conversation_id, "started") - .await - } - - pub async fn mark_local_chat_run_started( - &self, - request_id: String, - conversation_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let conversation_id = conversation_id.trim().to_string(); - if request_id.is_empty() || conversation_id.is_empty() { - return Ok(()); - } - self.ledger_mark_run_running(&request_id, &conversation_id)?; - self.send_gateway_chat_control_event(request_id, conversation_id, "started") - .await - } - - pub async fn mark_chat_request_queued_in_gui( - &self, - request_id: String, - conversation_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let conversation_id = conversation_id.trim().to_string(); - let worker_id = worker_id.trim().to_string(); - let should_send = { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let Some(record) = inbox.get_mut(&request_id) else { - return Ok(()); - }; - if record.started { - return Ok(()); - } - if !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { - return Ok(()); - } - record.state = "queued_in_gui".to_string(); - record.lease_owner = None; - record.lease_expires_at = None; - if !conversation_id.is_empty() { - record.request.conversation_id = conversation_id.clone(); - } - record.updated_at = Instant::now(); - true - }; - if !should_send { - return Ok(()); - } - self.send_gateway_chat_control_event(request_id, conversation_id, "queued_in_gui") - .await - } - - pub async fn complete_chat_request( - &self, - request_id: String, - conversation_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let conversation_id = conversation_id.trim().to_string(); - let worker_id = worker_id.trim().to_string(); - let should_send = { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let Some(record) = inbox.get(&request_id) else { - return Ok(()); - }; - if !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { - return Ok(()); - } - inbox.remove(&request_id); - true - }; - if !should_send { - return Ok(()); - } - // Ledger first: once the inbox record is gone this is the only place - // that still knows the run finished, and the send below can fail. - self.ledger_mark_run_terminal( - &request_id, - &conversation_id, - ChatRunLedgerState::Completed, - "", - "", - )?; - self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "completed") - .await?; - self.ledger_mark_run_terminal_sent(&request_id) - } - - pub async fn fail_chat_request( - &self, - request_id: String, - conversation_id: Option, - error_code: String, - message: String, - terminal: bool, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let worker_id = worker_id.trim().to_string(); - let conversation_id = conversation_id.unwrap_or_default(); - // None: inbox record already gone; Some(true): accepted; Some(false): rejected. - let inbox_outcome = { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - match inbox.get_mut(&request_id) { - None => None, - Some(record) => { - let queued_in_gui = terminal && record.state.trim() == "queued_in_gui"; - if !queued_in_gui - && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) - { - Some(false) - } else { - record.state = if terminal { "failed" } else { "queued" }.to_string(); - record.lease_owner = None; - record.lease_expires_at = None; - record.last_error = Some(message.clone()); - record.updated_at = Instant::now(); - if terminal { - inbox.remove(&request_id); - } - Some(true) - } - } - } - }; - match inbox_outcome { - Some(false) => return Ok(()), - Some(true) => {} - None => { - // The inbox record can be gone while the run is still live in - // the ledger (e.g. a complete/fail race removed it). Dropping - // this terminal would strand the WebUI, so repair via the - // ledger instead of returning silently. - if !terminal || !self.ledger_has_live_run(&request_id)? { - return Ok(()); - } - } - } - if terminal { - self.ledger_mark_run_terminal( - &request_id, - &conversation_id, - ChatRunLedgerState::Failed, - &error_code, - &message, - )?; - } - self.send_gateway_chat_control_event_with_details( - request_id.clone(), - conversation_id, - "failed", - error_code, - message, - ) - .await?; - if terminal { - self.ledger_mark_run_terminal_sent(&request_id)?; - } - Ok(()) - } - - pub async fn cancel_chat_request( - &self, - request_id: String, - conversation_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim().to_string(); - let conversation_id = conversation_id.trim().to_string(); - let worker_id = worker_id.trim().to_string(); - let should_send = { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let Some(record) = inbox.get(&request_id) else { - return Ok(()); - }; - let queued_in_gui = record.state.trim() == "queued_in_gui"; - if !queued_in_gui && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { - return Ok(()); - } - inbox.remove(&request_id); - true - }; - if !should_send { - return Ok(()); - } - // This "cancelled" is a genuine run terminal, not a cancel-request ack: - // the inbox record is removed above so no other terminal will ever be - // produced for this request (callers use it to drop queued turns that - // never start; running runs terminate via done/error/fail instead). - // First-terminal-wins keeps this from clobbering an earlier outcome. - self.ledger_mark_run_terminal( - &request_id, - &conversation_id, - ChatRunLedgerState::Cancelled, - "", - "", - )?; - self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "cancelled") - .await?; - self.ledger_mark_run_terminal_sent(&request_id) - } - - pub fn heartbeat_chat_request( - &self, - request_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim(); - let worker_id = worker_id.trim(); - if request_id.is_empty() || worker_id.is_empty() { - return Ok(()); - } - { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - if let Some(record) = inbox.get_mut(request_id) { - if record.lease_owner.as_deref() == Some(worker_id) { - let lease_ms = Self::remote_chat_record_lease_ms(record); - record.lease_expires_at = - Some(Instant::now() + Duration::from_millis(lease_ms)); - record.updated_at = Instant::now(); - } - } - } - self.ledger_touch_run(request_id, "") - } - - pub async fn publish_chat_runtime_status( - &self, - worker_id: String, - state: String, - visible: bool, - active_run_count: u32, - ) -> Result<(), String> { - let worker_id = worker_id.trim().to_string(); - if worker_id.is_empty() { - return Ok(()); - } - let state = match state.trim() { - "draining" => "draining", - "busy" => "busy", - "suspended" => "suspended", - _ => "ready", - } - .to_string(); - let (active_reports, finished_reports) = { - let (now, _now_ms) = chat_run_ledger_now(); - let ledger = self - .chat_run_ledger - .lock() - .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; - (ledger.active_reports(now), ledger.recent_terminal_reports()) - }; - let active_run_count = - active_run_count.max(u32::try_from(active_reports.len()).unwrap_or(u32::MAX)); - let envelope = build_gateway_runtime_status_envelope( - worker_id, - state, - visible, - active_run_count, - active_reports - .iter() - .map(chat_run_report_from_entry) - .collect(), - finished_reports - .iter() - .map(chat_run_report_from_entry) - .collect(), - ); - match self.send_agent_envelope(envelope).await { - Ok(()) => Ok(()), - Err(error) if error.contains("outbound stream is offline") => Ok(()), - Err(error) => Err(error), - } - } - - pub fn release_chat_request_lease( - &self, - request_id: String, - worker_id: String, - ) -> Result<(), String> { - let request_id = request_id.trim(); - let worker_id = worker_id.trim(); - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - if let Some(record) = inbox.get_mut(request_id) { - if record.lease_owner.as_deref() == Some(worker_id) && !record.started { - record.state = "queued".to_string(); - record.lease_owner = None; - record.lease_expires_at = None; - record.updated_at = Instant::now(); - } - } - Ok(()) - } - - async fn expire_remote_chat_leases(&self) -> Result<(), String> { - let mut failed: Vec<(String, String)> = Vec::new(); - let mut wake = false; - { - let mut inbox = self - .remote_chat_inbox - .lock() - .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; - let now = Instant::now(); - for record in inbox.values_mut() { - let Some(expires_at) = record.lease_expires_at else { - continue; - }; - if now < expires_at { - continue; - } - if record.started { - record.state = "failed".to_string(); - failed.push(( - record.request.request_id.clone(), - record.request.conversation_id.clone(), - )); - continue; - } - record.state = "queued".to_string(); - record.lease_owner = None; - record.lease_expires_at = None; - record.updated_at = now; - wake = true; - } - for (request_id, _) in &failed { - inbox.remove(request_id); - } - } - if wake { - let _ = self.app_handle.emit( - "gateway:chat-request-ready", - json!({ "reason": "lease_expired" }), - ); - } - for (request_id, conversation_id) in failed { - self.ledger_mark_run_terminal( - &request_id, - &conversation_id, - ChatRunLedgerState::Failed, - "desktop_runtime_lease_expired", - "Desktop chat runtime stopped before completing the remote request.", - )?; - // One failed send must not abort the remaining terminals; the - // ledger flush loop retries anything that stays unsent. - match self - .send_gateway_chat_control_event_with_details( - request_id.clone(), - conversation_id, - "failed", - "desktop_runtime_lease_expired".to_string(), - "Desktop chat runtime stopped before completing the remote request." - .to_string(), - ) - .await - { - Ok(()) => self.ledger_mark_run_terminal_sent(&request_id)?, - Err(error) => { - eprintln!("send gateway chat lease-expired terminal failed: {error}"); - } - } - } - Ok(()) - } - - fn with_chat_run_ledger( - &self, - f: impl FnOnce(&mut ChatRunLedger) -> T, - ) -> Result { - let mut ledger = self - .chat_run_ledger - .lock() - .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; - Ok(f(&mut ledger)) - } - - fn ledger_mark_run_running(&self, run_id: &str, conversation_id: &str) -> Result<(), String> { - let (now, now_ms) = chat_run_ledger_now(); - self.with_chat_run_ledger(|ledger| { - ledger.mark_running(run_id, conversation_id, now, now_ms); - }) - } - - fn ledger_touch_run(&self, run_id: &str, conversation_id: &str) -> Result<(), String> { - let (now, now_ms) = chat_run_ledger_now(); - self.with_chat_run_ledger(|ledger| ledger.touch(run_id, conversation_id, now, now_ms)) - } - - fn ledger_mark_run_terminal( - &self, - run_id: &str, - conversation_id: &str, - state: ChatRunLedgerState, - error_code: &str, - message: &str, - ) -> Result { - let (now, now_ms) = chat_run_ledger_now(); - self.with_chat_run_ledger(|ledger| { - ledger.mark_terminal( - run_id, - conversation_id, - state, - error_code, - message, - now, - now_ms, - ) - }) - } - - fn ledger_mark_run_terminal_sent(&self, run_id: &str) -> Result<(), String> { - self.with_chat_run_ledger(|ledger| ledger.mark_terminal_sent(run_id)) - } - - fn ledger_has_live_run(&self, run_id: &str) -> Result { - self.with_chat_run_ledger(|ledger| { - ledger - .get(run_id) - .map(|entry| !entry.state.is_terminal()) - .unwrap_or(false) - }) - } - - async fn flush_unsent_chat_run_terminals(&self) -> Result<(), String> { - if !self.status().online { - return Ok(()); - } - let unsent = { - let (now, now_ms) = chat_run_ledger_now(); - let mut ledger = self - .chat_run_ledger - .lock() - .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; - // Sweep first: runs demoted by the TTL become unsent terminals and - // are picked up by this very flush. - ledger.sweep(now, now_ms); - ledger.unsent_terminals() - }; - for entry in unsent { - // The gateway cannot anchor a control event without a conversation - // (it drops them at ingress); such entries only age out. - if entry.conversation_id.is_empty() { - continue; - } - match self - .send_gateway_chat_control_event_with_details( - entry.run_id.clone(), - entry.conversation_id.clone(), - entry.state.as_str(), - entry.error_code.clone(), - entry.message.clone(), - ) - .await - { - Ok(()) => self.ledger_mark_run_terminal_sent(&entry.run_id)?, - Err(error) => { - eprintln!( - "flush gateway chat run terminal {} failed: {error}", - entry.run_id - ); - } - } - } - Ok(()) - } - - async fn republish_chat_run_states(&self) -> Result<(), String> { - let (active, recent_terminals) = { - let (now, _now_ms) = chat_run_ledger_now(); - let ledger = self - .chat_run_ledger - .lock() - .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; - (ledger.active_reports(now), ledger.recent_terminal_reports()) - }; - for entry in active { - if entry.conversation_id.is_empty() { - continue; - } - // "started" is idempotent on the gateway; replaying it re-anchors - // runs the gateway may have lost across a restart. - if let Err(error) = self - .send_gateway_chat_control_event( - entry.run_id.clone(), - entry.conversation_id.clone(), - "started", - ) - .await - { - eprintln!( - "republish gateway chat run {} failed: {error}", - entry.run_id - ); - } - } - // Replay all recent terminals, sent or not: a gateway restart can lose - // them, and the control events are idempotent server-side. Unsent - // terminals older than the recent window are covered by the periodic - // flush a few seconds later. - for entry in recent_terminals { - if entry.conversation_id.is_empty() { - continue; - } - if let Err(error) = self - .send_gateway_chat_control_event_with_details( - entry.run_id.clone(), - entry.conversation_id.clone(), - entry.state.as_str(), - entry.error_code.clone(), - entry.message.clone(), - ) - .await - { - eprintln!( - "republish gateway chat run terminal {} failed: {error}", - entry.run_id - ); - } else { - self.ledger_mark_run_terminal_sent(&entry.run_id)?; - } - } - Ok(()) - } - - async fn send_gateway_chat_control_event( - &self, - request_id: String, - conversation_id: String, - event_type: &str, - ) -> Result<(), String> { - self.send_gateway_chat_control_event_with_details( - request_id, - conversation_id, - event_type, - String::new(), - String::new(), - ) - .await - } - - async fn send_gateway_chat_control_event_with_details( - &self, - request_id: String, - conversation_id: String, - event_type: &str, - error_code: String, - message: String, - ) -> Result<(), String> { - self.send_agent_envelope(build_gateway_chat_control_event_envelope( - request_id, - conversation_id, - event_type, - error_code, - message, - )) - .await - } - - async fn handle_chat_queue_request( - self: &Arc, - request_id: String, - request: proto::ChatQueueRequest, - ) -> Result<(), String> { - let event_payload = GatewayChatQueueRequestEvent { - request_id: request_id.clone(), - action: request.action, - conversation_id: request.conversation_id, - item_id: request.item_id, - direction: request.direction, - revision: request.revision, - draft_json: request.draft_json, - uploaded_files_json: request.uploaded_files_json, - request_json: request.request_json, - }; - - let (tx, rx) = oneshot::channel(); - self.pending_chat_queue_requests - .lock() - .map_err(|_| "gateway chat queue request lock poisoned".to_string())? - .insert(request_id.clone(), tx); - - if let Err(error) = self - .app_handle - .emit("gateway:chat-queue-request", event_payload) - { - let _ = self - .pending_chat_queue_requests - .lock() - .map(|mut pending| pending.remove(&request_id)); - return self - .send_chat_queue_response( - request_id, - proto::ChatQueueResponse { - accepted: false, - message: format!("emit gateway chat queue request failed: {error}"), - error_code: "emit_failed".to_string(), - ..Default::default() - }, - ) - .await; - } - - let response = match tokio::time::timeout(Duration::from_secs(30), rx).await { - Ok(Ok(response)) => response, - Ok(Err(_)) => proto::ChatQueueResponse { - accepted: false, - message: "chat queue response dropped".to_string(), - error_code: "response_dropped".to_string(), - ..Default::default() - }, - Err(_) => { - let _ = self - .pending_chat_queue_requests - .lock() - .map(|mut pending| pending.remove(&request_id)); - proto::ChatQueueResponse { - accepted: false, - message: "chat queue request timed out".to_string(), - error_code: "timeout".to_string(), - ..Default::default() - } - } - }; - - self.send_chat_queue_response(request_id, response).await - } - - async fn send_chat_queue_response( - &self, - request_id: String, - response: proto::ChatQueueResponse, - ) -> Result<(), String> { - self.send_agent_envelope(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatQueueResp(response)), - }) - .await - } - - pub fn respond_chat_queue_request( - &self, - input: GatewayChatQueueResponseInput, - ) -> Result<(), String> { - let request_id = input.request_id.trim().to_string(); - if request_id.is_empty() { - return Err("chat queue request_id is required".to_string()); - } - let sender = self - .pending_chat_queue_requests - .lock() - .map_err(|_| "gateway chat queue request lock poisoned".to_string())? - .remove(&request_id); - if let Some(sender) = sender { - let _ = sender.send(proto::ChatQueueResponse { - accepted: input.accepted, - message: input.message, - snapshot_json: input.snapshot_json, - item_json: input.item_json, - error_code: input.error_code, - revision: input.revision, - }); - } - Ok(()) - } - - pub async fn publish_chat_queue_event( - &self, - input: GatewayChatQueueEventInput, - ) -> Result<(), String> { - self.send_agent_envelope(proto::AgentEnvelope { - request_id: format!("chat-queue-event-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatQueueEvent( - proto::ChatQueueEvent { - conversation_id: input.conversation_id, - snapshot_json: input.snapshot_json, - revision: input.revision, - }, - )), - }) - .await - } -} - -async fn await_abortable_on_reconfigure( - config: &RemoteSettingsPayload, - config_rx: &mut watch::Receiver, - fut: impl Future>, -) -> Result, String> { - tokio::pin!(fut); - - loop { - tokio::select! { - result = &mut fut => return result.map(Some), - changed = config_rx.changed() => { - if changed.is_err() { - return Ok(None); - } - let next = config_rx.borrow().clone(); - if next != *config { - return Ok(None); - } - } - } - } -} - -fn merge_settings_sync_snapshot(snapshot: Value, cached: Option<&Value>) -> Result { - let mut merged = match snapshot { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - - if let Some(Value::Object(cached_map)) = cached { - for field in UI_ONLY_SETTINGS_SYNC_FIELDS { - if let Some(value) = cached_map.get(*field) { - merged.insert((*field).to_string(), value.clone()); - } - } - } - - Ok(Value::Object(merged)) -} - -fn merge_settings_update_into_snapshot(snapshot: Value, update: Value) -> Result { - let mut merged = match snapshot { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - let update = match update { - Value::Object(map) => map, - _ => return Err("gateway settings update payload must be an object".to_string()), - }; - - for (field, value) in update { - // Remote settings are desktop-owned (loaded from the local DB on every - // snapshot rebuild); never let a remote client overwrite them. - if field == "remote" { - continue; - } - merged.insert(field, value); - } - - Ok(Value::Object(merged)) -} - -pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistorySyncEvent { - GatewayHistorySyncEvent { - kind: "upsert".to_string(), - conversation_id: summary.id.clone(), - conversation: Some(GatewayHistorySyncConversation { - id: summary.id.clone(), - title: summary.title.clone(), - provider_id: Some(summary.provider_id.clone()), - model: Some(summary.model.clone()), - session_id: summary.session_id.clone(), - cwd: summary.cwd.clone(), - created_at: summary.created_at, - updated_at: summary.updated_at, - message_count: summary.message_count, - is_pinned: summary.is_pinned, - pinned_at: summary.pinned_at, - is_shared: summary.is_shared, - }), - } -} - -pub fn build_history_sync_delete(conversation_id: impl Into) -> GatewayHistorySyncEvent { - let conversation_id = conversation_id.into(); - GatewayHistorySyncEvent { - kind: "delete".to_string(), - conversation_id, - conversation: None, - } -} - -fn build_history_sync_upsert_from_proto( - summary: &proto::ConversationSummary, -) -> GatewayHistorySyncEvent { - GatewayHistorySyncEvent { - kind: "upsert".to_string(), - conversation_id: summary.id.clone(), - conversation: Some(GatewayHistorySyncConversation { - id: summary.id.clone(), - title: summary.title.clone(), - provider_id: (!summary.provider_id.trim().is_empty()) - .then(|| summary.provider_id.clone()), - model: (!summary.model.trim().is_empty()).then(|| summary.model.clone()), - session_id: (!summary.session_id.trim().is_empty()).then(|| summary.session_id.clone()), - cwd: (!summary.cwd.trim().is_empty()).then(|| summary.cwd.clone()), - created_at: summary.created_at, - updated_at: summary.updated_at, - message_count: i64::from(summary.message_count), - is_pinned: summary.is_pinned, - pinned_at: (summary.pinned_at > 0).then_some(summary.pinned_at), - is_shared: summary.is_shared, - }), - } -} - -fn optional_proto_text(value: String) -> Option { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -fn optional_proto_u16(value: u32) -> Option { - if value == 0 { - None - } else { - Some(value.min(u32::from(u16::MAX)) as u16) - } -} - -fn optional_proto_usize(value: u32) -> Option { - (value > 0).then_some(value as usize) -} - -fn required_terminal_project_path_key(value: &str) -> Result { - let project_path_key = normalize_project_path_key(value); - if project_path_key.is_empty() { - return Err("project_path_key is required".to_string()); - } - Ok(project_path_key) -} - -fn terminal_u128_to_u64(value: u128) -> u64 { - value.min(u128::from(u64::MAX)) as u64 -} - -fn terminal_session_to_proto(session: TerminalSessionRecord) -> proto::TerminalSession { - proto::TerminalSession { - id: session.id, - project_path_key: normalize_project_path_key(&session.project_path_key), - cwd: session.cwd, - shell: session.shell, - title: session.title, - pid: session.pid.unwrap_or_default(), - cols: u32::from(session.cols), - rows: u32::from(session.rows), - created_at: terminal_u128_to_u64(session.created_at), - updated_at: terminal_u128_to_u64(session.updated_at), - finished_at: session - .finished_at - .map(terminal_u128_to_u64) - .unwrap_or_default(), - exit_code: session.exit_code.unwrap_or_default(), - running: session.running, - kind: if session.kind.trim() == "ssh" { - "ssh".to_string() - } else { - "local".to_string() - }, - ssh: session.ssh.map(|ssh| proto::TerminalSshMetadata { - host_id: ssh.host_id, - host_name: ssh.host_name, - username: ssh.username, - host: ssh.host, - port: u32::from(ssh.port), - auth_type: ssh.auth_type, - status: ssh.status, - reconnect_attempt: u32::from(ssh.reconnect_attempt), - reconnect_max_attempts: u32::from(ssh.reconnect_max_attempts), - sftp_enabled: ssh.sftp_enabled, - }), - } -} - -fn terminal_shell_option_to_proto(option: TerminalShellOption) -> proto::TerminalShellOption { - proto::TerminalShellOption { - id: option.id, - label: option.label, - command: option.command, - } -} - -fn terminal_list_response_to_proto( - action: String, - sessions: Vec, -) -> proto::TerminalResponse { - proto::TerminalResponse { - action, - sessions: sessions - .into_iter() - .map(terminal_session_to_proto) - .collect(), - session: None, - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - } -} - -fn terminal_record_response_to_proto( - action: String, - session: TerminalSessionRecord, -) -> proto::TerminalResponse { - proto::TerminalResponse { - action, - sessions: Vec::new(), - session: Some(terminal_session_to_proto(session)), - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - } -} - -fn terminal_create_snapshot_response_to_proto( - action: String, - snapshot: TerminalSnapshotResponse, -) -> proto::TerminalResponse { - proto::TerminalResponse { - action, - sessions: Vec::new(), - session: Some(terminal_session_to_proto(snapshot.session)), - output: snapshot.output_bytes, - truncated: snapshot.truncated, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: snapshot.output_start_offset, - output_end_offset: snapshot.output_end_offset, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: None, - } -} - -fn terminal_ssh_create_response_to_proto( - action: String, - response: TerminalSshCreateResponse, -) -> proto::TerminalResponse { - proto::TerminalResponse { - action, - sessions: Vec::new(), - session: response.session.map(terminal_session_to_proto), - output: response.output_bytes, - truncated: response.truncated, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: response.output_start_offset, - output_end_offset: response.output_end_offset, - ssh_prompt: response.ssh_prompt.map(|prompt| proto::TerminalSshPrompt { - id: prompt.id, - kind: prompt.kind, - host_id: prompt.host_id, - host_name: prompt.host_name, - host: prompt.host, - port: u32::from(prompt.port), - message: prompt.message, - fingerprint_sha256: prompt.fingerprint_sha256, - key_type: prompt.key_type, - answer_echo: prompt.answer_echo, - }), - latency_ms: 0, - ssh_tabs: None, - } -} - -fn terminal_ssh_tabs_response_to_proto( - action: String, - snapshot: SshTerminalTabsSnapshot, -) -> proto::TerminalResponse { - proto::TerminalResponse { - action, - sessions: Vec::new(), - session: None, - output: Vec::new(), - truncated: false, - shell_options: Vec::new(), - default_shell: String::new(), - output_start_offset: 0, - output_end_offset: 0, - ssh_prompt: None, - latency_ms: 0, - ssh_tabs: Some(ssh_terminal_tabs_to_proto(snapshot)), - } -} - -fn terminal_stream_snapshot_to_proto( - stream_id: String, - snapshot: TerminalStreamSnapshotResponse, -) -> proto::TerminalStreamFrame { - let project_path_key = normalize_project_path_key(&snapshot.session.project_path_key); - let session_id = snapshot.session.id.clone(); - proto::TerminalStreamFrame { - kind: "snapshot".to_string(), - stream_id, - session_id, - project_path_key, - seq: 0, - start_offset: snapshot.output_start_offset, - end_offset: snapshot.output_end_offset, - cols: u32::from(snapshot.session.cols), - rows: u32::from(snapshot.session.rows), - max_bytes: 0, - truncated: snapshot.truncated, - error: String::new(), - session: Some(terminal_session_to_proto(snapshot.session)), - data: snapshot.bytes, - } -} - -fn build_terminal_stream_output_frame( - payload: TerminalStreamEventPayload, -) -> proto::TerminalStreamFrame { - proto::TerminalStreamFrame { - kind: payload.kind, - stream_id: String::new(), - session_id: payload.session_id, - project_path_key: normalize_project_path_key(&payload.project_path_key), - seq: 0, - start_offset: payload.start_offset, - end_offset: payload.end_offset, - cols: 0, - rows: 0, - max_bytes: 0, - truncated: false, - error: String::new(), - session: None, - data: payload.bytes, - } -} - -fn terminal_stream_error_frame( - stream_id: String, - session_id: String, - project_path_key: String, - error: String, -) -> proto::TerminalStreamFrame { - proto::TerminalStreamFrame { - kind: "error".to_string(), - stream_id, - session_id, - project_path_key: normalize_project_path_key(&project_path_key), - seq: 0, - start_offset: 0, - end_offset: 0, - cols: 0, - rows: 0, - max_bytes: 0, - truncated: false, - error, - session: None, - data: Vec::new(), - } -} - -fn ssh_terminal_tab_to_proto(tab: SshTerminalTabRecord) -> proto::TerminalSshTab { - proto::TerminalSshTab { - id: tab.id, - session_id: tab.session_id, - project_path_key: normalize_project_path_key(&tab.project_path_key), - kind: tab.kind, - created_at: tab.created_at as u64, - updated_at: tab.updated_at as u64, - } -} - -fn ssh_terminal_tabs_to_proto(snapshot: SshTerminalTabsSnapshot) -> proto::TerminalSshTabsSnapshot { - proto::TerminalSshTabsSnapshot { - project_path_key: normalize_project_path_key(&snapshot.project_path_key), - tabs: snapshot - .tabs - .into_iter() - .map(ssh_terminal_tab_to_proto) - .collect(), - revision: snapshot.revision, - } -} - -fn sftp_side_path(side: &str, local_path: &str, remote_path: &str) -> String { - if side.trim().eq_ignore_ascii_case("local") { - local_path.trim().to_string() - } else { - remote_path.trim().to_string() - } -} - -fn sftp_entry_to_proto(entry: SftpEntry) -> proto::SftpEntry { - proto::SftpEntry { - path: entry.path, - name: entry.name, - kind: entry.kind, - size_bytes: entry.size_bytes, - mtime: entry.mtime, - } -} - -fn sftp_transfer_to_proto(transfer: SftpTransferState) -> proto::SftpTransfer { - proto::SftpTransfer { - id: transfer.id, - session_id: transfer.session_id, - direction: transfer.direction, - status: transfer.status, - source_path: transfer.source_path, - target_path: transfer.target_path, - current_path: transfer.current_path, - bytes_done: transfer.bytes_done, - bytes_total: transfer.bytes_total, - files_done: transfer.files_done, - files_total: transfer.files_total, - error: transfer.error.unwrap_or_default(), - } -} - -fn sftp_list_response_to_proto(action: String, response: SftpListResponse) -> proto::SftpResponse { - proto::SftpResponse { - action, - path: response.path, - entries: response - .entries - .into_iter() - .map(sftp_entry_to_proto) - .collect(), - entry: None, - exists: false, - transfer: None, - } -} - -fn sftp_stat_response_to_proto(action: String, response: SftpStatResponse) -> proto::SftpResponse { - proto::SftpResponse { - action, - path: response - .entry - .as_ref() - .map(|entry| entry.path.clone()) - .unwrap_or_default(), - entries: Vec::new(), - entry: response.entry.map(sftp_entry_to_proto), - exists: response.exists, - transfer: None, - } -} - -fn sftp_action_response_to_proto( - action: String, - response: SftpActionResponse, -) -> proto::SftpResponse { - proto::SftpResponse { - action, - path: response.path, - entries: Vec::new(), - entry: response.entry.map(sftp_entry_to_proto), - exists: false, - transfer: response.transfer.map(sftp_transfer_to_proto), - } -} - -fn sftp_transfer_response_to_proto( - action: String, - response: SftpTransferResponse, -) -> proto::SftpResponse { - proto::SftpResponse { - action, - path: response.transfer.target_path.clone(), - entries: Vec::new(), - entry: None, - exists: false, - transfer: Some(sftp_transfer_to_proto(response.transfer)), - } -} - -fn build_terminal_event_envelope(payload: TerminalEventPayload) -> proto::AgentEnvelope { - proto::AgentEnvelope { - request_id: format!("terminal-event-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::TerminalEvent( - proto::TerminalEvent { - kind: payload.kind, - session_id: payload.session_id, - project_path_key: normalize_project_path_key(&payload.project_path_key), - session: payload.session.map(terminal_session_to_proto), - data: payload.data.unwrap_or_default(), - output_start_offset: payload.output_start_offset.unwrap_or_default(), - output_end_offset: payload.output_end_offset.unwrap_or_default(), - ssh_tabs: payload.ssh_tabs.map(ssh_terminal_tabs_to_proto), - }, - )), - } -} - -fn build_sftp_event_envelope(payload: SftpEventPayload) -> proto::AgentEnvelope { - proto::AgentEnvelope { - request_id: format!("sftp-event-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SftpEvent( - proto::SftpEvent { - kind: payload.kind, - transfer: Some(sftp_transfer_to_proto(payload.transfer)), - }, - )), - } -} - -async fn send_agent_envelope_to( - sender: mpsc::Sender, - envelope: proto::AgentEnvelope, -) -> Result<(), String> { - sender - .send(envelope) - .await - .map_err(|_| "gateway outbound stream closed".to_string()) -} - -fn build_error_response_envelope( - request_id: String, - code: i32, - message: String, -) -> proto::AgentEnvelope { - proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::Error( - proto::ErrorResponse { code, message }, - )), - } -} - -fn history_share_resolve_error_code(message: &str) -> i32 { - let normalized = message.trim(); - if normalized.is_empty() { - return 500; - } - if normalized.contains("分享 token 不能为空") { - return 400; - } - if normalized.contains("分享链接不存在或已关闭") || normalized.contains("未找到对应的历史对话") - { - return 404; - } - 500 -} - -fn build_settings_sync_envelope(payload: Value) -> Result { - Ok(proto::AgentEnvelope { - request_id: format!("settings-sync-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::SettingsSync( - proto::SettingsSyncEvent { - settings_json: serialize_settings_sync_payload(&payload)?, - }, - )), - }) -} - -fn normalize_settings_sync_payload(payload: Value) -> Result { - match payload { - Value::Null => Ok(Value::Object(serde_json::Map::new())), - Value::Object(_) => Ok(payload), - _ => Err("gateway settings sync payload must be an object".to_string()), - } -} - -fn build_local_settings_update_event_payload(payload: Value) -> Result { - let mut event = match payload { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - let provider_api_key_updates = event.remove(PROVIDER_API_KEY_UPDATES_FIELD); - let ssh_secret_updates = event.remove(SSH_SECRET_UPDATES_FIELD); - event.remove("remote"); - let mut public_event = match redact_gateway_settings_sync_payload(Value::Object(event))? { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - if let Some(updates) = provider_api_key_updates { - public_event.insert(PROVIDER_API_KEY_UPDATES_FIELD.to_string(), updates); - } - if let Some(updates) = ssh_secret_updates { - public_event.insert(SSH_SECRET_UPDATES_FIELD.to_string(), updates); - } - Ok(Value::Object(public_event)) -} - -fn build_local_settings_update_event_payload_with_ssh( - payload: Value, - ssh: Value, -) -> Result { - let mut event = match payload { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - let provider_api_key_updates = event.remove(PROVIDER_API_KEY_UPDATES_FIELD); - let ssh_secret_updates = event.remove(SSH_SECRET_UPDATES_FIELD); - event.remove("remote"); - event.remove(SSH_PATCH_FIELD); - event.insert("ssh".to_string(), ssh); - let mut public_event = match redact_gateway_settings_sync_payload(Value::Object(event))? { - Value::Object(map) => map, - _ => return Err("gateway settings sync payload must be an object".to_string()), - }; - if let Some(updates) = provider_api_key_updates { - public_event.insert(PROVIDER_API_KEY_UPDATES_FIELD.to_string(), updates); - } - if let Some(updates) = ssh_secret_updates { - public_event.insert(SSH_SECRET_UPDATES_FIELD.to_string(), updates); - } - Ok(Value::Object(public_event)) -} - -fn parse_settings_sync_payload(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Ok(Value::Object(serde_json::Map::new())); - } - let payload = serde_json::from_str::(trimmed) - .map_err(|e| format!("parse gateway settings sync payload failed: {e}"))?; - normalize_settings_sync_payload(payload) -} - -fn serialize_settings_sync_payload(payload: &Value) -> Result { - serde_json::to_string(payload) - .map_err(|e| format!("serialize gateway settings sync payload failed: {e}")) -} - -#[cfg(test)] -mod tests { - use super::{ - build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, - build_gateway_runtime_status_envelope, build_grpc_url, - build_local_settings_update_event_payload, chat_event_is_terminal, - format_gateway_terminal_stream_rpc_error, history_share_resolve_error_code, - merge_settings_sync_snapshot, merge_settings_update_into_snapshot, proto, - queue_terminal_stream_handshake_frame, required_terminal_project_path_key, - set_disconnected_status, GatewayChatRequestEvent, GatewayChatRuntimeSnapshot, - GatewayController, GatewayStatusSnapshot, RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, - GATEWAY_CHAT_RUNNING_LEASE_MS, - }; - use crate::commands::settings::RemoteSettingsPayload; - use serde_json::{json, Value}; - use std::time::{Duration, Instant}; - - fn gateway_chat_request( - request_id: &str, - client_request_id: &str, - conversation_id: &str, - message: &str, - ) -> GatewayChatRequestEvent { - GatewayChatRequestEvent { - request_id: request_id.to_string(), - conversation_id: conversation_id.to_string(), - client_request_id: client_request_id.to_string(), - message: message.to_string(), - rebased: false, - base_message_ref: None, - selected_model: None, - runtime_controls: None, - execution_mode: String::new(), - workdir: String::new(), - selected_system_tools: Vec::new(), - uploaded_files: Vec::new(), - queue_policy: String::new(), - } - } - - fn remote_chat_record( - request: GatewayChatRequestEvent, - state: &str, - started: bool, - now: Instant, - ) -> RemoteChatInboxRecord { - RemoteChatInboxRecord { - request, - state: state.to_string(), - lease_owner: Some("worker-1".to_string()), - lease_expires_at: Some(now + Duration::from_secs(30)), - attempt: 1, - started, - last_error: None, - created_at: now - Duration::from_secs(10), - updated_at: now, - } - } - - #[test] - fn gateway_chat_command_mapping_preserves_rebase_signal() { - let request = proto::ChatRequest { - conversation_id: "conversation-1".to_string(), - client_request_id: "client-1".to_string(), - message: "edited".to_string(), - execution_mode: "tools".to_string(), - workdir: "/workspace".to_string(), - selected_system_tools: vec!["http_get_test".to_string()], - ..Default::default() - }; - - let event = GatewayController::build_gateway_chat_request_event( - "run-1".to_string(), - request, - true, - Some(proto::ChatMessageRef { - segment_index: 2, - message_index: 4, - segment_id: "segment-c".to_string(), - message_id: "user-c".to_string(), - role: "user".to_string(), - content_hash: "fnv1a32:00000000".to_string(), - }), - ); - - assert_eq!(event.request_id, "run-1"); - assert_eq!(event.conversation_id, "conversation-1"); - assert_eq!(event.client_request_id, "client-1"); - assert_eq!(event.message, "edited"); - assert!(event.rebased); - let base_message_ref = event - .base_message_ref - .as_ref() - .expect("base message ref should be preserved"); - assert_eq!(base_message_ref.segment_index, 2); - assert_eq!(base_message_ref.message_index, 4); - assert_eq!(base_message_ref.segment_id, "segment-c"); - assert_eq!(base_message_ref.message_id, "user-c"); - assert_eq!(base_message_ref.role, "user"); - assert_eq!(base_message_ref.content_hash, "fnv1a32:00000000"); - assert_eq!(event.execution_mode, "tools"); - assert_eq!(event.workdir, "/workspace"); - assert_eq!(event.selected_system_tools, vec!["http_get_test"]); - } - - #[test] - fn chat_runtime_snapshot_envelope_preserves_live_projection() { - let envelope = build_chat_runtime_snapshot_envelope(GatewayChatRuntimeSnapshot { - conversation_id: " conversation-1 ".to_string(), - run_id: " run-1 ".to_string(), - client_request_id: Some("client-1".to_string()), - worker_id: Some("worker-1".to_string()), - state: " running ".to_string(), - cwd: Some("/workspace".to_string()), - updated_at: 1_772_000_000_000, - revision: 7, - entries_json: r#"[{"id":"u1","kind":"user","text":"hello","attachments":[]}]"# - .to_string(), - tool_status: Some("Thinking...".to_string()), - tool_status_is_compaction: true, - }) - .expect("runtime snapshot envelope should be valid"); - - assert_eq!(envelope.request_id, "chat-runtime-snapshot-run-1-7"); - match envelope.payload { - Some(proto::agent_envelope::Payload::ChatRuntimeSnapshot(snapshot)) => { - assert_eq!(snapshot.conversation_id, "conversation-1"); - assert_eq!(snapshot.run_id, "run-1"); - assert_eq!(snapshot.client_request_id, "client-1"); - assert_eq!(snapshot.worker_id, "worker-1"); - assert_eq!(snapshot.state, "running"); - assert_eq!(snapshot.cwd, "/workspace"); - assert_eq!(snapshot.updated_at, 1_772_000_000_000); - assert_eq!(snapshot.revision, 7); - assert!(snapshot.entries_json.contains("hello")); - assert_eq!(snapshot.tool_status, "Thinking..."); - assert!(snapshot.tool_status_is_compaction); - } - other => panic!("unexpected payload: {other:?}"), - } - } - - #[test] - fn chat_runtime_snapshot_envelope_rejects_missing_identity() { - let err = build_chat_runtime_snapshot_envelope(GatewayChatRuntimeSnapshot { - conversation_id: "conversation-1".to_string(), - run_id: " ".to_string(), - client_request_id: None, - worker_id: None, - state: "running".to_string(), - cwd: None, - updated_at: 0, - revision: 1, - entries_json: String::new(), - tool_status: None, - tool_status_is_compaction: false, - }) - .expect_err("empty run id should be rejected"); - - assert!(err.contains("run_id")); - } - - #[test] - fn remote_chat_started_records_use_running_lease() { - let now = Instant::now(); - let queued = remote_chat_record( - gateway_chat_request("request-1", "client-1", "conversation-1", "hello"), - "queued", - false, - now, - ); - let running = remote_chat_record( - gateway_chat_request("request-2", "client-2", "conversation-2", "hello"), - "running", - true, - now, - ); - - assert_eq!( - GatewayController::remote_chat_record_lease_ms(&queued), - GATEWAY_CHAT_LEASE_MS - ); - assert_eq!( - GatewayController::remote_chat_record_lease_ms(&running), - GATEWAY_CHAT_RUNNING_LEASE_MS - ); - assert!(GATEWAY_CHAT_RUNNING_LEASE_MS > GATEWAY_CHAT_LEASE_MS); - } - - #[test] - fn duplicate_remote_chat_request_preserves_running_record() { - let now = Instant::now(); - let mut record = remote_chat_record( - gateway_chat_request("request-1", "client-1", "conversation-1", "first"), - "running", - true, - now, - ); - let original_lease_owner = record.lease_owner.clone(); - let original_lease_expires_at = record.lease_expires_at; - - GatewayController::merge_duplicate_remote_chat_request( - &mut record, - gateway_chat_request("request-2", "client-1", "conversation-2", "replayed"), - now + Duration::from_secs(1), - ); - - assert_eq!(record.request.request_id, "request-1"); - assert_eq!(record.request.client_request_id, "client-1"); - assert_eq!(record.request.conversation_id, "conversation-1"); - assert_eq!(record.request.message, "first"); - assert_eq!(record.state, "running"); - assert!(record.started); - assert_eq!(record.lease_owner, original_lease_owner); - assert_eq!(record.lease_expires_at, original_lease_expires_at); - assert_eq!( - GatewayController::remote_chat_record_control_type(&record), - "started" - ); - assert!(!GatewayController::remote_chat_record_should_wake_runtime( - &record, - now + Duration::from_secs(1), - )); - } - - #[test] - fn duplicate_queued_remote_chat_request_keeps_canonical_request_id() { - let now = Instant::now(); - let mut record = remote_chat_record( - gateway_chat_request("request-1", "client-1", "conversation-1", "first"), - "queued", - false, - now, - ); - record.lease_owner = None; - record.lease_expires_at = None; - - GatewayController::merge_duplicate_remote_chat_request( - &mut record, - gateway_chat_request("request-2", "client-1", "conversation-2", "replayed"), - now + Duration::from_secs(1), - ); - - assert_eq!(record.request.request_id, "request-1"); - assert_eq!(record.request.client_request_id, "client-1"); - assert_eq!(record.request.conversation_id, "conversation-2"); - assert_eq!(record.request.message, "replayed"); - assert_eq!(record.state, "queued"); - assert!(!record.started); - assert!(GatewayController::remote_chat_record_should_wake_runtime( - &record, - now + Duration::from_secs(1), - )); - } - - #[test] - fn conversation_cancel_preserves_gui_queued_remote_requests() { - let now = Instant::now(); - let queued_in_gui = remote_chat_record( - gateway_chat_request("request-1", "client-1", "conversation-1", "first"), - "queued_in_gui", - false, - now, - ); - let queued = remote_chat_record( - gateway_chat_request("request-2", "client-2", "conversation-1", "second"), - "queued", - false, - now, - ); - let claimed = remote_chat_record( - gateway_chat_request("request-3", "client-3", "conversation-1", "third"), - "claimed", - false, - now, - ); - let running = remote_chat_record( - gateway_chat_request("request-4", "client-4", "conversation-1", "fourth"), - "running", - true, - now, - ); - - assert!( - !GatewayController::remote_chat_record_should_cancel_for_conversation(&queued_in_gui) - ); - assert!(!GatewayController::remote_chat_record_should_cancel_for_conversation(&queued)); - assert!(GatewayController::remote_chat_record_should_cancel_for_conversation(&claimed)); - assert!(GatewayController::remote_chat_record_should_cancel_for_conversation(&running)); - } - - #[test] - fn history_share_resolve_error_code_maps_public_share_failures() { - assert_eq!(history_share_resolve_error_code("分享 token 不能为空"), 400); - assert_eq!( - history_share_resolve_error_code("分享链接不存在或已关闭"), - 404 - ); - assert_eq!( - history_share_resolve_error_code("未找到对应的历史对话"), - 404 - ); - assert_eq!( - history_share_resolve_error_code("读取历史对话分享链接失败:db"), - 500 - ); - } - - #[test] - fn merge_settings_sync_snapshot_keeps_cached_ui_only_fields() { - let db_snapshot = json!({ - "system": { "executionMode": "agent-dev" }, - "cron": [{ "id": "cron-a" }], - "theme": "light", - "locale": "zh-CN", - "skills": {}, - "chatRuntimeControls": { - "thinkingEnabled": true, - "nativeWebSearchEnabled": true, - "reasoning": "high" - }, - "customSettings": {}, - "selectedModel": null, - }); - let cached_snapshot = json!({ - "theme": "dark", - "locale": "en-US", - "skills": { "enabled": true }, - "chatRuntimeControls": { - "thinkingEnabled": false, - "nativeWebSearchEnabled": false, - "reasoning": "xhigh" - }, - "customSettings": { - "conversationTitleModel": { - "customProviderId": "provider-a", - "model": "gpt-5-mini" - } - }, - "selectedModel": { - "customProviderId": "provider-a", - "model": "gpt-5.4" - }, - }); - - let merged = merge_settings_sync_snapshot(db_snapshot, Some(&cached_snapshot)) - .expect("merge settings sync snapshot"); - - assert_eq!(merged["cron"], json!([{ "id": "cron-a" }])); - assert_eq!(merged["theme"], json!("dark")); - assert_eq!(merged["locale"], json!("en-US")); - assert_eq!(merged["skills"], json!({ "enabled": true })); - assert_eq!( - merged["chatRuntimeControls"], - json!({ - "thinkingEnabled": false, - "nativeWebSearchEnabled": false, - "reasoning": "xhigh" - }) - ); - assert_eq!( - merged["customSettings"], - json!({ - "conversationTitleModel": { - "customProviderId": "provider-a", - "model": "gpt-5-mini" - } - }) - ); - assert_eq!( - merged["selectedModel"], - json!({ - "customProviderId": "provider-a", - "model": "gpt-5.4" - }) - ); - } - - #[test] - fn merge_settings_sync_snapshot_without_cache_leaves_ui_only_fields_absent() { - let db_snapshot = json!({ - "system": { "executionMode": "agent-dev" }, - "cron": [{ "id": "cron-a" }], - }); - - let merged = - merge_settings_sync_snapshot(db_snapshot, None).expect("merge settings sync snapshot"); - - let merged_map = merged.as_object().expect("merged snapshot object"); - assert!(!merged_map.contains_key("theme")); - assert!(!merged_map.contains_key("locale")); - assert!(!merged_map.contains_key("selectedModel")); - assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); - } - - #[test] - fn merge_settings_update_into_snapshot_keeps_unrelated_fields() { - let full_snapshot = json!({ - "system": { "executionMode": "agent-dev" }, - "theme": "dark", - "locale": "en-US", - "selectedModel": { - "customProviderId": "provider-a", - "model": "gpt-5.4" - }, - "remote": { "enableWebTerminal": true }, - }); - let partial_update = json!({ - "theme": "system", - "remote": { "enableWebTerminal": false }, - }); - - let merged = merge_settings_update_into_snapshot(full_snapshot, partial_update) - .expect("merge settings update into snapshot"); - - assert_eq!(merged["theme"], json!("system")); - assert_eq!(merged["locale"], json!("en-US")); - assert_eq!( - merged["selectedModel"], - json!({ - "customProviderId": "provider-a", - "model": "gpt-5.4" - }) - ); - assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); - // Remote settings are desktop-owned and must not be overwritten by clients. - assert_eq!(merged["remote"], json!({ "enableWebTerminal": true })); - } - - #[test] - fn local_settings_update_event_keeps_private_api_key_updates_only_at_root() { - let payload = json!({ - "customProviders": [ - { - "id": "provider-a", - "name": "A", - "apiKey": "leaked-key" - } - ], - "remote": { - "enableWebTerminal": true - }, - "providerApiKeyUpdates": { - "provider-a": "new-key" - } - }); - - let event_payload = - build_local_settings_update_event_payload(payload).expect("build event payload"); - assert_eq!(event_payload.get("remote"), None); - assert_eq!(event_payload["customProviders"][0]["apiKey"], Value::Null); - assert_eq!( - event_payload["customProviders"][0]["apiKeyConfigured"], - true - ); - assert_eq!( - event_payload["providerApiKeyUpdates"]["provider-a"], - "new-key" - ); - } - - #[test] - fn terminal_project_path_key_is_required_for_gateway_requests() { - assert_eq!( - required_terminal_project_path_key(" /workspace/project ").as_deref(), - Ok("/workspace/project") - ); - assert_eq!( - required_terminal_project_path_key(r" C:\Repo\ ").as_deref(), - Ok("c:/repo") - ); - assert!(required_terminal_project_path_key(" ").is_err()); - } - - #[test] - fn set_disconnected_status_resets_runtime_fields_for_new_config() { - let config = RemoteSettingsPayload { - enabled: true, - gateway_url: "https://gateway.example.com".to_string(), - grpc_port: 50051, - grpc_endpoint: String::new(), - token: "dev-token".to_string(), - agent_id: "agent-new".to_string(), - auto_reconnect: true, - heartbeat_interval: 30, - enable_web_terminal: false, - enable_web_ssh_terminal: false, - enable_web_git: false, - enable_web_tunnels: false, - }; - let mut status = GatewayStatusSnapshot { - online: true, - enabled: true, - configured: true, - gateway_url: "https://old-gateway.example.com".to_string(), - agent_id: "agent-old".to_string(), - session_id: Some("session-123".to_string()), - connected_since: Some(123), - last_heartbeat: Some(456), - last_error: Some("previous error".to_string()), - }; - - set_disconnected_status( - &mut status, - &config, - Some("connect gateway failed".to_string()), - ); - - assert!(!status.online); - assert!(status.enabled); - assert!(status.configured); - assert_eq!(status.gateway_url, "https://gateway.example.com"); - assert_eq!(status.agent_id, "agent-new"); - assert_eq!(status.session_id, None); - assert_eq!(status.connected_since, None); - assert_eq!(status.last_heartbeat, None); - assert_eq!(status.last_error.as_deref(), Some("connect gateway failed")); - } - - #[test] - fn build_https_gateway_endpoint_initializes_tls_provider() { - build_endpoint("https://agent.cnweb.org:443", Duration::from_secs(30)) - .expect("build https gateway endpoint"); - } - - #[test] - fn build_grpc_url_prefers_explicit_endpoint() { - let config = RemoteSettingsPayload { - enabled: true, - gateway_url: "https://gateway.example.com".to_string(), - grpc_port: 50051, - grpc_endpoint: "tcp.proxy.rlwy.net:12345".to_string(), - token: "dev-token".to_string(), - agent_id: "agent".to_string(), - auto_reconnect: true, - heartbeat_interval: 30, - enable_web_terminal: false, - enable_web_ssh_terminal: false, - enable_web_git: false, - enable_web_tunnels: false, - }; - - let grpc_url = build_grpc_url(&config).expect("build explicit gRPC endpoint"); - - assert_eq!(grpc_url, "http://tcp.proxy.rlwy.net:12345"); - } - - #[test] - fn terminal_stream_handshake_frame_is_gateway_noop() { - let (sender, mut receiver) = tokio::sync::mpsc::channel::(1); - - queue_terminal_stream_handshake_frame(&sender).expect("queue terminal stream handshake"); - - let frame = receiver - .try_recv() - .expect("terminal stream handshake frame"); - assert_eq!(frame.kind, "detach"); - assert!(frame.stream_id.starts_with("desktop-handshake-")); - assert!(frame.session_id.is_empty()); - } - - #[test] - fn terminal_stream_h2_error_points_to_grpc_endpoint() { - let config = RemoteSettingsPayload { - enabled: true, - gateway_url: "https://gateway.example.com".to_string(), - grpc_port: 443, - grpc_endpoint: String::new(), - token: "dev-token".to_string(), - agent_id: "agent".to_string(), - auto_reconnect: true, - heartbeat_interval: 30, - enable_web_terminal: true, - enable_web_ssh_terminal: true, - enable_web_git: false, - enable_web_tunnels: false, - }; - - let message = format_gateway_terminal_stream_rpc_error( - "receive", - &tonic::Status::internal("h2 protocol error: http2 error"), - &config, - ); - - assert!(message.contains("receive failed")); - assert!(message.contains("HTTP/2 bidi streams")); - assert!(message.contains("https://gateway.example.com")); - } - - #[test] - fn build_chat_event_envelope_preserves_tool_result_arguments() { - let envelope = build_chat_event_envelope( - "request-1".to_string(), - json!({ - "type": "tool_result", - "conversation_id": "conversation-1", - "id": "bash-call", - "name": "Bash", - "arguments": { - "command": "printf live", - "cwd": "crates/agent-gateway" - }, - "content": [{ "type": "text", "text": "live" }], - "isError": false, - "round": 1 - }), - ) - .expect("build chat event envelope"); - - let chat_event = match envelope.payload.expect("payload") { - super::proto::agent_envelope::Payload::ChatEvent(event) => event, - _ => panic!("expected chat event payload"), - }; - assert_eq!(chat_event.conversation_id, "conversation-1"); - assert_eq!( - chat_event.r#type, - super::proto::chat_event::ChatEventType::ToolResult as i32 - ); - - let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); - assert_eq!(data["arguments"]["command"], "printf live"); - assert_eq!(data["arguments"]["cwd"], "crates/agent-gateway"); - } - - #[test] - fn build_chat_event_envelope_preserves_title_final_flag() { - let envelope = build_chat_event_envelope( - "request-1".to_string(), - json!({ - "type": "token", - "conversation_id": "conversation-1", - "text": "", - "title": "Final title", - "titleFinal": true - }), - ) - .expect("build chat title event envelope"); - - let chat_event = match envelope.payload.expect("payload") { - super::proto::agent_envelope::Payload::ChatEvent(event) => event, - _ => panic!("expected chat event payload"), - }; - assert_eq!(chat_event.conversation_id, "conversation-1"); - assert_eq!( - chat_event.r#type, - super::proto::chat_event::ChatEventType::Token as i32 - ); - - let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); - assert_eq!(data["title"], "Final title"); - assert_eq!(data["titleFinal"], true); - } - - #[test] - fn build_chat_event_envelope_preserves_hosted_search_payload() { - let envelope = build_chat_event_envelope( - "request-1".to_string(), - json!({ - "type": "hosted_search", - "conversation_id": "conversation-1", - "id": "search-1", - "provider": "codex", - "status": "completed", - "queries": ["设计模式定义"], - "sources": [ - { - "url": "https://example.com/pattern", - "title": "设计模式", - "sourceType": "citation" - } - ], - "updatedAt": 1234, - "round": 2 - }), - ) - .expect("build hosted search event envelope"); - - let chat_event = match envelope.payload.expect("payload") { - super::proto::agent_envelope::Payload::ChatEvent(event) => event, - _ => panic!("expected chat event payload"), - }; - assert_eq!(chat_event.conversation_id, "conversation-1"); - assert_eq!( - chat_event.r#type, - super::proto::chat_event::ChatEventType::HostedSearch as i32 - ); - - let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); - assert_eq!(data["id"], "search-1"); - assert_eq!(data["provider"], "codex"); - assert_eq!(data["status"], "completed"); - assert_eq!(data["queries"][0], "设计模式定义"); - assert_eq!(data["sources"][0]["url"], "https://example.com/pattern"); - assert_eq!(data["updatedAt"], 1234); - assert_eq!(data["round"], 2); - } - - #[test] - fn build_chat_event_envelope_preserves_user_message_payload() { - let envelope = build_chat_event_envelope( - "request-1".to_string(), - json!({ - "type": "user_message", - "conversation_id": "conversation-1", - "message": "queued prompt", - "uploaded_files": [ - { - "relativePath": "notes.md", - "absolutePath": "/workspace/notes.md", - "fileName": "notes.md", - "kind": "text", - "sizeBytes": 12 - } - ], - "execution_mode": "agent" - }), - ) - .expect("build user message event envelope"); - - let chat_event = match envelope.payload.expect("payload") { - super::proto::agent_envelope::Payload::ChatEvent(event) => event, - _ => panic!("expected chat event payload"), - }; - assert_eq!(chat_event.conversation_id, "conversation-1"); - assert_eq!( - chat_event.r#type, - super::proto::chat_event::ChatEventType::UserMessage as i32 - ); - - let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); - assert_eq!(data["message"], "queued prompt"); - assert_eq!(data["uploaded_files"][0]["relativePath"], "notes.md"); - assert_eq!(data["uploaded_files"][0]["kind"], "text"); - assert_eq!(data["execution_mode"], "agent"); - } - - #[test] - fn chat_event_terminal_detection_covers_done_and_error_only() { - assert!(chat_event_is_terminal(&json!({ "type": "done" }))); - assert!(chat_event_is_terminal( - &json!({ "type": "error", "message": "boom" }) - )); - assert!(chat_event_is_terminal(&json!({ "type": " done " }))); - assert!(!chat_event_is_terminal( - &json!({ "type": "token", "text": "hi" }) - )); - assert!(!chat_event_is_terminal(&json!({ "type": "tool_call" }))); - assert!(!chat_event_is_terminal(&json!({ "kind": "done" }))); - assert!(!chat_event_is_terminal(&json!("done"))); - } - - #[test] - fn runtime_status_envelope_carries_run_reports() { - let active_run = proto::ChatRunReport { - run_id: "run-1".to_string(), - conversation_id: "conversation-1".to_string(), - state: "running".to_string(), - error_code: String::new(), - message: String::new(), - updated_at: 1_772_000_000_000, - }; - let finished_run = proto::ChatRunReport { - run_id: "run-2".to_string(), - conversation_id: "conversation-2".to_string(), - state: "failed".to_string(), - error_code: "desktop_run_lost".to_string(), - message: "The desktop runtime stopped reporting this run.".to_string(), - updated_at: 1_772_000_000_500, - }; - - let envelope = build_gateway_runtime_status_envelope( - "worker-1".to_string(), - "busy".to_string(), - true, - 2, - vec![active_run], - vec![finished_run], - ); - - let status = match envelope.payload.expect("payload") { - super::proto::agent_envelope::Payload::RuntimeStatus(status) => status, - _ => panic!("expected runtime status payload"), - }; - assert_eq!(status.worker_id, "worker-1"); - assert_eq!(status.state, "busy"); - assert!(status.visible); - assert_eq!(status.active_run_count, 2); - assert_eq!(status.active_runs.len(), 1); - assert_eq!(status.active_runs[0].run_id, "run-1"); - assert_eq!(status.active_runs[0].state, "running"); - assert_eq!(status.active_runs[0].updated_at, 1_772_000_000_000); - assert_eq!(status.finished_runs.len(), 1); - assert_eq!(status.finished_runs[0].run_id, "run-2"); - assert_eq!(status.finished_runs[0].state, "failed"); - assert_eq!(status.finished_runs[0].error_code, "desktop_run_lost"); - assert_eq!( - status.finished_runs[0].message, - "The desktop runtime stopped reporting this run." - ); - } -} - -fn chat_event_type(event: &Value) -> Option<&str> { - event.get("type").and_then(Value::as_str).map(str::trim) -} - -fn chat_event_is_terminal(event: &Value) -> bool { - matches!(chat_event_type(event), Some("done") | Some("error")) -} - -fn chat_event_conversation_id(event: &Value) -> String { - event - .as_object() - .and_then(|object| { - optional_string_field(object, "conversation_id") - .or_else(|| optional_string_field(object, "conversationId")) - }) - .unwrap_or_default() -} - -fn build_chat_event_envelope( - request_id: String, - event: Value, -) -> Result { - let object = event - .as_object() - .ok_or_else(|| "gateway chat event payload must be an object".to_string())?; - let event_type = string_field(object, "type")?; - let conversation_id = optional_string_field(object, "conversation_id") - .or_else(|| optional_string_field(object, "conversationId")) - .unwrap_or_default(); - - let (event_kind, data) = match event_type.as_str() { - "token" => ( - proto::chat_event::ChatEventType::Token as i32, - json!({ - "text": required_raw_string_field(object, "text")?, - "title": optional_string_field(object, "title"), - "titleFinal": object.get("titleFinal").and_then(Value::as_bool).unwrap_or(false), - "round": optional_number_field(object, "round"), - "provider": optional_string_field(object, "provider"), - "model": optional_string_field(object, "model"), - "api": optional_string_field(object, "api"), - "stopReason": optional_string_field(object, "stopReason") - .or_else(|| optional_string_field(object, "stop_reason")), - "usage": object.get("usage").cloned().unwrap_or(Value::Null), - "checkpoint": object.get("checkpoint").cloned().unwrap_or(Value::Null), - }), - ), - "thinking" => ( - proto::chat_event::ChatEventType::Thinking as i32, - json!({ - "text": required_raw_string_field(object, "text")?, - "round": optional_number_field(object, "round"), - }), - ), - "tool_call" | "tool_call_delta" => ( - proto::chat_event::ChatEventType::ToolCall as i32, - json!({ - "type": event_type, - "id": optional_string_field(object, "id"), - "name": optional_string_field(object, "name"), - "arguments": object.get("arguments").cloned().unwrap_or(Value::Null), - "round": optional_number_field(object, "round"), - }), - ), - "tool_result" => ( - proto::chat_event::ChatEventType::ToolResult as i32, - json!({ - "id": optional_string_field(object, "id"), - "name": optional_string_field(object, "name"), - "arguments": object.get("arguments").cloned().unwrap_or(Value::Null), - "content": object.get("content").cloned().unwrap_or(Value::Null), - "details": object.get("details").cloned().unwrap_or(Value::Null), - "isError": object.get("isError").and_then(Value::as_bool).unwrap_or(false), - "round": optional_number_field(object, "round"), - }), - ), - "hosted_search" => ( - proto::chat_event::ChatEventType::HostedSearch as i32, - json!({ - "id": optional_string_field(object, "id"), - "provider": optional_string_field(object, "provider"), - "status": optional_string_field(object, "status"), - "queries": object.get("queries").cloned().unwrap_or(Value::Null), - "sources": object.get("sources").cloned().unwrap_or(Value::Null), - "updatedAt": object.get("updatedAt").cloned().unwrap_or(Value::Null), - "round": optional_number_field(object, "round"), - }), - ), - "user_message" => ( - proto::chat_event::ChatEventType::UserMessage as i32, - json!({ - "message": required_raw_string_field(object, "message")?, - "uploaded_files": object.get("uploaded_files") - .or_else(|| object.get("uploadedFiles")) - .cloned() - .unwrap_or(Value::Null), - "execution_mode": optional_string_field(object, "execution_mode") - .or_else(|| optional_string_field(object, "executionMode")), - "workdir": optional_string_field(object, "workdir"), - "selected_system_tools": object.get("selected_system_tools") - .or_else(|| object.get("selectedSystemTools")) - .cloned() - .unwrap_or(Value::Null), - "runtime_controls": object.get("runtime_controls") - .or_else(|| object.get("runtimeControls")) - .cloned() - .unwrap_or(Value::Null), - "selected_model": object.get("selected_model") - .or_else(|| object.get("selectedModel")) - .cloned() - .unwrap_or(Value::Null), - "base_message_ref": object.get("base_message_ref") - .or_else(|| object.get("baseMessageRef")) - .cloned() - .unwrap_or(Value::Null), - "reason": optional_string_field(object, "reason"), - }), - ), - "done" => ( - proto::chat_event::ChatEventType::Done as i32, - json!({ - "round": optional_number_field(object, "round"), - }), - ), - "error" => ( - proto::chat_event::ChatEventType::Error as i32, - json!({ - "message": required_string_field(object, "message")?, - "round": optional_number_field(object, "round"), - }), - ), - "tool_status" => ( - proto::chat_event::ChatEventType::ToolStatus as i32, - json!({ - "status": object.get("status").cloned().unwrap_or(Value::Null), - "isCompaction": object.get("isCompaction").and_then(Value::as_bool).unwrap_or(false), - "round": optional_number_field(object, "round"), - }), - ), - other => return Err(format!("unsupported gateway chat event type: {other}")), - }; - - Ok(proto::AgentEnvelope { - request_id, - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatEvent( - proto::ChatEvent { - r#type: event_kind, - conversation_id, - data: serde_json::to_string(&data) - .map_err(|e| format!("serialize gateway chat event failed: {e}"))?, - }, - )), - }) -} - -fn build_gateway_chat_control_event_envelope( - request_id: String, - conversation_id: String, - event_type: &str, - error_code: String, - message: String, -) -> proto::AgentEnvelope { - let state = match event_type.trim() { - "accepted" => "queued", - "delivered" => "delivered", - "claimed" => "claimed", - "starting" => "starting", - "started" => "running", - "completed" => "completed", - "failed" => "failed", - "cancelled" => "cancelled", - _ => "", - } - .to_string(); - proto::AgentEnvelope { - request_id: request_id.clone(), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatControl( - proto::ChatControlEvent { - request_id, - conversation_id, - r#type: event_type.trim().to_string(), - state, - error_code, - message, - ..Default::default() - }, - )), - } -} - -fn build_gateway_runtime_status_envelope( - worker_id: String, - state: String, - visible: bool, - active_run_count: u32, - active_runs: Vec, - finished_runs: Vec, -) -> proto::AgentEnvelope { - proto::AgentEnvelope { - request_id: format!("runtime-status-{}", worker_id.trim()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::RuntimeStatus( - proto::RuntimeStatusEvent { - worker_id, - state, - visible, - active_run_count, - timestamp: now_unix_seconds(), - active_runs, - finished_runs, - }, - )), - } -} - -fn chat_run_report_from_entry(entry: &ChatRunLedgerEntry) -> proto::ChatRunReport { - proto::ChatRunReport { - run_id: entry.run_id.clone(), - conversation_id: entry.conversation_id.clone(), - state: entry.state.as_str().to_string(), - error_code: entry.error_code.clone(), - message: entry.message.clone(), - updated_at: entry.updated_at_ms, - } -} - -fn build_history_sync_envelope( - event: GatewayHistorySyncEvent, -) -> Result { - let conversation = event - .conversation - .map(|conversation| proto::ConversationSummary { - id: conversation.id, - title: conversation.title, - created_at: conversation.created_at, - updated_at: conversation.updated_at, - message_count: i32::try_from(conversation.message_count).unwrap_or(i32::MAX), - provider_id: conversation.provider_id.unwrap_or_default(), - model: conversation.model.unwrap_or_default(), - session_id: conversation.session_id.unwrap_or_default(), - cwd: conversation.cwd.unwrap_or_default(), - is_pinned: conversation.is_pinned, - pinned_at: conversation.pinned_at.unwrap_or_default(), - is_shared: conversation.is_shared, - }); - - Ok(proto::AgentEnvelope { - request_id: format!("history-sync-{}", Uuid::new_v4()), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::HistorySync( - proto::HistorySyncEvent { - kind: event.kind, - conversation, - conversation_id: event.conversation_id, - }, - )), - }) -} - -fn build_chat_runtime_snapshot_envelope( - snapshot: GatewayChatRuntimeSnapshot, -) -> Result { - let conversation_id = snapshot.conversation_id.trim().to_string(); - if conversation_id.is_empty() { - return Err("chat runtime snapshot conversation_id is required".to_string()); - } - - let run_id = snapshot.run_id.trim().to_string(); - if run_id.is_empty() { - return Err("chat runtime snapshot run_id is required".to_string()); - } - - let state = snapshot.state.trim().to_string(); - if state.is_empty() { - return Err("chat runtime snapshot state is required".to_string()); - } - - let updated_at = if snapshot.updated_at > 0 { - snapshot.updated_at - } else { - chrono::Utc::now().timestamp_millis() - }; - - Ok(proto::AgentEnvelope { - request_id: format!("chat-runtime-snapshot-{}-{}", run_id, snapshot.revision), - timestamp: now_unix_seconds(), - payload: Some(proto::agent_envelope::Payload::ChatRuntimeSnapshot( - proto::ChatRuntimeSnapshot { - conversation_id, - run_id, - client_request_id: snapshot.client_request_id.unwrap_or_default(), - worker_id: snapshot.worker_id.unwrap_or_default(), - state, - cwd: snapshot.cwd.unwrap_or_default(), - updated_at, - revision: snapshot.revision, - entries_json: snapshot.entries_json, - tool_status: snapshot.tool_status.unwrap_or_default(), - tool_status_is_compaction: snapshot.tool_status_is_compaction, - }, - )), - }) -} - -fn build_grpc_url(config: &RemoteSettingsPayload) -> Result { - let grpc_endpoint = config.grpc_endpoint.trim(); - if !grpc_endpoint.is_empty() { - let with_scheme = - if grpc_endpoint.starts_with("http://") || grpc_endpoint.starts_with("https://") { - grpc_endpoint.to_string() - } else { - format!("http://{grpc_endpoint}") - }; - let mut url = - Url::parse(&with_scheme).map_err(|e| format!("invalid gateway gRPC endpoint: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err("gateway gRPC endpoint must start with http:// or https://".to_string()); - } - url.set_path(""); - url.set_query(None); - url.set_fragment(None); - return Ok(url.to_string().trim_end_matches('/').to_string()); - } - - let trimmed = config.gateway_url.trim(); - if trimmed.is_empty() { - return Err("gateway URL is empty".to_string()); - } - - let mut url = Url::parse(trimmed).map_err(|e| format!("invalid gateway URL: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err("gateway URL must start with http:// or https://".to_string()); - } - url.set_port(Some(config.grpc_port)) - .map_err(|_| "failed to apply gRPC port to gateway URL".to_string())?; - url.set_path(""); - url.set_query(None); - url.set_fragment(None); - Ok(url.to_string().trim_end_matches('/').to_string()) -} - -fn queue_terminal_stream_handshake_frame( - sender: &mpsc::Sender, -) -> Result<(), String> { - // Some HTTP/2 proxies do not fully establish a bidi stream until the client - // sends its first DATA frame. `detach` is a gateway no-op and is not forwarded - // to browser terminal subscribers. - sender - .try_send(terminal_stream_noop_frame("desktop-handshake")) - .map_err(|error| format!("queue gateway terminal stream handshake failed: {error}")) -} - -async fn queue_terminal_stream_keepalive_frame( - sender: &mpsc::Sender, -) -> Result<(), String> { - sender - .send(terminal_stream_noop_frame("desktop-keepalive")) - .await - .map_err(|error| format!("queue gateway terminal stream keepalive failed: {error}")) -} - -fn terminal_stream_noop_frame(prefix: &str) -> proto::TerminalStreamFrame { - proto::TerminalStreamFrame { - kind: "detach".to_string(), - stream_id: format!("{}-{}", prefix.trim(), Uuid::new_v4()), - ..Default::default() - } -} - -fn format_gateway_terminal_stream_rpc_error( - phase: &str, - error: &tonic::Status, - config: &RemoteSettingsPayload, -) -> String { - let message = error.to_string(); - if !is_h2_protocol_error(&message) { - return format!("gateway terminal stream {phase} failed: {message}"); - } - - let endpoint = build_grpc_url(config).unwrap_or_else(|_| "invalid endpoint".to_string()); - format!( - "gateway terminal stream {phase} failed: {message}. \ - The gateway terminal stream requires a gRPC endpoint that supports HTTP/2 bidi streams; \ - check Remote gRPC Endpoint / gRPC port. Current endpoint: {endpoint}" - ) -} - -fn is_h2_protocol_error(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - normalized.contains("h2 protocol error") || normalized.contains("http2 error") -} - -fn build_endpoint(grpc_url: &str, keepalive_interval: Duration) -> Result { - // HTTP/2 keepalive owns dead-link detection: PING frames bypass stream - // flow control, so congestion or streaming never delays them. - let endpoint = Endpoint::from_shared(grpc_url.to_string()) - .map_err(|e| format!("invalid gateway endpoint: {e}"))? - .connect_timeout(Duration::from_secs(10)) - .tcp_keepalive(Some(Duration::from_secs(30))) - .http2_keep_alive_interval(keepalive_interval) - .keep_alive_timeout(Duration::from_secs(15)) - .keep_alive_while_idle(true); - - if grpc_url.starts_with("https://") { - ensure_rustls_crypto_provider(); - let host = Url::parse(grpc_url) - .ok() - .and_then(|url| url.host_str().map(ToString::to_string)) - .ok_or_else(|| "failed to extract TLS host from gateway URL".to_string())?; - endpoint - .tls_config( - ClientTlsConfig::new() - .with_enabled_roots() - .domain_name(host), - ) - .map_err(|e| format!("configure gateway TLS failed: {e}")) - } else { - Ok(endpoint) - } -} - -fn ensure_rustls_crypto_provider() { - static INSTALL_DEFAULT_PROVIDER: Once = Once::new(); - INSTALL_DEFAULT_PROVIDER.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); -} - -fn insert_bearer_metadata( - metadata: &mut tonic::metadata::MetadataMap, - token: &str, -) -> Result<(), String> { - let value = MetadataValue::try_from(format!("Bearer {}", token.trim())) - .map_err(|e| format!("invalid gateway authorization metadata: {e}"))?; - metadata.insert("authorization", value); - Ok(()) -} - -fn is_remote_configured(config: &RemoteSettingsPayload) -> bool { - !config.gateway_url.trim().is_empty() && !config.token.trim().is_empty() -} - -fn effective_agent_id(config: &RemoteSettingsPayload) -> String { - if !config.agent_id.trim().is_empty() { - return config.agent_id.trim().to_string(); - } - fallback_agent_id() -} - -fn fallback_agent_id() -> String { - std::env::var("HOSTNAME") - .ok() - .or_else(|| std::env::var("COMPUTERNAME").ok()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "liveagent-desktop".to_string()) -} - -fn set_disconnected_status( - status: &mut GatewayStatusSnapshot, - config: &RemoteSettingsPayload, - last_error: Option, -) { - status.online = false; - status.enabled = config.enabled; - status.configured = is_remote_configured(config); - status.gateway_url = config.gateway_url.clone(); - status.agent_id = effective_agent_id(config); - status.session_id = None; - status.connected_since = None; - status.last_heartbeat = None; - status.last_error = last_error; -} - -pub(crate) fn now_unix_seconds() -> i64 { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)); - i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) -} - -pub(crate) fn now_unix_millis() -> i64 { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_else(|_| Duration::from_secs(0)); - i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) -} - -fn chat_run_ledger_now() -> (Instant, i64) { - (Instant::now(), now_unix_millis()) -} - -fn string_field(object: &serde_json::Map, key: &str) -> Result { - object - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) - .ok_or_else(|| format!("gateway chat event {key} is required")) -} - -fn required_string_field( - object: &serde_json::Map, - key: &str, -) -> Result { - string_field(object, key) -} - -fn required_raw_string_field( - object: &serde_json::Map, - key: &str, -) -> Result { - object - .get(key) - .and_then(Value::as_str) - .map(ToString::to_string) - .ok_or_else(|| format!("gateway chat event {key} is required")) -} - -fn optional_string_field(object: &serde_json::Map, key: &str) -> Option { - object - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn optional_number_field(object: &serde_json::Map, key: &str) -> Option { - object.get(key).and_then(Value::as_i64) -} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs new file mode 100644 index 00000000..52e81949 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat.rs @@ -0,0 +1,655 @@ +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tauri::Emitter; +use tokio::sync::oneshot; +use uuid::Uuid; + +use crate::services::chat_run_ledger::ChatRunLedgerEntry; + +use super::*; + +impl GatewayController { + pub(crate) async fn handle_chat_command( + self: &Arc, + request_id: String, + command: proto::ChatCommandRequest, + ) -> Result<(), String> { + match command.r#type.trim() { + "chat.submit" => { + let Some(request) = command.request else { + return self + .send_gateway_chat_control_event_with_details( + request_id, + String::new(), + "failed", + "invalid_chat_command".to_string(), + "chat.submit requires request payload".to_string(), + ) + .await; + }; + let event_payload = + Self::build_gateway_chat_request_event(request_id, request, false, None); + self.enqueue_gateway_chat_request(event_payload).await + } + "chat.edit_resend" => { + let Some(request) = command.request else { + return self + .send_gateway_chat_control_event_with_details( + request_id, + String::new(), + "failed", + "invalid_chat_command".to_string(), + "chat.edit_resend requires request payload".to_string(), + ) + .await; + }; + let conversation_id = request.conversation_id.trim().to_string(); + let Some(base_message_ref) = command.base_message_ref else { + return self + .send_gateway_chat_control_event_with_details( + request_id, + conversation_id, + "failed", + "invalid_chat_command".to_string(), + "chat.edit_resend requires base_message_ref".to_string(), + ) + .await; + }; + if !is_complete_user_chat_message_ref(&base_message_ref) { + return self + .send_gateway_chat_control_event_with_details( + request_id, + conversation_id, + "failed", + "invalid_chat_command".to_string(), + "chat.edit_resend requires a complete stable base_message_ref" + .to_string(), + ) + .await; + } + if conversation_id.is_empty() { + return self + .send_gateway_chat_control_event_with_details( + request_id, + String::new(), + "failed", + "invalid_chat_command".to_string(), + "chat.edit_resend requires conversation_id".to_string(), + ) + .await; + } + let event_payload = Self::build_gateway_chat_request_event( + request_id, + request, + true, + Some(base_message_ref), + ); + self.enqueue_gateway_chat_request(event_payload).await + } + "chat.cancel" => { + let conversation_id = command + .cancel + .map(|cancel| cancel.conversation_id) + .or_else(|| command.request.map(|request| request.conversation_id)) + .unwrap_or_default(); + self.cancel_remote_chat_request(&request_id, &conversation_id)?; + self.send_gateway_chat_control_event( + request_id.clone(), + conversation_id.clone(), + "cancelled", + ) + .await?; + self.app_handle + .emit( + "gateway:chat-cancel", + GatewayChatCancelEvent { + request_id, + conversation_id, + }, + ) + .map_err(|e| format!("emit gateway chat cancel failed: {e}")) + } + other => { + self.send_gateway_chat_control_event_with_details( + request_id, + command + .request + .map(|request| request.conversation_id) + .unwrap_or_default(), + "failed", + "unsupported_chat_command".to_string(), + format!("unsupported chat command: {other}"), + ) + .await + } + } + } + + pub(crate) async fn enqueue_gateway_chat_request( + &self, + event_payload: GatewayChatRequestEvent, + ) -> Result<(), String> { + let enqueue_outcome = self.enqueue_remote_chat_request(event_payload)?; + if let Err(error) = self + .send_gateway_chat_control_event( + enqueue_outcome.request_id.clone(), + enqueue_outcome.conversation_id.clone(), + enqueue_outcome.control_type, + ) + .await + { + if enqueue_outcome.inserted { + self.remove_remote_chat_request(&enqueue_outcome.request_id)?; + } + return Err(error); + } + if enqueue_outcome.should_wake_runtime { + self.app_handle + .emit( + "gateway:chat-request-ready", + json!({ "requestId": enqueue_outcome.request_id }), + ) + .map_err(|e| format!("emit gateway chat request ready failed: {e}"))?; + } + Ok(()) + } + + pub(crate) fn build_gateway_chat_request_event( + request_id: String, + request: proto::ChatRequest, + rebased: bool, + base_message_ref: Option, + ) -> GatewayChatRequestEvent { + let proto::ChatRequest { + conversation_id, + client_request_id, + message, + selected_model, + runtime_controls, + execution_mode, + workdir, + selected_system_tools, + uploaded_files, + queue_policy, + } = request; + let selected_model = selected_model.map(|selected_model| GatewaySelectedModelEvent { + custom_provider_id: selected_model.custom_provider_id, + model: selected_model.model, + provider_type: selected_model.provider_type, + }); + let runtime_controls = + runtime_controls.map(|runtime_controls| GatewayChatRuntimeControlsEvent { + thinking_enabled: runtime_controls.thinking_enabled, + native_web_search_enabled: runtime_controls.native_web_search_enabled, + reasoning: runtime_controls.reasoning, + }); + let base_message_ref = + base_message_ref.map(|base_message_ref| GatewayChatMessageRefEvent { + segment_index: base_message_ref.segment_index, + message_index: base_message_ref.message_index, + segment_id: base_message_ref.segment_id, + message_id: base_message_ref.message_id, + role: base_message_ref.role, + content_hash: base_message_ref.content_hash, + }); + GatewayChatRequestEvent { + request_id, + conversation_id, + client_request_id, + message, + rebased, + base_message_ref, + selected_model, + runtime_controls, + execution_mode, + workdir, + selected_system_tools, + uploaded_files: uploaded_files + .into_iter() + .map(|file| GatewayUploadedFileEvent { + relative_path: file.relative_path, + absolute_path: file.absolute_path, + file_name: file.file_name, + kind: file.kind, + size_bytes: file.size_bytes, + }) + .collect(), + queue_policy, + } + } + + pub(crate) async fn send_gateway_chat_control_event( + &self, + request_id: String, + conversation_id: String, + event_type: &str, + ) -> Result<(), String> { + self.send_gateway_chat_control_event_with_details( + request_id, + conversation_id, + event_type, + String::new(), + String::new(), + ) + .await + } + + pub(crate) async fn send_gateway_chat_control_event_with_details( + &self, + request_id: String, + conversation_id: String, + event_type: &str, + error_code: String, + message: String, + ) -> Result<(), String> { + self.send_agent_envelope(build_gateway_chat_control_event_envelope( + request_id, + conversation_id, + event_type, + error_code, + message, + )) + .await + } + + pub(crate) async fn handle_chat_queue_request( + self: &Arc, + request_id: String, + request: proto::ChatQueueRequest, + ) -> Result<(), String> { + let event_payload = GatewayChatQueueRequestEvent { + request_id: request_id.clone(), + action: request.action, + conversation_id: request.conversation_id, + item_id: request.item_id, + direction: request.direction, + revision: request.revision, + draft_json: request.draft_json, + uploaded_files_json: request.uploaded_files_json, + request_json: request.request_json, + }; + + let (tx, rx) = oneshot::channel(); + self.pending_chat_queue_requests + .lock() + .map_err(|_| "gateway chat queue request lock poisoned".to_string())? + .insert(request_id.clone(), tx); + + if let Err(error) = self + .app_handle + .emit("gateway:chat-queue-request", event_payload) + { + let _ = self + .pending_chat_queue_requests + .lock() + .map(|mut pending| pending.remove(&request_id)); + return self + .send_chat_queue_response( + request_id, + proto::ChatQueueResponse { + accepted: false, + message: format!("emit gateway chat queue request failed: {error}"), + error_code: "emit_failed".to_string(), + ..Default::default() + }, + ) + .await; + } + + let response = match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(response)) => response, + Ok(Err(_)) => proto::ChatQueueResponse { + accepted: false, + message: "chat queue response dropped".to_string(), + error_code: "response_dropped".to_string(), + ..Default::default() + }, + Err(_) => { + let _ = self + .pending_chat_queue_requests + .lock() + .map(|mut pending| pending.remove(&request_id)); + proto::ChatQueueResponse { + accepted: false, + message: "chat queue request timed out".to_string(), + error_code: "timeout".to_string(), + ..Default::default() + } + } + }; + + self.send_chat_queue_response(request_id, response).await + } + + pub(crate) async fn send_chat_queue_response( + &self, + request_id: String, + response: proto::ChatQueueResponse, + ) -> Result<(), String> { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatQueueResp(response)), + }) + .await + } + + pub fn respond_chat_queue_request( + &self, + input: GatewayChatQueueResponseInput, + ) -> Result<(), String> { + let request_id = input.request_id.trim().to_string(); + if request_id.is_empty() { + return Err("chat queue request_id is required".to_string()); + } + let sender = self + .pending_chat_queue_requests + .lock() + .map_err(|_| "gateway chat queue request lock poisoned".to_string())? + .remove(&request_id); + if let Some(sender) = sender { + let _ = sender.send(proto::ChatQueueResponse { + accepted: input.accepted, + message: input.message, + snapshot_json: input.snapshot_json, + item_json: input.item_json, + error_code: input.error_code, + revision: input.revision, + }); + } + Ok(()) + } + + pub async fn publish_chat_queue_event( + &self, + input: GatewayChatQueueEventInput, + ) -> Result<(), String> { + self.send_agent_envelope(proto::AgentEnvelope { + request_id: format!("chat-queue-event-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatQueueEvent( + proto::ChatQueueEvent { + conversation_id: input.conversation_id, + snapshot_json: input.snapshot_json, + revision: input.revision, + }, + )), + }) + .await + } +} + +pub(crate) fn chat_event_type(event: &Value) -> Option<&str> { + event.get("type").and_then(Value::as_str).map(str::trim) +} + +pub(crate) fn chat_event_is_terminal(event: &Value) -> bool { + matches!(chat_event_type(event), Some("done") | Some("error")) +} + +pub(crate) fn chat_event_conversation_id(event: &Value) -> String { + event + .as_object() + .and_then(|object| { + optional_string_field(object, "conversation_id") + .or_else(|| optional_string_field(object, "conversationId")) + }) + .unwrap_or_default() +} + +pub(crate) fn build_chat_event_envelope( + request_id: String, + event: Value, +) -> Result { + let object = event + .as_object() + .ok_or_else(|| "gateway chat event payload must be an object".to_string())?; + let event_type = string_field(object, "type")?; + let conversation_id = optional_string_field(object, "conversation_id") + .or_else(|| optional_string_field(object, "conversationId")) + .unwrap_or_default(); + + let (event_kind, data) = match event_type.as_str() { + "token" => ( + proto::chat_event::ChatEventType::Token as i32, + json!({ + "text": required_raw_string_field(object, "text")?, + "title": optional_string_field(object, "title"), + "titleFinal": object.get("titleFinal").and_then(Value::as_bool).unwrap_or(false), + "round": optional_number_field(object, "round"), + "provider": optional_string_field(object, "provider"), + "model": optional_string_field(object, "model"), + "api": optional_string_field(object, "api"), + "stopReason": optional_string_field(object, "stopReason") + .or_else(|| optional_string_field(object, "stop_reason")), + "usage": object.get("usage").cloned().unwrap_or(Value::Null), + "checkpoint": object.get("checkpoint").cloned().unwrap_or(Value::Null), + }), + ), + "thinking" => ( + proto::chat_event::ChatEventType::Thinking as i32, + json!({ + "text": required_raw_string_field(object, "text")?, + "round": optional_number_field(object, "round"), + }), + ), + "tool_call" | "tool_call_delta" => ( + proto::chat_event::ChatEventType::ToolCall as i32, + json!({ + "type": event_type, + "id": optional_string_field(object, "id"), + "name": optional_string_field(object, "name"), + "arguments": object.get("arguments").cloned().unwrap_or(Value::Null), + "round": optional_number_field(object, "round"), + }), + ), + "tool_result" => ( + proto::chat_event::ChatEventType::ToolResult as i32, + json!({ + "id": optional_string_field(object, "id"), + "name": optional_string_field(object, "name"), + "arguments": object.get("arguments").cloned().unwrap_or(Value::Null), + "content": object.get("content").cloned().unwrap_or(Value::Null), + "details": object.get("details").cloned().unwrap_or(Value::Null), + "isError": object.get("isError").and_then(Value::as_bool).unwrap_or(false), + "round": optional_number_field(object, "round"), + }), + ), + "hosted_search" => ( + proto::chat_event::ChatEventType::HostedSearch as i32, + json!({ + "id": optional_string_field(object, "id"), + "provider": optional_string_field(object, "provider"), + "status": optional_string_field(object, "status"), + "queries": object.get("queries").cloned().unwrap_or(Value::Null), + "sources": object.get("sources").cloned().unwrap_or(Value::Null), + "updatedAt": object.get("updatedAt").cloned().unwrap_or(Value::Null), + "round": optional_number_field(object, "round"), + }), + ), + "user_message" => ( + proto::chat_event::ChatEventType::UserMessage as i32, + json!({ + "message": required_raw_string_field(object, "message")?, + "uploaded_files": object.get("uploaded_files") + .or_else(|| object.get("uploadedFiles")) + .cloned() + .unwrap_or(Value::Null), + "execution_mode": optional_string_field(object, "execution_mode") + .or_else(|| optional_string_field(object, "executionMode")), + "workdir": optional_string_field(object, "workdir"), + "selected_system_tools": object.get("selected_system_tools") + .or_else(|| object.get("selectedSystemTools")) + .cloned() + .unwrap_or(Value::Null), + "runtime_controls": object.get("runtime_controls") + .or_else(|| object.get("runtimeControls")) + .cloned() + .unwrap_or(Value::Null), + "selected_model": object.get("selected_model") + .or_else(|| object.get("selectedModel")) + .cloned() + .unwrap_or(Value::Null), + "base_message_ref": object.get("base_message_ref") + .or_else(|| object.get("baseMessageRef")) + .cloned() + .unwrap_or(Value::Null), + "reason": optional_string_field(object, "reason"), + }), + ), + "done" => ( + proto::chat_event::ChatEventType::Done as i32, + json!({ + "round": optional_number_field(object, "round"), + }), + ), + "error" => ( + proto::chat_event::ChatEventType::Error as i32, + json!({ + "message": required_string_field(object, "message")?, + "round": optional_number_field(object, "round"), + }), + ), + "tool_status" => ( + proto::chat_event::ChatEventType::ToolStatus as i32, + json!({ + "status": object.get("status").cloned().unwrap_or(Value::Null), + "isCompaction": object.get("isCompaction").and_then(Value::as_bool).unwrap_or(false), + "round": optional_number_field(object, "round"), + }), + ), + other => return Err(format!("unsupported gateway chat event type: {other}")), + }; + + Ok(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatEvent( + proto::ChatEvent { + r#type: event_kind, + conversation_id, + data: serde_json::to_string(&data) + .map_err(|e| format!("serialize gateway chat event failed: {e}"))?, + }, + )), + }) +} + +pub(crate) fn build_gateway_chat_control_event_envelope( + request_id: String, + conversation_id: String, + event_type: &str, + error_code: String, + message: String, +) -> proto::AgentEnvelope { + let state = match event_type.trim() { + "accepted" => "queued", + "delivered" => "delivered", + "claimed" => "claimed", + "starting" => "starting", + "started" => "running", + "completed" => "completed", + "failed" => "failed", + "cancelled" => "cancelled", + _ => "", + } + .to_string(); + proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatControl( + proto::ChatControlEvent { + request_id, + conversation_id, + r#type: event_type.trim().to_string(), + state, + error_code, + message, + ..Default::default() + }, + )), + } +} + +pub(crate) fn build_gateway_runtime_status_envelope( + worker_id: String, + state: String, + visible: bool, + active_run_count: u32, + active_runs: Vec, + finished_runs: Vec, +) -> proto::AgentEnvelope { + proto::AgentEnvelope { + request_id: format!("runtime-status-{}", worker_id.trim()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::RuntimeStatus( + proto::RuntimeStatusEvent { + worker_id, + state, + visible, + active_run_count, + timestamp: now_unix_seconds(), + active_runs, + finished_runs, + }, + )), + } +} + +pub(crate) fn chat_run_report_from_entry(entry: &ChatRunLedgerEntry) -> proto::ChatRunReport { + proto::ChatRunReport { + run_id: entry.run_id.clone(), + conversation_id: entry.conversation_id.clone(), + state: entry.state.as_str().to_string(), + error_code: entry.error_code.clone(), + message: entry.message.clone(), + updated_at: entry.updated_at_ms, + } +} + +pub(crate) fn build_chat_runtime_snapshot_envelope( + snapshot: GatewayChatRuntimeSnapshot, +) -> Result { + let conversation_id = snapshot.conversation_id.trim().to_string(); + if conversation_id.is_empty() { + return Err("chat runtime snapshot conversation_id is required".to_string()); + } + + let run_id = snapshot.run_id.trim().to_string(); + if run_id.is_empty() { + return Err("chat runtime snapshot run_id is required".to_string()); + } + + let state = snapshot.state.trim().to_string(); + if state.is_empty() { + return Err("chat runtime snapshot state is required".to_string()); + } + + let updated_at = if snapshot.updated_at > 0 { + snapshot.updated_at + } else { + chrono::Utc::now().timestamp_millis() + }; + + Ok(proto::AgentEnvelope { + request_id: format!("chat-runtime-snapshot-{}-{}", run_id, snapshot.revision), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ChatRuntimeSnapshot( + proto::ChatRuntimeSnapshot { + conversation_id, + run_id, + client_request_id: snapshot.client_request_id.unwrap_or_default(), + worker_id: snapshot.worker_id.unwrap_or_default(), + state, + cwd: snapshot.cwd.unwrap_or_default(), + updated_at, + revision: snapshot.revision, + entries_json: snapshot.entries_json, + tool_status: snapshot.tool_status.unwrap_or_default(), + tool_status_is_compaction: snapshot.tool_status_is_compaction, + }, + )), + }) +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs new file mode 100644 index 00000000..7bfd0a97 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/chat_inbox.rs @@ -0,0 +1,942 @@ +use std::time::{Duration, Instant}; + +use serde_json::json; +use tauri::Emitter; + +use crate::services::chat_run_ledger::{ChatRunLedger, ChatRunLedgerState}; + +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct RemoteChatInboxRecord { + pub(crate) request: GatewayChatRequestEvent, + pub(crate) state: String, + pub(crate) lease_owner: Option, + pub(crate) lease_expires_at: Option, + pub(crate) attempt: u32, + pub(crate) started: bool, + pub(crate) last_error: Option, + pub(crate) created_at: Instant, + pub(crate) updated_at: Instant, +} + +#[derive(Debug, Clone)] +pub(crate) struct RemoteChatEnqueueOutcome { + pub(crate) request_id: String, + pub(crate) conversation_id: String, + pub(crate) control_type: &'static str, + pub(crate) should_wake_runtime: bool, + pub(crate) inserted: bool, +} + +impl GatewayController { + pub(crate) fn enqueue_remote_chat_request( + &self, + request: GatewayChatRequestEvent, + ) -> Result { + let request_id = request.request_id.trim(); + if request_id.is_empty() { + return Ok(RemoteChatEnqueueOutcome { + request_id: String::new(), + conversation_id: String::new(), + control_type: "delivered", + should_wake_runtime: false, + inserted: false, + }); + } + let request_id = request_id.to_string(); + let client_request_id = request.client_request_id.trim().to_string(); + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + + let existing_request_id = if inbox.contains_key(&request_id) { + Some(request_id.clone()) + } else if client_request_id.is_empty() { + None + } else { + inbox.iter().find_map(|(candidate_request_id, record)| { + if record.request.client_request_id.trim() == client_request_id { + Some(candidate_request_id.clone()) + } else { + None + } + }) + }; + + if let Some(existing_request_id) = existing_request_id { + let now = Instant::now(); + let record = inbox + .get_mut(&existing_request_id) + .ok_or_else(|| "remote chat request disappeared while enqueueing".to_string())?; + Self::merge_duplicate_remote_chat_request(record, request, now); + return Ok(RemoteChatEnqueueOutcome { + request_id: existing_request_id, + conversation_id: record.request.conversation_id.clone(), + control_type: Self::remote_chat_record_control_type(record), + should_wake_runtime: Self::remote_chat_record_should_wake_runtime(record, now), + inserted: false, + }); + } + + let now = Instant::now(); + inbox.insert( + request_id.clone(), + RemoteChatInboxRecord { + request, + state: "queued".to_string(), + lease_owner: None, + lease_expires_at: None, + attempt: 0, + started: false, + last_error: None, + created_at: now, + updated_at: now, + }, + ); + let conversation_id = inbox + .get(request_id.as_str()) + .map(|record| record.request.conversation_id.clone()) + .unwrap_or_default(); + Ok(RemoteChatEnqueueOutcome { + request_id, + conversation_id, + control_type: "delivered", + should_wake_runtime: true, + inserted: true, + }) + } + + pub(crate) fn remove_remote_chat_request(&self, request_id: &str) -> Result<(), String> { + let request_id = request_id.trim(); + if request_id.is_empty() { + return Ok(()); + } + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + inbox.remove(request_id); + Ok(()) + } + + pub(crate) fn cancel_remote_chat_request( + &self, + request_id: &str, + conversation_id: &str, + ) -> Result<(), String> { + let request_id = request_id.trim(); + let conversation_id = conversation_id.trim(); + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + if !request_id.is_empty() { + inbox.remove(request_id); + } + if !conversation_id.is_empty() { + inbox.retain(|_, record| { + record.request.conversation_id.trim() != conversation_id + || !Self::remote_chat_record_should_cancel_for_conversation(record) + }); + } + Ok(()) + } + + pub(crate) fn merge_duplicate_remote_chat_request( + record: &mut RemoteChatInboxRecord, + request: GatewayChatRequestEvent, + now: Instant, + ) { + // A reconnect can replay the same gateway request while the JS runner is + // already processing it. Preserve local lease/owner/started state and + // only fill metadata that may have been absent in the original payload. + if !record.started && record.state.trim() == "queued" { + let canonical_request_id = record.request.request_id.clone(); + record.request = request; + record.request.request_id = canonical_request_id; + record.updated_at = now; + return; + } + if record.request.client_request_id.trim().is_empty() + && !request.client_request_id.trim().is_empty() + { + record.request.client_request_id = request.client_request_id.clone(); + } + if record.request.conversation_id.trim().is_empty() + && !request.conversation_id.trim().is_empty() + { + record.request.conversation_id = request.conversation_id.clone(); + } + record.updated_at = now; + } + + pub(crate) fn remote_chat_record_control_type(record: &RemoteChatInboxRecord) -> &'static str { + if record.started { + return "started"; + } + match record.state.trim() { + "claimed" => "claimed", + "starting" => "starting", + "queued_in_gui" => "queued_in_gui", + "running" => "started", + "failed" => "failed", + "cancelled" => "cancelled", + "completed" => "completed", + _ => "delivered", + } + } + + pub(crate) fn remote_chat_record_should_wake_runtime( + record: &RemoteChatInboxRecord, + now: Instant, + ) -> bool { + if record.started { + return false; + } + match record.state.trim() { + "queued" | "delivered" => true, + "claimed" | "starting" => record + .lease_expires_at + .map(|expires_at| now >= expires_at) + .unwrap_or(true), + _ => false, + } + } + + pub(crate) fn remote_chat_record_should_cancel_for_conversation( + record: &RemoteChatInboxRecord, + ) -> bool { + if record.started { + return true; + } + matches!(record.state.trim(), "claimed" | "starting" | "running") + } + + pub(crate) fn remote_chat_record_has_current_lease( + record: &RemoteChatInboxRecord, + worker_id: &str, + now: Instant, + ) -> bool { + if worker_id.trim().is_empty() { + return false; + } + if record.lease_owner.as_deref() != Some(worker_id) { + return false; + } + record + .lease_expires_at + .map(|expires_at| now < expires_at) + .unwrap_or(false) + } + + pub(crate) fn remote_chat_record_is_owned_by_worker( + record: &RemoteChatInboxRecord, + worker_id: &str, + ) -> bool { + !worker_id.trim().is_empty() && record.lease_owner.as_deref() == Some(worker_id) + } + + pub(crate) fn remote_chat_record_lease_ms(record: &RemoteChatInboxRecord) -> u64 { + if record.started { + GATEWAY_CHAT_RUNNING_LEASE_MS + } else { + GATEWAY_CHAT_LEASE_MS + } + } + + pub(crate) fn renew_remote_chat_request_lease( + &self, + request_id: &str, + worker_id: Option<&str>, + require_current: bool, + ) -> Result { + let request_id = request_id.trim(); + if request_id.is_empty() { + return Ok(true); + } + let worker_id = worker_id.unwrap_or_default().trim(); + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let Some(record) = inbox.get(request_id) else { + return Ok(true); + }; + let now = Instant::now(); + if require_current && !Self::remote_chat_record_has_current_lease(record, worker_id, now) { + return Ok(false); + } + if !require_current && !Self::remote_chat_record_is_owned_by_worker(record, worker_id) { + return Ok(false); + } + let lease_ms = Self::remote_chat_record_lease_ms(record); + if let Some(record) = inbox.get_mut(request_id) { + record.lease_expires_at = Some(now + Duration::from_millis(lease_ms)); + record.updated_at = now; + } + Ok(true) + } + + pub async fn claim_next_chat_request( + &self, + worker_id: String, + lease_ms: Option, + ) -> Result, String> { + let worker_id = worker_id.trim().to_string(); + if worker_id.is_empty() { + return Err("worker_id is required".to_string()); + } + let lease_ms = lease_ms + .unwrap_or(GATEWAY_CHAT_LEASE_MS) + .clamp(1_000, 120_000); + let now = Instant::now(); + let claimed = { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let mut selected_request_id: Option = None; + let mut selected_created_at: Option = None; + for (request_id, record) in inbox.iter() { + let state = record.state.trim(); + let lease_expired = record + .lease_expires_at + .map(|expires_at| now >= expires_at) + .unwrap_or(true); + if state == "queued" + || ((state == "claimed" || state == "starting") + && lease_expired + && !record.started) + { + if selected_created_at + .map(|created_at| record.created_at < created_at) + .unwrap_or(true) + { + selected_request_id = Some(request_id.clone()); + selected_created_at = Some(record.created_at); + } + } + } + selected_request_id.and_then(|request_id| { + inbox.get_mut(&request_id).map(|record| { + record.state = "claimed".to_string(); + record.lease_owner = Some(worker_id.clone()); + record.lease_expires_at = Some(now + Duration::from_millis(lease_ms)); + record.attempt = record.attempt.saturating_add(1); + record.updated_at = now; + GatewayChatClaimedRequest { + request_id: record.request.request_id.clone(), + client_request_id: record.request.client_request_id.clone(), + conversation_id: record.request.conversation_id.clone(), + state: record.state.clone(), + attempt: record.attempt, + lease_ms, + request: record.request.clone(), + } + }) + }) + }; + if let Some(claimed) = claimed.as_ref() { + self.send_gateway_chat_control_event( + claimed.request_id.clone(), + claimed.conversation_id.clone(), + "claimed", + ) + .await?; + } + Ok(claimed) + } + + pub async fn mark_chat_request_started( + &self, + request_id: String, + conversation_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let conversation_id = conversation_id.trim().to_string(); + let worker_id = worker_id.trim().to_string(); + { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let now = Instant::now(); + let record = inbox + .get_mut(&request_id) + .ok_or_else(|| "remote chat request lease is no longer active".to_string())?; + let queued_in_gui = record.state.trim() == "queued_in_gui" && !record.started; + if !queued_in_gui + && !Self::remote_chat_record_has_current_lease(record, &worker_id, now) + { + return Err("remote chat request lease is no longer active".to_string()); + } + if record.started { + return Ok(()); + } + record.state = "running".to_string(); + record.started = true; + record.lease_owner = Some(worker_id); + if !conversation_id.is_empty() { + record.request.conversation_id = conversation_id.clone(); + } + record.lease_expires_at = + Some(now + Duration::from_millis(GATEWAY_CHAT_RUNNING_LEASE_MS)); + record.updated_at = now; + } + self.ledger_mark_run_running(&request_id, &conversation_id)?; + self.send_gateway_chat_control_event(request_id, conversation_id, "started") + .await + } + + pub async fn mark_local_chat_run_started( + &self, + request_id: String, + conversation_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let conversation_id = conversation_id.trim().to_string(); + if request_id.is_empty() || conversation_id.is_empty() { + return Ok(()); + } + self.ledger_mark_run_running(&request_id, &conversation_id)?; + self.send_gateway_chat_control_event(request_id, conversation_id, "started") + .await + } + + pub async fn mark_chat_request_queued_in_gui( + &self, + request_id: String, + conversation_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let conversation_id = conversation_id.trim().to_string(); + let worker_id = worker_id.trim().to_string(); + let should_send = { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let Some(record) = inbox.get_mut(&request_id) else { + return Ok(()); + }; + if record.started { + return Ok(()); + } + if !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { + return Ok(()); + } + record.state = "queued_in_gui".to_string(); + record.lease_owner = None; + record.lease_expires_at = None; + if !conversation_id.is_empty() { + record.request.conversation_id = conversation_id.clone(); + } + record.updated_at = Instant::now(); + true + }; + if !should_send { + return Ok(()); + } + self.send_gateway_chat_control_event(request_id, conversation_id, "queued_in_gui") + .await + } + + pub async fn complete_chat_request( + &self, + request_id: String, + conversation_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let conversation_id = conversation_id.trim().to_string(); + let worker_id = worker_id.trim().to_string(); + let should_send = { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let Some(record) = inbox.get(&request_id) else { + return Ok(()); + }; + if !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { + return Ok(()); + } + inbox.remove(&request_id); + true + }; + if !should_send { + return Ok(()); + } + // Ledger first: once the inbox record is gone this is the only place + // that still knows the run finished, and the send below can fail. + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Completed, + "", + "", + )?; + self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "completed") + .await?; + self.ledger_mark_run_terminal_sent(&request_id) + } + + pub async fn fail_chat_request( + &self, + request_id: String, + conversation_id: Option, + error_code: String, + message: String, + terminal: bool, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let worker_id = worker_id.trim().to_string(); + let conversation_id = conversation_id.unwrap_or_default(); + // None: inbox record already gone; Some(true): accepted; Some(false): rejected. + let inbox_outcome = { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + match inbox.get_mut(&request_id) { + None => None, + Some(record) => { + let queued_in_gui = terminal && record.state.trim() == "queued_in_gui"; + if !queued_in_gui + && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) + { + Some(false) + } else { + record.state = if terminal { "failed" } else { "queued" }.to_string(); + record.lease_owner = None; + record.lease_expires_at = None; + record.last_error = Some(message.clone()); + record.updated_at = Instant::now(); + if terminal { + inbox.remove(&request_id); + } + Some(true) + } + } + } + }; + match inbox_outcome { + Some(false) => return Ok(()), + Some(true) => {} + None => { + // The inbox record can be gone while the run is still live in + // the ledger (e.g. a complete/fail race removed it). Dropping + // this terminal would strand the WebUI, so repair via the + // ledger instead of returning silently. + if !terminal || !self.ledger_has_live_run(&request_id)? { + return Ok(()); + } + } + } + if terminal { + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Failed, + &error_code, + &message, + )?; + } + self.send_gateway_chat_control_event_with_details( + request_id.clone(), + conversation_id, + "failed", + error_code, + message, + ) + .await?; + if terminal { + self.ledger_mark_run_terminal_sent(&request_id)?; + } + Ok(()) + } + + pub async fn cancel_chat_request( + &self, + request_id: String, + conversation_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim().to_string(); + let conversation_id = conversation_id.trim().to_string(); + let worker_id = worker_id.trim().to_string(); + let should_send = { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let Some(record) = inbox.get(&request_id) else { + return Ok(()); + }; + let queued_in_gui = record.state.trim() == "queued_in_gui"; + if !queued_in_gui && !Self::remote_chat_record_is_owned_by_worker(record, &worker_id) { + return Ok(()); + } + inbox.remove(&request_id); + true + }; + if !should_send { + return Ok(()); + } + // This "cancelled" is a genuine run terminal, not a cancel-request ack: + // the inbox record is removed above so no other terminal will ever be + // produced for this request (callers use it to drop queued turns that + // never start; running runs terminate via done/error/fail instead). + // First-terminal-wins keeps this from clobbering an earlier outcome. + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Cancelled, + "", + "", + )?; + self.send_gateway_chat_control_event(request_id.clone(), conversation_id, "cancelled") + .await?; + self.ledger_mark_run_terminal_sent(&request_id) + } + + pub fn heartbeat_chat_request( + &self, + request_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim(); + let worker_id = worker_id.trim(); + if request_id.is_empty() || worker_id.is_empty() { + return Ok(()); + } + { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + if let Some(record) = inbox.get_mut(request_id) { + if record.lease_owner.as_deref() == Some(worker_id) { + let lease_ms = Self::remote_chat_record_lease_ms(record); + record.lease_expires_at = + Some(Instant::now() + Duration::from_millis(lease_ms)); + record.updated_at = Instant::now(); + } + } + } + self.ledger_touch_run(request_id, "") + } + + pub async fn publish_chat_runtime_status( + &self, + worker_id: String, + state: String, + visible: bool, + active_run_count: u32, + ) -> Result<(), String> { + let worker_id = worker_id.trim().to_string(); + if worker_id.is_empty() { + return Ok(()); + } + let state = match state.trim() { + "draining" => "draining", + "busy" => "busy", + "suspended" => "suspended", + _ => "ready", + } + .to_string(); + let (active_reports, finished_reports) = { + let (now, _now_ms) = chat_run_ledger_now(); + let ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + (ledger.active_reports(now), ledger.recent_terminal_reports()) + }; + let active_run_count = + active_run_count.max(u32::try_from(active_reports.len()).unwrap_or(u32::MAX)); + let envelope = build_gateway_runtime_status_envelope( + worker_id, + state, + visible, + active_run_count, + active_reports + .iter() + .map(chat_run_report_from_entry) + .collect(), + finished_reports + .iter() + .map(chat_run_report_from_entry) + .collect(), + ); + match self.send_agent_envelope(envelope).await { + Ok(()) => Ok(()), + Err(error) if error.contains("outbound stream is offline") => Ok(()), + Err(error) => Err(error), + } + } + + pub fn release_chat_request_lease( + &self, + request_id: String, + worker_id: String, + ) -> Result<(), String> { + let request_id = request_id.trim(); + let worker_id = worker_id.trim(); + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + if let Some(record) = inbox.get_mut(request_id) { + if record.lease_owner.as_deref() == Some(worker_id) && !record.started { + record.state = "queued".to_string(); + record.lease_owner = None; + record.lease_expires_at = None; + record.updated_at = Instant::now(); + } + } + Ok(()) + } + + pub(crate) async fn expire_remote_chat_leases(&self) -> Result<(), String> { + let mut failed: Vec<(String, String)> = Vec::new(); + let mut wake = false; + { + let mut inbox = self + .remote_chat_inbox + .lock() + .map_err(|_| "gateway remote chat inbox lock poisoned".to_string())?; + let now = Instant::now(); + for record in inbox.values_mut() { + let Some(expires_at) = record.lease_expires_at else { + continue; + }; + if now < expires_at { + continue; + } + if record.started { + record.state = "failed".to_string(); + failed.push(( + record.request.request_id.clone(), + record.request.conversation_id.clone(), + )); + continue; + } + record.state = "queued".to_string(); + record.lease_owner = None; + record.lease_expires_at = None; + record.updated_at = now; + wake = true; + } + for (request_id, _) in &failed { + inbox.remove(request_id); + } + } + if wake { + let _ = self.app_handle.emit( + "gateway:chat-request-ready", + json!({ "reason": "lease_expired" }), + ); + } + for (request_id, conversation_id) in failed { + self.ledger_mark_run_terminal( + &request_id, + &conversation_id, + ChatRunLedgerState::Failed, + "desktop_runtime_lease_expired", + "Desktop chat runtime stopped before completing the remote request.", + )?; + // One failed send must not abort the remaining terminals; the + // ledger flush loop retries anything that stays unsent. + match self + .send_gateway_chat_control_event_with_details( + request_id.clone(), + conversation_id, + "failed", + "desktop_runtime_lease_expired".to_string(), + "Desktop chat runtime stopped before completing the remote request." + .to_string(), + ) + .await + { + Ok(()) => self.ledger_mark_run_terminal_sent(&request_id)?, + Err(error) => { + eprintln!("send gateway chat lease-expired terminal failed: {error}"); + } + } + } + Ok(()) + } + + pub(crate) fn with_chat_run_ledger( + &self, + f: impl FnOnce(&mut ChatRunLedger) -> T, + ) -> Result { + let mut ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + Ok(f(&mut ledger)) + } + + pub(crate) fn ledger_mark_run_running( + &self, + run_id: &str, + conversation_id: &str, + ) -> Result<(), String> { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| { + ledger.mark_running(run_id, conversation_id, now, now_ms); + }) + } + + pub(crate) fn ledger_touch_run( + &self, + run_id: &str, + conversation_id: &str, + ) -> Result<(), String> { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| ledger.touch(run_id, conversation_id, now, now_ms)) + } + + pub(crate) fn ledger_mark_run_terminal( + &self, + run_id: &str, + conversation_id: &str, + state: ChatRunLedgerState, + error_code: &str, + message: &str, + ) -> Result { + let (now, now_ms) = chat_run_ledger_now(); + self.with_chat_run_ledger(|ledger| { + ledger.mark_terminal( + run_id, + conversation_id, + state, + error_code, + message, + now, + now_ms, + ) + }) + } + + pub(crate) fn ledger_mark_run_terminal_sent(&self, run_id: &str) -> Result<(), String> { + self.with_chat_run_ledger(|ledger| ledger.mark_terminal_sent(run_id)) + } + + pub(crate) fn ledger_has_live_run(&self, run_id: &str) -> Result { + self.with_chat_run_ledger(|ledger| { + ledger + .get(run_id) + .map(|entry| !entry.state.is_terminal()) + .unwrap_or(false) + }) + } + + pub(crate) async fn flush_unsent_chat_run_terminals(&self) -> Result<(), String> { + if !self.status().online { + return Ok(()); + } + let unsent = { + let (now, now_ms) = chat_run_ledger_now(); + let mut ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + // Sweep first: runs demoted by the TTL become unsent terminals and + // are picked up by this very flush. + ledger.sweep(now, now_ms); + ledger.unsent_terminals() + }; + for entry in unsent { + // The gateway cannot anchor a control event without a conversation + // (it drops them at ingress); such entries only age out. + if entry.conversation_id.is_empty() { + continue; + } + match self + .send_gateway_chat_control_event_with_details( + entry.run_id.clone(), + entry.conversation_id.clone(), + entry.state.as_str(), + entry.error_code.clone(), + entry.message.clone(), + ) + .await + { + Ok(()) => self.ledger_mark_run_terminal_sent(&entry.run_id)?, + Err(error) => { + eprintln!( + "flush gateway chat run terminal {} failed: {error}", + entry.run_id + ); + } + } + } + Ok(()) + } + + pub(crate) async fn republish_chat_run_states(&self) -> Result<(), String> { + let (active, recent_terminals) = { + let (now, _now_ms) = chat_run_ledger_now(); + let ledger = self + .chat_run_ledger + .lock() + .map_err(|_| "gateway chat run ledger lock poisoned".to_string())?; + (ledger.active_reports(now), ledger.recent_terminal_reports()) + }; + for entry in active { + if entry.conversation_id.is_empty() { + continue; + } + // "started" is idempotent on the gateway; replaying it re-anchors + // runs the gateway may have lost across a restart. + if let Err(error) = self + .send_gateway_chat_control_event( + entry.run_id.clone(), + entry.conversation_id.clone(), + "started", + ) + .await + { + eprintln!( + "republish gateway chat run {} failed: {error}", + entry.run_id + ); + } + } + // Replay all recent terminals, sent or not: a gateway restart can lose + // them, and the control events are idempotent server-side. Unsent + // terminals older than the recent window are covered by the periodic + // flush a few seconds later. + for entry in recent_terminals { + if entry.conversation_id.is_empty() { + continue; + } + if let Err(error) = self + .send_gateway_chat_control_event_with_details( + entry.run_id.clone(), + entry.conversation_id.clone(), + entry.state.as_str(), + entry.error_code.clone(), + entry.message.clone(), + ) + .await + { + eprintln!( + "republish gateway chat run terminal {} failed: {error}", + entry.run_id + ); + } else { + self.ledger_mark_run_terminal_sent(&entry.run_id)?; + } + } + Ok(()) + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs new file mode 100644 index 00000000..bc466830 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs @@ -0,0 +1,523 @@ +use std::future::Future; +use std::sync::{Arc, Once}; +use std::time::Duration; + +use reqwest::Url; +use serde_json::Value; +use tauri::Emitter; +use tokio::sync::{mpsc, watch}; +use tokio_stream::wrappers::ReceiverStream; +use tonic::metadata::MetadataValue; +use tonic::transport::{ClientTlsConfig, Endpoint}; + +use crate::commands::settings::RemoteSettingsPayload; +use crate::runtime::terminal::TerminalEventPayload; +use crate::services::gateway_bridge; + +use super::*; + +impl GatewayController { + pub(crate) async fn run( + self: Arc, + mut config_rx: watch::Receiver, + ) { + loop { + let config = config_rx.borrow().clone(); + if !config.enabled || !is_remote_configured(&config) { + self.set_outbound_sender(None); + self.set_terminal_stream_sender(None); + self.publish_status(|status| { + set_disconnected_status(status, &config, None); + }); + if config_rx.changed().await.is_err() { + break; + } + continue; + } + + let current_config = config.clone(); + let connect_result = self + .connect_and_serve(current_config.clone(), &mut config_rx) + .await; + let latest_config = config_rx.borrow().clone(); + let reconfigured = latest_config != current_config; + + self.set_outbound_sender(None); + self.set_terminal_stream_sender(None); + if reconfigured { + self.publish_status(|status| { + set_disconnected_status(status, &latest_config, None); + }); + continue; + } + + self.publish_status(|status| match connect_result.as_ref() { + Ok(()) => set_disconnected_status(status, ¤t_config, None), + Err(error) => set_disconnected_status(status, ¤t_config, Some(error.clone())), + }); + + if config_rx.has_changed().unwrap_or(false) { + continue; + } + + if !current_config.auto_reconnect { + if config_rx.changed().await.is_err() { + break; + } + continue; + } + + tokio::select! { + changed = config_rx.changed() => { + if changed.is_err() { + break; + } + } + _ = tokio::time::sleep(GATEWAY_RECONNECT_DELAY) => {} + } + } + } + + pub(crate) async fn connect_and_serve( + self: &Arc, + config: RemoteSettingsPayload, + config_rx: &mut watch::Receiver, + ) -> Result<(), String> { + let grpc_url = build_grpc_url(&config)?; + // The heartbeat interval setting drives the h2 keepalive cadence; the + // lower bound stays above the gateway's keepalive enforcement MinTime. + let keepalive_interval = Duration::from_secs(config.heartbeat_interval.clamp(10, 60)); + let endpoint = build_endpoint(&grpc_url, keepalive_interval)?; + let channel = endpoint.connect_lazy(); + + let mut client = proto::agent_gateway_client::AgentGatewayClient::new(channel) + .max_decoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES) + .max_encoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES); + let mut auth_request = tonic::Request::new(proto::AuthRequest { + token: config.token.clone(), + agent_id: effective_agent_id(&config), + agent_version: crate::app_version().to_string(), + }); + insert_bearer_metadata(auth_request.metadata_mut(), &config.token)?; + + let auth_call = client.authenticate(auth_request); + let auth_response = match await_abortable_on_reconfigure(&config, config_rx, async move { + tokio::time::timeout(Duration::from_secs(10), auth_call) + .await + .map_err(|_| "gateway authenticate timed out".to_string())? + .map_err(|e| format!("gateway authenticate failed: {e}")) + .map(|response| response.into_inner()) + }) + .await? + { + Some(response) => response, + None => return Ok(()), + }; + if !auth_response.success { + return Err(if auth_response.message.trim().is_empty() { + "gateway authentication failed".to_string() + } else { + auth_response.message + }); + } + + let terminal_client = client.clone(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(4096); + self.set_outbound_sender(Some(outbound_tx)); + let (terminal_stop_tx, terminal_stop_rx) = watch::channel(false); + let terminal_task = + self.spawn_terminal_stream(terminal_client, config.clone(), terminal_stop_rx); + + let serve_result = async { + let mut connect_request = tonic::Request::new(ReceiverStream::new(outbound_rx)); + insert_bearer_metadata(connect_request.metadata_mut(), &config.token)?; + + let connect_call = client.agent_connect(connect_request); + let response = match await_abortable_on_reconfigure(&config, config_rx, async move { + tokio::time::timeout(Duration::from_secs(10), connect_call) + .await + .map_err(|_| "open gateway stream timed out".to_string())? + .map_err(|e| format!("open gateway stream failed: {e}")) + }) + .await? + { + Some(response) => response, + None => return Ok(()), + }; + let mut inbound = response.into_inner(); + + let connected_at = now_unix_seconds(); + self.publish_status(|status| { + status.online = true; + status.enabled = true; + status.configured = true; + status.gateway_url = config.gateway_url.clone(); + status.agent_id = effective_agent_id(&config); + status.session_id = Some(auth_response.session_id.clone()); + status.connected_since = Some(connected_at); + status.last_heartbeat = Some(connected_at); + status.last_error = None; + }); + + if let Err(error) = self.publish_current_settings_sync().await { + eprintln!("publish gateway settings sync failed: {error}"); + } + if let Err(error) = self.publish_current_terminal_sessions().await { + eprintln!("publish gateway terminal sessions failed: {error}"); + } + if let Err(error) = self.publish_desired_tunnels().await { + eprintln!("publish gateway tunnel desired state failed: {error}"); + } + if let Err(error) = self.republish_chat_run_states().await { + eprintln!("republish gateway chat run states failed: {error}"); + } + self.spawn_tunnel_probes(None, false); + + // Dead links are detected by the transport-level HTTP/2 keepalive + // configured on the endpoint and surface as receive errors here. + loop { + tokio::select! { + changed = config_rx.changed() => { + if changed.is_err() { + return Ok(()); + } + let next = config_rx.borrow().clone(); + if next != config { + return Ok(()); + } + } + message = inbound.message() => { + match message { + Err(err) => return Err(format!("gateway stream receive failed: {err}")), + Ok(None) => return Err("gateway stream closed".to_string()), + Ok(Some(envelope)) => { + self.touch_heartbeat(); + self.handle_gateway_envelope(envelope).await?; + } + } + } + } + } + } + .await; + + let _ = terminal_stop_tx.send(true); + terminal_task.abort(); + self.set_terminal_stream_sender(None); + serve_result + } + + pub(crate) async fn send_agent_envelope( + &self, + envelope: proto::AgentEnvelope, + ) -> Result<(), String> { + let sender = self.current_outbound_sender()?; + send_agent_envelope_to(sender, envelope).await + } + + pub(crate) fn current_outbound_sender( + &self, + ) -> Result, String> { + self.outbound_tx + .lock() + .map_err(|_| "gateway outbound sender lock poisoned".to_string())? + .clone() + .ok_or_else(|| "gateway outbound stream is offline".to_string()) + } + + pub(crate) fn current_terminal_stream_sender( + &self, + ) -> Result, String> { + self.terminal_stream_tx + .lock() + .map_err(|_| "gateway terminal stream sender lock poisoned".to_string())? + .clone() + .ok_or_else(|| "gateway terminal stream is offline".to_string()) + } + + pub(crate) fn spawn_uploaded_image_preview_response( + &self, + request_id: String, + request: proto::UploadedImagePreviewRequest, + ) -> Result<(), String> { + let sender = self.current_outbound_sender()?; + tauri::async_runtime::spawn(async move { + let envelope = match gateway_bridge::handle_uploaded_image_preview(request).await { + Ok(response) => proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::UploadedImagePreviewResp( + response, + )), + }, + Err(error) => build_error_response_envelope(request_id, 500, error), + }; + if let Err(error) = send_agent_envelope_to(sender, envelope).await { + eprintln!("send gateway uploaded image preview response failed: {error}"); + } + }); + Ok(()) + } + + pub(crate) async fn send_error_response( + &self, + request_id: String, + code: i32, + message: String, + ) -> Result<(), String> { + self.send_agent_envelope(build_error_response_envelope(request_id, code, message)) + .await + } + + pub(crate) fn set_outbound_sender(&self, sender: Option>) { + if let Ok(mut slot) = self.outbound_tx.lock() { + *slot = sender; + } + } + + pub(crate) fn set_terminal_stream_sender( + &self, + sender: Option>, + ) { + if let Ok(mut slot) = self.terminal_stream_tx.lock() { + *slot = sender; + } + } + + pub(crate) fn clear_terminal_stream_sender_if_current( + &self, + sender: &mpsc::Sender, + ) { + if let Ok(mut slot) = self.terminal_stream_tx.lock() { + if slot + .as_ref() + .map(|current| current.same_channel(sender)) + .unwrap_or(false) + { + *slot = None; + } + } + } + + pub(crate) fn touch_heartbeat(&self) { + self.publish_status(|status| { + status.last_heartbeat = Some(now_unix_seconds()); + }); + } + + pub(crate) fn publish_status(&self, mutate: impl FnOnce(&mut GatewayStatusSnapshot)) { + let next = if let Ok(mut status) = self.status.lock() { + mutate(&mut status); + status.clone() + } else { + return; + }; + let _ = self.app_handle.emit("gateway:status", next); + } + + pub(crate) async fn publish_current_settings_sync(&self) -> Result<(), String> { + let snapshot = self.current_settings_snapshot().await?; + self.publish_settings_sync(snapshot).await + } + + pub(crate) async fn publish_current_terminal_sessions(&self) -> Result<(), String> { + let sessions = self.terminal_registry.list(None).sessions; + for session in sessions { + self.send_agent_envelope(build_terminal_event_envelope(TerminalEventPayload { + kind: "created".to_string(), + session_id: session.id.clone(), + project_path_key: session.project_path_key.clone(), + session: Some(session), + data: None, + output_start_offset: None, + output_end_offset: None, + ssh_tabs: None, + })) + .await?; + } + Ok(()) + } + + pub async fn refresh_settings_sync_from_db(&self) -> Result { + let snapshot = self.current_settings_snapshot().await?; + self.app_handle + .emit(GATEWAY_SETTINGS_SYNC_EVENT, snapshot.clone()) + .map_err(|e| format!("emit gateway settings sync failed: {e}"))?; + self.publish_settings_sync(snapshot.clone()).await?; + Ok(snapshot) + } +} + +pub(crate) async fn await_abortable_on_reconfigure( + config: &RemoteSettingsPayload, + config_rx: &mut watch::Receiver, + fut: impl Future>, +) -> Result, String> { + tokio::pin!(fut); + + loop { + tokio::select! { + result = &mut fut => return result.map(Some), + changed = config_rx.changed() => { + if changed.is_err() { + return Ok(None); + } + let next = config_rx.borrow().clone(); + if next != *config { + return Ok(None); + } + } + } + } +} + +pub(crate) async fn send_agent_envelope_to( + sender: mpsc::Sender, + envelope: proto::AgentEnvelope, +) -> Result<(), String> { + sender + .send(envelope) + .await + .map_err(|_| "gateway outbound stream closed".to_string()) +} + +pub(crate) fn build_error_response_envelope( + request_id: String, + code: i32, + message: String, +) -> proto::AgentEnvelope { + proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::Error( + proto::ErrorResponse { code, message }, + )), + } +} + +pub(crate) fn build_grpc_url(config: &RemoteSettingsPayload) -> Result { + let grpc_endpoint = config.grpc_endpoint.trim(); + if !grpc_endpoint.is_empty() { + let with_scheme = + if grpc_endpoint.starts_with("http://") || grpc_endpoint.starts_with("https://") { + grpc_endpoint.to_string() + } else { + format!("http://{grpc_endpoint}") + }; + let mut url = + Url::parse(&with_scheme).map_err(|e| format!("invalid gateway gRPC endpoint: {e}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err("gateway gRPC endpoint must start with http:// or https://".to_string()); + } + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + return Ok(url.to_string().trim_end_matches('/').to_string()); + } + + let trimmed = config.gateway_url.trim(); + if trimmed.is_empty() { + return Err("gateway URL is empty".to_string()); + } + + let mut url = Url::parse(trimmed).map_err(|e| format!("invalid gateway URL: {e}"))?; + if url.scheme() != "http" && url.scheme() != "https" { + return Err("gateway URL must start with http:// or https://".to_string()); + } + url.set_port(Some(config.grpc_port)) + .map_err(|_| "failed to apply gRPC port to gateway URL".to_string())?; + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + Ok(url.to_string().trim_end_matches('/').to_string()) +} + +pub(crate) fn is_h2_protocol_error(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + normalized.contains("h2 protocol error") || normalized.contains("http2 error") +} + +pub(crate) fn build_endpoint( + grpc_url: &str, + keepalive_interval: Duration, +) -> Result { + // HTTP/2 keepalive owns dead-link detection: PING frames bypass stream + // flow control, so congestion or streaming never delays them. + let endpoint = Endpoint::from_shared(grpc_url.to_string()) + .map_err(|e| format!("invalid gateway endpoint: {e}"))? + .connect_timeout(Duration::from_secs(10)) + .tcp_keepalive(Some(Duration::from_secs(30))) + .http2_keep_alive_interval(keepalive_interval) + .keep_alive_timeout(Duration::from_secs(15)) + .keep_alive_while_idle(true); + + if grpc_url.starts_with("https://") { + ensure_rustls_crypto_provider(); + let host = Url::parse(grpc_url) + .ok() + .and_then(|url| url.host_str().map(ToString::to_string)) + .ok_or_else(|| "failed to extract TLS host from gateway URL".to_string())?; + endpoint + .tls_config( + ClientTlsConfig::new() + .with_enabled_roots() + .domain_name(host), + ) + .map_err(|e| format!("configure gateway TLS failed: {e}")) + } else { + Ok(endpoint) + } +} + +pub(crate) fn ensure_rustls_crypto_provider() { + static INSTALL_DEFAULT_PROVIDER: Once = Once::new(); + INSTALL_DEFAULT_PROVIDER.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +pub(crate) fn insert_bearer_metadata( + metadata: &mut tonic::metadata::MetadataMap, + token: &str, +) -> Result<(), String> { + let value = MetadataValue::try_from(format!("Bearer {}", token.trim())) + .map_err(|e| format!("invalid gateway authorization metadata: {e}"))?; + metadata.insert("authorization", value); + Ok(()) +} + +pub(crate) fn is_remote_configured(config: &RemoteSettingsPayload) -> bool { + !config.gateway_url.trim().is_empty() && !config.token.trim().is_empty() +} + +pub(crate) fn effective_agent_id(config: &RemoteSettingsPayload) -> String { + if !config.agent_id.trim().is_empty() { + return config.agent_id.trim().to_string(); + } + fallback_agent_id() +} + +pub(crate) fn fallback_agent_id() -> String { + std::env::var("HOSTNAME") + .ok() + .or_else(|| std::env::var("COMPUTERNAME").ok()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "liveagent-desktop".to_string()) +} + +pub(crate) fn set_disconnected_status( + status: &mut GatewayStatusSnapshot, + config: &RemoteSettingsPayload, + last_error: Option, +) { + status.online = false; + status.enabled = config.enabled; + status.configured = is_remote_configured(config); + status.gateway_url = config.gateway_url.clone(); + status.agent_id = effective_agent_id(config); + status.session_id = None; + status.connected_since = None; + status.last_heartbeat = None; + status.last_error = last_error; +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/controller.rs b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs new file mode 100644 index 00000000..fb8bad2e --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/controller.rs @@ -0,0 +1,354 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Once}; +use std::thread; + +use serde_json::Value; +use tauri::Emitter; +use tokio::sync::watch; + +use crate::commands::settings::{ + load_remote_settings, normalize_remote_settings_payload, open_db, RemoteSettingsPayload, +}; +use crate::runtime::sftp::SftpSessionRegistry; +use crate::runtime::terminal::TerminalSessionRegistry; +use crate::services::chat_run_ledger::{ChatRunLedger, ChatRunLedgerState}; +use crate::services::cron::CronManager; +use crate::services::memory::MemoryStore; +use crate::services::tunnel::{TunnelProxy, TunnelStore}; + +use super::*; + +impl GatewayController { + pub fn new( + app_handle: tauri::AppHandle, + cron_manager: Arc, + memory_store: Arc, + terminal_registry: Arc, + sftp_registry: Arc, + ) -> Self { + let initial_config = RemoteSettingsPayload::default(); + let (config_tx, _) = watch::channel(initial_config); + let tunnel_store = TunnelStore::new(app_handle.clone()); + Self { + app_handle, + cron_manager, + memory_store, + terminal_registry, + sftp_registry, + config_tx, + runner_task: Mutex::new(None), + status: Mutex::new(GatewayStatusSnapshot { + online: false, + enabled: false, + configured: false, + gateway_url: String::new(), + agent_id: fallback_agent_id(), + session_id: None, + connected_since: None, + last_heartbeat: None, + last_error: None, + }), + outbound_tx: Mutex::new(None), + terminal_stream_tx: Mutex::new(None), + settings_snapshot: Mutex::new(None), + remote_chat_inbox: Mutex::new(HashMap::new()), + chat_run_ledger: Mutex::new(ChatRunLedger::new()), + tunnel_store, + tunnel_proxy: TunnelProxy::new(), + pending_chat_queue_requests: Mutex::new(HashMap::new()), + terminal_forwarder_once: Once::new(), + terminal_stream_forwarder_once: Once::new(), + sftp_forwarder_once: Once::new(), + remote_chat_inbox_sweeper_once: Once::new(), + tunnel_store_once: Once::new(), + } + } + + pub fn start(self: &Arc) -> Result<(), String> { + self.start_terminal_forwarder(); + self.start_terminal_stream_forwarder(); + self.start_sftp_forwarder(); + self.start_remote_chat_inbox_sweeper(); + self.start_tunnel_store(); + self.ensure_runner() + } + + pub(crate) fn start_terminal_forwarder(self: &Arc) { + let controller = Arc::clone(self); + self.terminal_forwarder_once.call_once(move || { + let (receiver, guard) = controller.terminal_registry.subscribe(); + thread::spawn(move || { + let _guard = guard; + while let Ok(event) = receiver.recv() { + let envelope = build_terminal_event_envelope(event.payload); + let Ok(sender) = controller.current_outbound_sender() else { + continue; + }; + if let Err(error) = sender.blocking_send(envelope) { + eprintln!("send gateway terminal event failed: {error}"); + } + } + }); + }); + } + + pub(crate) fn start_terminal_stream_forwarder(self: &Arc) { + let controller = Arc::clone(self); + self.terminal_stream_forwarder_once.call_once(move || { + let (receiver, guard) = controller.terminal_registry.subscribe_stream(); + thread::spawn(move || { + let _guard = guard; + while let Ok(event) = receiver.recv() { + let frame = build_terminal_stream_output_frame(event.payload); + let Ok(sender) = controller.current_terminal_stream_sender() else { + continue; + }; + if let Err(error) = sender.blocking_send(frame) { + eprintln!("send gateway terminal stream frame failed: {error}"); + } + } + }); + }); + } + + pub(crate) fn start_sftp_forwarder(self: &Arc) { + let controller = Arc::clone(self); + self.sftp_forwarder_once.call_once(move || { + let (receiver, guard) = controller.sftp_registry.subscribe(); + thread::spawn(move || { + let _guard = guard; + while let Ok(event) = receiver.recv() { + let envelope = build_sftp_event_envelope(event.payload); + let Ok(sender) = controller.current_outbound_sender() else { + continue; + }; + if let Err(error) = sender.blocking_send(envelope) { + eprintln!("send gateway SFTP event failed: {error}"); + } + } + }); + }); + } + + pub(crate) fn start_remote_chat_inbox_sweeper(self: &Arc) { + let controller = Arc::clone(self); + self.remote_chat_inbox_sweeper_once.call_once(move || { + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(GATEWAY_CHAT_LEASE_SWEEP_INTERVAL).await; + if let Err(error) = controller.expire_remote_chat_leases().await { + eprintln!("expire gateway remote chat leases failed: {error}"); + } + if let Err(error) = controller.flush_unsent_chat_run_terminals().await { + eprintln!("flush gateway chat run terminals failed: {error}"); + } + } + }); + }); + } + + pub(crate) fn spawn_runner( + self: &Arc, + runner_task: &mut Option>, + ) { + let receiver = self.config_tx.subscribe(); + let controller = Arc::clone(self); + *runner_task = Some(tauri::async_runtime::spawn(async move { + controller.run(receiver).await; + })); + } + + pub(crate) fn ensure_runner(self: &Arc) -> Result<(), String> { + let mut runner_task = self + .runner_task + .lock() + .map_err(|_| "gateway runner task lock poisoned".to_string())?; + let should_spawn = runner_task + .as_ref() + .map(|task| task.inner().is_finished()) + .unwrap_or(true); + if !should_spawn { + return Ok(()); + } + + self.spawn_runner(&mut runner_task); + Ok(()) + } + + pub(crate) fn restart_runner(self: &Arc) -> Result<(), String> { + self.set_outbound_sender(None); + self.set_terminal_stream_sender(None); + let mut runner_task = self + .runner_task + .lock() + .map_err(|_| "gateway runner task lock poisoned".to_string())?; + if let Some(task) = runner_task.take() { + task.abort(); + } + self.spawn_runner(&mut runner_task); + Ok(()) + } + + pub async fn reload_from_db(self: &Arc) -> Result<(), String> { + let config = tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + load_remote_settings(&conn) + }) + .await + .map_err(|e| format!("reload remote settings join failed: {e}"))??; + self.apply_config(config) + } + + pub fn apply_config(self: &Arc, config: RemoteSettingsPayload) -> Result<(), String> { + let normalized = normalize_remote_settings_payload(config); + let previous = self.config_tx.borrow().clone(); + let config_changed = previous != normalized; + let should_run_remote = normalized.enabled && is_remote_configured(&normalized); + self.config_tx.send_replace(normalized.clone()); + self.publish_status(|status| { + status.enabled = normalized.enabled; + status.configured = is_remote_configured(&normalized); + status.gateway_url = normalized.gateway_url.clone(); + status.agent_id = effective_agent_id(&normalized); + if !normalized.enabled { + set_disconnected_status(status, &normalized, None); + } else if config_changed { + set_disconnected_status(status, &normalized, None); + } + }); + if should_run_remote { + self.restart_runner()?; + } else { + self.ensure_runner()?; + } + Ok(()) + } + + pub fn disconnect_runtime(self: &Arc) -> Result<(), String> { + let mut config = self.config_tx.borrow().clone(); + config.enabled = false; + self.apply_config(config) + } + + pub fn status(&self) -> GatewayStatusSnapshot { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or(GatewayStatusSnapshot { + online: false, + enabled: false, + configured: false, + gateway_url: String::new(), + agent_id: fallback_agent_id(), + session_id: None, + connected_since: None, + last_heartbeat: None, + last_error: Some("gateway status lock poisoned".to_string()), + }) + } + + pub async fn send_chat_event( + &self, + request_id: String, + event: Value, + worker_id: Option, + ) -> Result<(), String> { + // Terminal events must bypass the lease-freshness check: an expired but + // still-owned lease may no longer be "current", yet dropping the run's + // done/error signal here would leave the WebUI streaming forever. + let is_terminal = chat_event_is_terminal(&event); + if !self.renew_remote_chat_request_lease(&request_id, worker_id.as_deref(), !is_terminal)? { + return Ok(()); + } + let conversation_id = chat_event_conversation_id(&event); + if is_terminal { + let state = if chat_event_type(&event) == Some("done") { + ChatRunLedgerState::Completed + } else { + ChatRunLedgerState::Failed + }; + // Carry the error text into the ledger so a retransmitted terminal + // control event still surfaces it after the original send failed. + let message = event + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .trim(); + // Record the terminal before attempting the send so a failed send + // is retransmitted by the ledger flush loop. + self.ledger_mark_run_terminal(&request_id, &conversation_id, state, "", message)?; + } else { + self.ledger_touch_run(&request_id, &conversation_id)?; + } + let envelope = build_chat_event_envelope(request_id.clone(), event)?; + let result = self.send_agent_envelope(envelope).await; + if is_terminal && result.is_ok() { + self.ledger_mark_run_terminal_sent(&request_id)?; + } + result + } + + pub async fn publish_history_sync(&self, event: GatewayHistorySyncEvent) { + if let Err(error) = self.app_handle.emit(CHAT_HISTORY_SYNC_EVENT, event.clone()) { + eprintln!("emit chat history sync failed: {error}"); + } + + if !self.status().online { + return; + } + + let envelope = match build_history_sync_envelope(event) { + Ok(envelope) => envelope, + Err(error) => { + eprintln!("build gateway history sync envelope failed: {error}"); + return; + } + }; + + if let Err(error) = self.send_agent_envelope(envelope).await { + eprintln!("send gateway history sync event failed: {error}"); + } + } + + pub async fn publish_chat_runtime_snapshot( + &self, + snapshot: GatewayChatRuntimeSnapshot, + ) -> Result<(), String> { + let run_id = snapshot.run_id.trim().to_string(); + let conversation_id = snapshot.conversation_id.trim().to_string(); + let terminal_state = match snapshot.state.trim() { + "completed" => Some(ChatRunLedgerState::Completed), + "failed" => Some(ChatRunLedgerState::Failed), + "cancelled" => Some(ChatRunLedgerState::Cancelled), + _ => None, + }; + if !run_id.is_empty() { + match terminal_state { + Some(state) => { + self.ledger_mark_run_terminal(&run_id, &conversation_id, state, "", "")?; + } + None if snapshot.state.trim() == "running" => { + self.ledger_touch_run(&run_id, &conversation_id)?; + } + None => {} + } + } + let envelope = build_chat_runtime_snapshot_envelope(snapshot)?; + let result = self.send_agent_envelope(envelope).await; + if terminal_state.is_some() && !run_id.is_empty() && result.is_ok() { + self.ledger_mark_run_terminal_sent(&run_id)?; + } + result + } + + pub async fn publish_settings_sync(&self, payload: Value) -> Result<(), String> { + let snapshot = self.store_settings_snapshot(payload)?; + + if !self.status().online { + return Ok(()); + } + + let envelope = build_settings_sync_envelope(snapshot)?; + self.send_agent_envelope(envelope).await + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs new file mode 100644 index 00000000..4a45c31a --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/envelope_handler.rs @@ -0,0 +1,918 @@ +use std::sync::Arc; + +use serde_json::Value; +use tauri::Emitter; + +use crate::commands::chat_history::{self}; +use crate::commands::settings::{ + apply_ssh_patch_with_conn, open_db, redact_gateway_settings_sync_payload, + reset_runtime_ssh_known_host, SSH_PATCH_FIELD, +}; +use crate::services::gateway_bridge; + +use super::*; + +impl GatewayController { + pub(crate) async fn handle_gateway_envelope( + self: &Arc, + envelope: proto::GatewayEnvelope, + ) -> Result<(), String> { + let request_id = envelope.request_id.clone(); + + match envelope.payload { + Some(proto::gateway_envelope::Payload::Ping(ping)) => { + // Never block the receive loop on outbound saturation: the + // gateway counts any inbound envelope as liveness, so a pong + // dropped under load is harmless. + if let Ok(sender) = self.current_outbound_sender() { + let _ = sender.try_send(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::Pong(proto::PongResponse { + timestamp: ping.timestamp, + })), + }); + } + Ok(()) + } + Some(proto::gateway_envelope::Payload::TunnelState(snapshot)) => { + self.handle_tunnel_state_snapshot(snapshot); + Ok(()) + } + Some(proto::gateway_envelope::Payload::TunnelMutation(mutation)) => { + self.handle_tunnel_mutation_request(request_id, mutation); + Ok(()) + } + Some(proto::gateway_envelope::Payload::TunnelFrame(frame)) => { + self.tunnel_proxy.handle_frame(self, frame) + } + Some(proto::gateway_envelope::Payload::ChatCommand(command)) => { + self.handle_chat_command(request_id, command).await + } + Some(proto::gateway_envelope::Payload::ChatQueue(request)) => { + self.handle_chat_queue_request(request_id, request).await + } + Some(proto::gateway_envelope::Payload::CronManage(request)) => { + let should_refresh_settings = + matches!(request.action.trim(), "create" | "update" | "delete"); + match gateway_bridge::handle_cron_manage(Arc::clone(&self.cron_manager), request) + .await + { + Ok(response) => { + let send_result = self + .send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::CronManageResp( + response, + )), + }) + .await; + if send_result.is_ok() && should_refresh_settings { + if let Err(error) = self.refresh_settings_sync_from_db().await { + eprintln!( + "refresh gateway settings sync after cron manage failed: {error}" + ); + } + } + send_result + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::HistoryList(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_list(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::HistoryListResp( + response, + )), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.list handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryWorkdirs(_request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_workdirs().await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryWorkdirsResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.workdirs handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryGet(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_get(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::HistoryGetResp( + response, + )), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.get handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryPrefix(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_prefix(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryPrefixResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.prefix handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryRename(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_rename(request).await { + Ok(response) => { + if let Some(conversation) = response.conversation.as_ref() { + controller + .publish_history_sync(build_history_sync_upsert_from_proto( + conversation, + )) + .await; + } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryRenameResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.rename handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryPin(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_pin(request).await { + Ok(response) => { + if let Some(conversation) = response.conversation.as_ref() { + controller + .publish_history_sync(build_history_sync_upsert_from_proto( + conversation, + )) + .await; + } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::HistoryPinResp( + response, + )), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.pin handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryShareGet(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_get(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareGetResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.get handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryShareSet(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_set(request).await { + Ok(response) => { + if let Some(share) = response.share.as_ref() { + match chat_history::chat_history_get_summary_inner( + share.conversation_id.clone(), + ) + .await + { + Ok(summary) => { + controller + .publish_history_sync(build_history_sync_upsert( + &summary, + )) + .await; + } + Err(error) => { + eprintln!( + "publish history share sync event failed: {error}" + ) + } + } + } + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareSetResp( + response, + ), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.set handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryShareResolve(request)) => { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_share_resolve(request).await { + Ok(response) => { + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryShareResolveResp( + response, + ), + ), + }) + .await + } + Err(error) => { + let code = history_share_resolve_error_code(&error); + controller + .send_error_response(request_id.clone(), code, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.share.resolve handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::HistoryDelete(request)) => { + let deleted_conversation_id = request.conversation_id.trim().to_string(); + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + let result = match gateway_bridge::handle_history_delete(request).await { + Ok(response) => { + controller + .publish_history_sync(build_history_sync_delete( + deleted_conversation_id, + )) + .await; + controller + .send_agent_envelope(proto::AgentEnvelope { + request_id: request_id.clone(), + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::HistoryDeleteResp(response), + ), + }) + .await + } + Err(error) => { + controller + .send_error_response(request_id.clone(), 500, error) + .await + } + }; + if let Err(err) = result { + eprintln!("gateway history.delete handler failed: {err}"); + } + }); + Ok(()) + } + Some(proto::gateway_envelope::Payload::ProviderList(_request)) => { + match gateway_bridge::handle_provider_list().await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::ProviderListResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SettingsGet(_request)) => { + match self.current_settings_snapshot().await { + Ok(snapshot) => { + let settings_json = match serialize_settings_sync_payload(&snapshot) { + Ok(settings_json) => settings_json, + Err(error) => { + return self.send_error_response(request_id, 500, error).await; + } + }; + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SettingsGetResp( + proto::SettingsGetResponse { settings_json }, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SettingsUpdate(request)) => { + match parse_settings_sync_payload(&request.settings_json) { + Ok(snapshot) => { + if snapshot.get(SSH_PATCH_FIELD).is_some() { + let patch_payload = snapshot.clone(); + let apply_response = + match tauri::async_runtime::spawn_blocking(move || { + let mut conn = open_db()?; + apply_ssh_patch_with_conn(&mut conn, patch_payload) + }) + .await + .map_err(|e| format!("settings ssh patch join failed: {e}")) + { + Ok(Ok(response)) => response, + Ok(Err(error)) | Err(error) => { + return self + .send_error_response(request_id, 500, error) + .await; + } + }; + if let Some(conflict) = apply_response.conflict { + return self + .send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::SettingsUpdateResp( + proto::SettingsUpdateResponse { + accepted: false, + message: conflict, + }, + ), + ), + }) + .await; + } + + let fresh_snapshot = match self.current_settings_snapshot().await { + Ok(snapshot) => snapshot, + Err(error) => { + return self.send_error_response(request_id, 500, error).await; + } + }; + let merged_ssh = + fresh_snapshot.get("ssh").cloned().unwrap_or(Value::Null); + let event_payload = + match build_local_settings_update_event_payload_with_ssh( + snapshot.clone(), + merged_ssh, + ) { + Ok(payload) => payload, + Err(error) => { + return self + .send_error_response(request_id, 400, error) + .await; + } + }; + if let Err(error) = self + .app_handle + .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) + { + return self + .send_error_response( + request_id, + 500, + format!("emit gateway settings sync failed: {error}"), + ) + .await; + } + if let Err(error) = self.publish_settings_sync(fresh_snapshot).await { + eprintln!("publish gateway ssh settings sync failed: {error}"); + } + return self + .send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::SettingsUpdateResp( + proto::SettingsUpdateResponse { + accepted: true, + message: "ok".to_string(), + }, + ), + ), + }) + .await; + } + + let event_payload = + match build_local_settings_update_event_payload(snapshot.clone()) { + Ok(payload) => payload, + Err(error) => { + return self.send_error_response(request_id, 400, error).await; + } + }; + let public_update = match redact_gateway_settings_sync_payload(snapshot) { + Ok(payload) => payload, + Err(error) => { + return self.send_error_response(request_id, 400, error).await; + } + }; + // The update is a partial payload (only changed fields, e.g. + // {"theme":"dark"}). Overlay it onto the current full snapshot; + // storing it as-is would drop every other cached field and let + // rebuilt snapshots revert UI-only settings like theme. + let current_snapshot = match self.current_settings_snapshot().await { + Ok(snapshot) => snapshot, + Err(error) => { + return self.send_error_response(request_id, 500, error).await; + } + }; + let merged_snapshot = match merge_settings_update_into_snapshot( + current_snapshot, + public_update, + ) { + Ok(payload) => payload, + Err(error) => { + return self.send_error_response(request_id, 400, error).await; + } + }; + if let Err(error) = self.store_settings_snapshot(merged_snapshot) { + return self.send_error_response(request_id, 500, error).await; + } + match self + .app_handle + .emit(GATEWAY_SETTINGS_SYNC_EVENT, event_payload) + { + Ok(()) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::SettingsUpdateResp( + proto::SettingsUpdateResponse { + accepted: true, + message: "ok".to_string(), + }, + ), + ), + }) + .await + } + Err(error) => { + self.send_error_response( + request_id, + 500, + format!("emit gateway settings sync failed: {error}"), + ) + .await + } + } + } + Err(error) => self.send_error_response(request_id, 400, error).await, + } + } + Some(proto::gateway_envelope::Payload::SettingsResetSshKnownHost(request)) => { + let host = request.host.trim().to_string(); + let port = match u16::try_from(request.port) { + Ok(port) if port > 0 => port, + _ => { + return self + .send_error_response( + request_id, + 400, + "SSH port must be between 1 and 65535".to_string(), + ) + .await; + } + }; + match reset_runtime_ssh_known_host(&host, port) { + Ok(deleted) => { + let deleted = u32::try_from(deleted).unwrap_or(u32::MAX); + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::SettingsResetSshKnownHostResp( + proto::SettingsResetSshKnownHostResponse { deleted }, + ), + ), + }) + .await + } + Err(error) => self.send_error_response(request_id, 400, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsRoots(_request)) => { + match gateway_bridge::handle_fs_roots().await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsRootsResp(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsListDirs(request)) => { + match gateway_bridge::handle_fs_list_dirs(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsListDirsResp(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsCreateProjectFolder(request)) => { + match gateway_bridge::handle_fs_create_project_folder(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::FsCreateProjectFolderResp(response), + ), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsList(request)) => { + match gateway_bridge::handle_fs_list(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsListResp(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsReadEditableText(request)) => { + match gateway_bridge::handle_fs_read_editable_text(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsReadEditableTextResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsReadWorkspaceImage(request)) => { + match gateway_bridge::handle_fs_read_workspace_image(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some( + proto::agent_envelope::Payload::FsReadWorkspaceImageResp(response), + ), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsWriteText(request)) => { + match gateway_bridge::handle_fs_write_text(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsWriteTextResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsCreateDir(request)) => { + match gateway_bridge::handle_fs_create_dir(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsCreateDirResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsRename(request)) => { + match gateway_bridge::handle_fs_rename(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsRenameResp(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FsDelete(request)) => { + match gateway_bridge::handle_fs_delete(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FsDeleteResp(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SkillFilesList(_request)) => { + match gateway_bridge::handle_skill_files_list().await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SkillFilesListResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::FileMentionList(request)) => { + match gateway_bridge::handle_file_mention_list(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::FileMentionListResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::UploadReadableFiles(request)) => { + match gateway_bridge::handle_upload_readable_files(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::UploadReadableFilesResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::UploadedImagePreview(request)) => { + self.spawn_uploaded_image_preview_response(request_id, request) + } + Some(proto::gateway_envelope::Payload::MemoryManage(request)) => { + match gateway_bridge::handle_memory_manage(Arc::clone(&self.memory_store), request) + .await + { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::MemoryManageResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SkillMetadataRead(request)) => { + match gateway_bridge::handle_skill_metadata_read(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SkillMetadataReadResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SkillTextRead(request)) => { + match gateway_bridge::handle_skill_text_read(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SkillTextReadResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SkillManage(request)) => { + match gateway_bridge::handle_skill_manage(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SkillManageResp( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::GitRequest(request)) => { + match gateway_bridge::handle_git_request(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::GitResponse(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::TerminalRequest(request)) => { + match self.handle_terminal_request(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TerminalResponse( + response, + )), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + Some(proto::gateway_envelope::Payload::SftpRequest(request)) => { + match self.handle_sftp_request(request).await { + Ok(response) => { + self.send_agent_envelope(proto::AgentEnvelope { + request_id, + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SftpResponse(response)), + }) + .await + } + Err(error) => self.send_error_response(request_id, 500, error).await, + } + } + None => Ok(()), + } + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs b/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs new file mode 100644 index 00000000..4ffb7b38 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/history_sync.rs @@ -0,0 +1,107 @@ +use uuid::Uuid; + +use crate::commands::chat_history::ChatHistorySummary; + +use super::*; + +pub fn build_history_sync_upsert(summary: &ChatHistorySummary) -> GatewayHistorySyncEvent { + GatewayHistorySyncEvent { + kind: "upsert".to_string(), + conversation_id: summary.id.clone(), + conversation: Some(GatewayHistorySyncConversation { + id: summary.id.clone(), + title: summary.title.clone(), + provider_id: Some(summary.provider_id.clone()), + model: Some(summary.model.clone()), + session_id: summary.session_id.clone(), + cwd: summary.cwd.clone(), + created_at: summary.created_at, + updated_at: summary.updated_at, + message_count: summary.message_count, + is_pinned: summary.is_pinned, + pinned_at: summary.pinned_at, + is_shared: summary.is_shared, + }), + } +} + +pub fn build_history_sync_delete(conversation_id: impl Into) -> GatewayHistorySyncEvent { + let conversation_id = conversation_id.into(); + GatewayHistorySyncEvent { + kind: "delete".to_string(), + conversation_id, + conversation: None, + } +} + +pub(crate) fn build_history_sync_upsert_from_proto( + summary: &proto::ConversationSummary, +) -> GatewayHistorySyncEvent { + GatewayHistorySyncEvent { + kind: "upsert".to_string(), + conversation_id: summary.id.clone(), + conversation: Some(GatewayHistorySyncConversation { + id: summary.id.clone(), + title: summary.title.clone(), + provider_id: (!summary.provider_id.trim().is_empty()) + .then(|| summary.provider_id.clone()), + model: (!summary.model.trim().is_empty()).then(|| summary.model.clone()), + session_id: (!summary.session_id.trim().is_empty()).then(|| summary.session_id.clone()), + cwd: (!summary.cwd.trim().is_empty()).then(|| summary.cwd.clone()), + created_at: summary.created_at, + updated_at: summary.updated_at, + message_count: i64::from(summary.message_count), + is_pinned: summary.is_pinned, + pinned_at: (summary.pinned_at > 0).then_some(summary.pinned_at), + is_shared: summary.is_shared, + }), + } +} + +pub(crate) fn history_share_resolve_error_code(message: &str) -> i32 { + let normalized = message.trim(); + if normalized.is_empty() { + return 500; + } + if normalized.contains("分享 token 不能为空") { + return 400; + } + if normalized.contains("分享链接不存在或已关闭") || normalized.contains("未找到对应的历史对话") + { + return 404; + } + 500 +} + +pub(crate) fn build_history_sync_envelope( + event: GatewayHistorySyncEvent, +) -> Result { + let conversation = event + .conversation + .map(|conversation| proto::ConversationSummary { + id: conversation.id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + message_count: i32::try_from(conversation.message_count).unwrap_or(i32::MAX), + provider_id: conversation.provider_id.unwrap_or_default(), + model: conversation.model.unwrap_or_default(), + session_id: conversation.session_id.unwrap_or_default(), + cwd: conversation.cwd.unwrap_or_default(), + is_pinned: conversation.is_pinned, + pinned_at: conversation.pinned_at.unwrap_or_default(), + is_shared: conversation.is_shared, + }); + + Ok(proto::AgentEnvelope { + request_id: format!("history-sync-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::HistorySync( + proto::HistorySyncEvent { + kind: event.kind, + conversation, + conversation_id: event.conversation_id, + }, + )), + }) +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs new file mode 100644 index 00000000..514af977 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs @@ -0,0 +1,99 @@ +//! 网关控制器模块(拆分自原单文件 gateway.rs,代码逐字迁移,行为不变)。 +//! +//! - [`types`]:对外事件 / DTO 类型与事件名常量 +//! - [`controller`]:`GatewayController` 生命周期与公开 API(new/start/apply_config/publish_*) +//! - [`connection`]:gRPC 连接主循环、出站通道与端点构建 +//! - [`envelope_handler`]:网关入站信封(`GatewayEnvelope`)分发 +//! - [`terminal`]:终端请求处理、终端流与 proto 转换 +//! - [`sftp`]:SFTP 请求处理与 proto 转换 +//! - [`chat`]:聊天命令、聊天队列与聊天事件信封构建 +//! - [`chat_inbox`]:远程聊天收件箱、租约管理与 chat run ledger 记账 +//! - [`settings_sync`]:设置同步快照合并与信封构建 +//! - [`history_sync`]:会话历史同步事件与信封构建 +//! - [`util`]:时间戳与 JSON 字段工具 + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Once}; +use std::time::Duration; + +use serde_json::Value; +use tokio::sync::{mpsc, oneshot, watch}; + +use crate::commands::settings::RemoteSettingsPayload; +use crate::runtime::sftp::SftpSessionRegistry; +use crate::runtime::terminal::TerminalSessionRegistry; +use crate::services::chat_run_ledger::ChatRunLedger; +use crate::services::cron::CronManager; +use crate::services::memory::MemoryStore; +use crate::services::tunnel::{TunnelProxy, TunnelStore}; + +pub mod proto { + tonic::include_proto!("liveagent.gateway.v1"); +} + +mod chat; +mod chat_inbox; +mod connection; +mod controller; +mod envelope_handler; +mod history_sync; +mod settings_sync; +mod sftp; +mod terminal; +#[cfg(test)] +mod tests; +mod types; +mod util; + +pub(crate) use chat::*; +pub(crate) use chat_inbox::*; +pub(crate) use connection::*; +pub(crate) use history_sync::*; +pub use history_sync::{build_history_sync_delete, build_history_sync_upsert}; +pub(crate) use settings_sync::*; +pub(crate) use sftp::*; +pub(crate) use terminal::*; +pub use types::*; +pub(crate) use util::*; + +pub(crate) const UI_ONLY_SETTINGS_SYNC_FIELDS: &[&str] = &[ + "skills", + "chatRuntimeControls", + "customSettings", + "selectedModel", + "theme", + "locale", +]; +pub(crate) const GATEWAY_GRPC_MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; +pub(crate) const GATEWAY_RECONNECT_DELAY: Duration = Duration::from_secs(5); +pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MIN: Duration = Duration::from_millis(250); +pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MAX: Duration = Duration::from_secs(5); +pub(crate) const GATEWAY_TERMINAL_STREAM_STABLE_AFTER: Duration = Duration::from_secs(30); +pub(crate) const GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); +pub(crate) const GATEWAY_CHAT_LEASE_MS: u64 = 15_000; +pub(crate) const GATEWAY_CHAT_RUNNING_LEASE_MS: u64 = 30 * 60_000; +pub(crate) const GATEWAY_CHAT_LEASE_SWEEP_INTERVAL: Duration = Duration::from_secs(5); + +pub struct GatewayController { + app_handle: tauri::AppHandle, + cron_manager: Arc, + memory_store: Arc, + terminal_registry: Arc, + sftp_registry: Arc, + config_tx: watch::Sender, + runner_task: Mutex>>, + status: Mutex, + outbound_tx: Mutex>>, + terminal_stream_tx: Mutex>>, + settings_snapshot: Mutex>, + remote_chat_inbox: Mutex>, + chat_run_ledger: Mutex, + pub(crate) tunnel_store: TunnelStore, + pub(crate) tunnel_proxy: TunnelProxy, + pending_chat_queue_requests: Mutex>>, + terminal_forwarder_once: Once, + terminal_stream_forwarder_once: Once, + sftp_forwarder_once: Once, + remote_chat_inbox_sweeper_once: Once, + pub(crate) tunnel_store_once: Once, +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs b/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs new file mode 100644 index 00000000..ef1c6378 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/settings_sync.rs @@ -0,0 +1,167 @@ +use serde_json::Value; +use uuid::Uuid; + +use crate::commands::settings::{ + load_gateway_settings_sync_snapshot, open_db, redact_gateway_settings_sync_payload, + PROVIDER_API_KEY_UPDATES_FIELD, SSH_PATCH_FIELD, SSH_SECRET_UPDATES_FIELD, +}; + +use super::*; + +impl GatewayController { + pub(crate) async fn current_settings_snapshot(&self) -> Result { + let cached_snapshot = self + .settings_snapshot + .lock() + .map_err(|_| "gateway settings snapshot lock poisoned".to_string())? + .clone(); + + let db_snapshot = tauri::async_runtime::spawn_blocking(move || { + let conn = open_db()?; + load_gateway_settings_sync_snapshot(&conn) + }) + .await + .map_err(|e| format!("load gateway settings snapshot join failed: {e}"))??; + + let snapshot = merge_settings_sync_snapshot(db_snapshot, cached_snapshot.as_ref())?; + self.store_settings_snapshot(snapshot) + } + + pub(crate) fn store_settings_snapshot(&self, payload: Value) -> Result { + let snapshot = + redact_gateway_settings_sync_payload(normalize_settings_sync_payload(payload)?)?; + let mut guard = self + .settings_snapshot + .lock() + .map_err(|_| "gateway settings snapshot lock poisoned".to_string())?; + *guard = Some(snapshot.clone()); + Ok(snapshot) + } +} + +pub(crate) fn merge_settings_sync_snapshot( + snapshot: Value, + cached: Option<&Value>, +) -> Result { + let mut merged = match snapshot { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + + if let Some(Value::Object(cached_map)) = cached { + for field in UI_ONLY_SETTINGS_SYNC_FIELDS { + if let Some(value) = cached_map.get(*field) { + merged.insert((*field).to_string(), value.clone()); + } + } + } + + Ok(Value::Object(merged)) +} + +pub(crate) fn merge_settings_update_into_snapshot( + snapshot: Value, + update: Value, +) -> Result { + let mut merged = match snapshot { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + let update = match update { + Value::Object(map) => map, + _ => return Err("gateway settings update payload must be an object".to_string()), + }; + + for (field, value) in update { + // Remote settings are desktop-owned (loaded from the local DB on every + // snapshot rebuild); never let a remote client overwrite them. + if field == "remote" { + continue; + } + merged.insert(field, value); + } + + Ok(Value::Object(merged)) +} + +pub(crate) fn build_settings_sync_envelope(payload: Value) -> Result { + Ok(proto::AgentEnvelope { + request_id: format!("settings-sync-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SettingsSync( + proto::SettingsSyncEvent { + settings_json: serialize_settings_sync_payload(&payload)?, + }, + )), + }) +} + +pub(crate) fn normalize_settings_sync_payload(payload: Value) -> Result { + match payload { + Value::Null => Ok(Value::Object(serde_json::Map::new())), + Value::Object(_) => Ok(payload), + _ => Err("gateway settings sync payload must be an object".to_string()), + } +} + +pub(crate) fn build_local_settings_update_event_payload(payload: Value) -> Result { + let mut event = match payload { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + let provider_api_key_updates = event.remove(PROVIDER_API_KEY_UPDATES_FIELD); + let ssh_secret_updates = event.remove(SSH_SECRET_UPDATES_FIELD); + event.remove("remote"); + let mut public_event = match redact_gateway_settings_sync_payload(Value::Object(event))? { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + if let Some(updates) = provider_api_key_updates { + public_event.insert(PROVIDER_API_KEY_UPDATES_FIELD.to_string(), updates); + } + if let Some(updates) = ssh_secret_updates { + public_event.insert(SSH_SECRET_UPDATES_FIELD.to_string(), updates); + } + Ok(Value::Object(public_event)) +} + +pub(crate) fn build_local_settings_update_event_payload_with_ssh( + payload: Value, + ssh: Value, +) -> Result { + let mut event = match payload { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + let provider_api_key_updates = event.remove(PROVIDER_API_KEY_UPDATES_FIELD); + let ssh_secret_updates = event.remove(SSH_SECRET_UPDATES_FIELD); + event.remove("remote"); + event.remove(SSH_PATCH_FIELD); + event.insert("ssh".to_string(), ssh); + let mut public_event = match redact_gateway_settings_sync_payload(Value::Object(event))? { + Value::Object(map) => map, + _ => return Err("gateway settings sync payload must be an object".to_string()), + }; + if let Some(updates) = provider_api_key_updates { + public_event.insert(PROVIDER_API_KEY_UPDATES_FIELD.to_string(), updates); + } + if let Some(updates) = ssh_secret_updates { + public_event.insert(SSH_SECRET_UPDATES_FIELD.to_string(), updates); + } + Ok(Value::Object(public_event)) +} + +pub(crate) fn parse_settings_sync_payload(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + let payload = serde_json::from_str::(trimmed) + .map_err(|e| format!("parse gateway settings sync payload failed: {e}"))?; + normalize_settings_sync_payload(payload) +} + +pub(crate) fn serialize_settings_sync_payload(payload: &Value) -> Result { + serde_json::to_string(payload) + .map_err(|e| format!("serialize gateway settings sync payload failed: {e}")) +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/sftp.rs b/crates/agent-gui/src-tauri/src/services/gateway/sftp.rs new file mode 100644 index 00000000..bfa93455 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/sftp.rs @@ -0,0 +1,259 @@ +use uuid::Uuid; + +use crate::runtime::sftp::{ + SftpActionResponse, SftpEntry, SftpEventPayload, SftpListResponse, SftpStatResponse, + SftpTransferResponse, SftpTransferState, +}; + +use super::*; + +impl GatewayController { + pub(crate) async fn handle_sftp_request( + &self, + request: proto::SftpRequest, + ) -> Result { + if !self.config_tx.borrow().enable_web_ssh_terminal { + return Err("web SSH SFTP is disabled in desktop Remote settings".to_string()); + } + let action = request.action.trim().to_ascii_lowercase(); + match action.as_str() { + "list" => { + let side = if request.direction.trim().is_empty() { + "remote".to_string() + } else { + request.direction + }; + let path = sftp_side_path(&side, &request.local_path, &request.remote_path); + let response = self + .sftp_registry + .list( + request.session_id, + Some(request.project_path_key), + request.workdir, + side, + Some(path), + ) + .await?; + Ok(sftp_list_response_to_proto(action, response)) + } + "stat" | "probe" => { + let side = if request.direction.trim().is_empty() { + "remote".to_string() + } else { + request.direction + }; + let path = sftp_side_path(&side, &request.local_path, &request.remote_path); + let response = self + .sftp_registry + .stat( + request.session_id, + Some(request.project_path_key), + request.workdir, + side, + Some(path), + ) + .await?; + Ok(sftp_stat_response_to_proto(action, response)) + } + "mkdir" => { + let side = if request.direction.trim().is_empty() { + "remote".to_string() + } else { + request.direction + }; + let path = sftp_side_path(&side, &request.local_path, &request.remote_path); + let response = self + .sftp_registry + .mkdir( + request.session_id, + Some(request.project_path_key), + request.workdir, + side, + path, + ) + .await?; + Ok(sftp_action_response_to_proto(action, response)) + } + "rename" => { + let side = if request.direction.trim().is_empty() { + "remote".to_string() + } else { + request.direction + }; + let response = self + .sftp_registry + .rename( + request.session_id, + Some(request.project_path_key), + request.workdir, + side, + request.from_path, + request.to_path, + ) + .await?; + Ok(sftp_action_response_to_proto(action, response)) + } + "delete" => { + let side = if request.direction.trim().is_empty() { + "remote".to_string() + } else { + request.direction + }; + let path = sftp_side_path(&side, &request.local_path, &request.remote_path); + let response = self + .sftp_registry + .delete( + request.session_id, + Some(request.project_path_key), + request.workdir, + side, + path, + request.recursive, + ) + .await?; + Ok(sftp_action_response_to_proto(action, response)) + } + "transfer" => { + let response = self + .sftp_registry + .transfer( + request.session_id, + Some(request.project_path_key), + request.workdir, + request.direction, + request.from_path, + request.target_path, + request.recursive, + request.overwrite, + ) + .await?; + Ok(sftp_transfer_response_to_proto(action, response)) + } + "cancel" => { + self.sftp_registry + .cancel_transfer(request.session_id, request.from_path)?; + Ok(proto::SftpResponse { + action, + path: String::new(), + entries: Vec::new(), + entry: None, + exists: false, + transfer: None, + }) + } + _ => Err(format!("unsupported sftp action: {action}")), + } + } +} + +pub(crate) fn sftp_side_path(side: &str, local_path: &str, remote_path: &str) -> String { + if side.trim().eq_ignore_ascii_case("local") { + local_path.trim().to_string() + } else { + remote_path.trim().to_string() + } +} + +pub(crate) fn sftp_entry_to_proto(entry: SftpEntry) -> proto::SftpEntry { + proto::SftpEntry { + path: entry.path, + name: entry.name, + kind: entry.kind, + size_bytes: entry.size_bytes, + mtime: entry.mtime, + } +} + +pub(crate) fn sftp_transfer_to_proto(transfer: SftpTransferState) -> proto::SftpTransfer { + proto::SftpTransfer { + id: transfer.id, + session_id: transfer.session_id, + direction: transfer.direction, + status: transfer.status, + source_path: transfer.source_path, + target_path: transfer.target_path, + current_path: transfer.current_path, + bytes_done: transfer.bytes_done, + bytes_total: transfer.bytes_total, + files_done: transfer.files_done, + files_total: transfer.files_total, + error: transfer.error.unwrap_or_default(), + } +} + +pub(crate) fn sftp_list_response_to_proto( + action: String, + response: SftpListResponse, +) -> proto::SftpResponse { + proto::SftpResponse { + action, + path: response.path, + entries: response + .entries + .into_iter() + .map(sftp_entry_to_proto) + .collect(), + entry: None, + exists: false, + transfer: None, + } +} + +pub(crate) fn sftp_stat_response_to_proto( + action: String, + response: SftpStatResponse, +) -> proto::SftpResponse { + proto::SftpResponse { + action, + path: response + .entry + .as_ref() + .map(|entry| entry.path.clone()) + .unwrap_or_default(), + entries: Vec::new(), + entry: response.entry.map(sftp_entry_to_proto), + exists: response.exists, + transfer: None, + } +} + +pub(crate) fn sftp_action_response_to_proto( + action: String, + response: SftpActionResponse, +) -> proto::SftpResponse { + proto::SftpResponse { + action, + path: response.path, + entries: Vec::new(), + entry: response.entry.map(sftp_entry_to_proto), + exists: false, + transfer: response.transfer.map(sftp_transfer_to_proto), + } +} + +pub(crate) fn sftp_transfer_response_to_proto( + action: String, + response: SftpTransferResponse, +) -> proto::SftpResponse { + proto::SftpResponse { + action, + path: response.transfer.target_path.clone(), + entries: Vec::new(), + entry: None, + exists: false, + transfer: Some(sftp_transfer_to_proto(response.transfer)), + } +} + +pub(crate) fn build_sftp_event_envelope(payload: SftpEventPayload) -> proto::AgentEnvelope { + proto::AgentEnvelope { + request_id: format!("sftp-event-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::SftpEvent( + proto::SftpEvent { + kind: payload.kind, + transfer: Some(sftp_transfer_to_proto(payload.transfer)), + }, + )), + } +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs new file mode 100644 index 00000000..41598271 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs @@ -0,0 +1,851 @@ +use std::sync::Arc; +use std::time::Instant; + +use tokio::sync::{mpsc, watch}; +use tokio_stream::wrappers::ReceiverStream; +use uuid::Uuid; + +use crate::commands::settings::RemoteSettingsPayload; +use crate::runtime::project_path::{ + project_path_key as normalize_project_path_key, project_path_keys_equal, +}; +use crate::runtime::terminal::{ + terminal_shell_options, SshTerminalTabRecord, SshTerminalTabsSnapshot, TerminalEventPayload, + TerminalSessionRecord, TerminalShellOption, TerminalSnapshotResponse, + TerminalSshCreateResponse, TerminalStreamEventPayload, TerminalStreamSnapshotResponse, +}; + +use super::*; + +impl GatewayController { + pub(crate) fn spawn_terminal_stream( + self: &Arc, + client: proto::agent_gateway_client::AgentGatewayClient, + config: RemoteSettingsPayload, + stop_rx: watch::Receiver, + ) -> tauri::async_runtime::JoinHandle<()> { + let controller = Arc::clone(self); + tauri::async_runtime::spawn(async move { + controller + .run_terminal_stream(client, config, stop_rx) + .await; + }) + } + + pub(crate) async fn run_terminal_stream( + self: Arc, + client: proto::agent_gateway_client::AgentGatewayClient, + config: RemoteSettingsPayload, + mut stop_rx: watch::Receiver, + ) { + let mut reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; + + loop { + if *stop_rx.borrow() { + break; + } + + let attempt_started = Instant::now(); + let result = Arc::clone(&self) + .run_terminal_stream_once(client.clone(), config.clone(), stop_rx.clone()) + .await; + if *stop_rx.borrow() { + break; + } + self.set_terminal_stream_sender(None); + + if attempt_started.elapsed() >= GATEWAY_TERMINAL_STREAM_STABLE_AFTER { + reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; + } + match result { + Ok(()) => eprintln!("gateway terminal stream closed; reconnecting"), + Err(error) => eprintln!("gateway terminal stream stopped: {error}; reconnecting"), + } + + let delay = reconnect_delay; + reconnect_delay = + std::cmp::min(reconnect_delay * 2, GATEWAY_TERMINAL_STREAM_RECONNECT_MAX); + tokio::select! { + changed = stop_rx.changed() => { + if changed.is_err() || *stop_rx.borrow() { + break; + } + } + _ = tokio::time::sleep(delay) => {} + } + } + + self.set_terminal_stream_sender(None); + } + + pub(crate) async fn run_terminal_stream_once( + self: Arc, + mut client: proto::agent_gateway_client::AgentGatewayClient, + config: RemoteSettingsPayload, + mut stop_rx: watch::Receiver, + ) -> Result<(), String> { + let (terminal_tx, terminal_rx) = mpsc::channel::(4096); + + let result = async { + queue_terminal_stream_handshake_frame(&terminal_tx)?; + let mut request = tonic::Request::new(ReceiverStream::new(terminal_rx)); + insert_bearer_metadata(request.metadata_mut(), &config.token)?; + let response = tokio::select! { + changed = stop_rx.changed() => { + if changed.is_err() || *stop_rx.borrow() { + return Ok(()); + } + return Ok(()); + } + response = client.agent_terminal_connect(request) => { + response.map_err(|error| { + format_gateway_terminal_stream_rpc_error("open", &error, &config) + })? + } + }; + self.set_terminal_stream_sender(Some(terminal_tx.clone())); + let mut inbound = response.into_inner(); + let mut keepalive = tokio::time::interval(GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + keepalive.tick().await; + loop { + tokio::select! { + changed = stop_rx.changed() => { + if changed.is_err() || *stop_rx.borrow() { + return Ok(()); + } + } + _ = keepalive.tick() => { + queue_terminal_stream_keepalive_frame(&terminal_tx).await?; + } + message = inbound.message() => { + match message { + Ok(Some(frame)) => { + if let Err(error) = self.handle_terminal_stream_frame(frame).await { + eprintln!("handle gateway terminal stream frame failed: {error}"); + } + } + Ok(None) => return Ok(()), + Err(error) => { + return Err(format_gateway_terminal_stream_rpc_error("receive", &error, &config)) + } + } + } + } + } + } + .await; + + self.clear_terminal_stream_sender_if_current(&terminal_tx); + result + } + + pub(crate) async fn handle_terminal_stream_frame( + &self, + frame: proto::TerminalStreamFrame, + ) -> Result<(), String> { + let kind = frame.kind.trim().to_ascii_lowercase(); + let stream_id = frame.stream_id.clone(); + let session_id = frame.session_id.clone(); + let project_path_key = frame.project_path_key.clone(); + let result = match kind.as_str() { + "attach" => { + self.ensure_terminal_stream_allowed(&frame)?; + let snapshot = self.terminal_registry.stream_attach( + frame.session_id.clone(), + optional_proto_usize(frame.max_bytes), + )?; + self.send_terminal_stream_frame(terminal_stream_snapshot_to_proto( + stream_id.clone(), + snapshot, + )) + .await + } + "input" => { + self.ensure_terminal_stream_allowed(&frame)?; + self.terminal_registry + .input_bytes_from_remote(frame.session_id.clone(), frame.data.clone())?; + Ok(()) + } + "resize" => { + self.ensure_terminal_stream_allowed(&frame)?; + self.terminal_registry.stream_resize( + frame.session_id.clone(), + optional_proto_u16(frame.cols).unwrap_or(80), + optional_proto_u16(frame.rows).unwrap_or(24), + )?; + Ok(()) + } + "detach" => Ok(()), + "" => Err("terminal stream frame kind is required".to_string()), + other => Err(format!("unsupported terminal stream frame: {other}")), + }; + + if let Err(error) = result { + let _ = self + .send_terminal_stream_frame(terminal_stream_error_frame( + stream_id, + session_id, + project_path_key, + error.clone(), + )) + .await; + return Err(error); + } + Ok(()) + } + + pub(crate) async fn send_terminal_stream_frame( + &self, + frame: proto::TerminalStreamFrame, + ) -> Result<(), String> { + let sender = self.current_terminal_stream_sender()?; + sender + .send(frame) + .await + .map_err(|error| format!("send terminal stream frame failed: {error}")) + } + + pub(crate) async fn handle_terminal_request( + &self, + request: proto::TerminalRequest, + ) -> Result { + let action = request.action.trim().to_ascii_lowercase(); + self.ensure_terminal_request_allowed(&action, &request)?; + match action.as_str() { + "shell_options" => { + let options = terminal_shell_options(); + Ok(proto::TerminalResponse { + action, + sessions: Vec::new(), + session: None, + output: Vec::new(), + truncated: false, + shell_options: options + .options + .into_iter() + .map(terminal_shell_option_to_proto) + .collect(), + default_shell: options.default_shell, + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + }) + } + "list" => { + let project_path_key = normalize_project_path_key(&request.project_path_key); + let project_filter = (!project_path_key.is_empty()).then_some(project_path_key); + let config = self.config_tx.borrow().clone(); + let sessions = self + .terminal_registry + .list(project_filter) + .sessions + .into_iter() + .filter(|session| { + if session.kind.trim() == "ssh" { + config.enable_web_ssh_terminal + } else { + config.enable_web_terminal + } + }) + .map(terminal_session_to_proto) + .collect(); + Ok(proto::TerminalResponse { + action, + sessions, + session: None, + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + }) + } + "create" => { + let project_path_key = + required_terminal_project_path_key(&request.project_path_key)?; + let snapshot = self.terminal_registry.create( + request.cwd, + Some(project_path_key), + optional_proto_text(request.shell), + optional_proto_text(request.title), + optional_proto_u16(request.cols), + optional_proto_u16(request.rows), + )?; + Ok(terminal_create_snapshot_response_to_proto(action, snapshot)) + } + "create_ssh" => { + let project_path_key = + required_terminal_project_path_key(&request.project_path_key)?; + let response = self + .terminal_registry + .clone() + .create_ssh( + request.cwd, + Some(project_path_key), + request.ssh_host_id, + optional_proto_text(request.title), + optional_proto_u16(request.cols), + optional_proto_u16(request.rows), + request.sftp_enabled, + ) + .await?; + Ok(terminal_ssh_create_response_to_proto(action, response)) + } + "answer_ssh_prompt" => { + let response = self + .terminal_registry + .clone() + .answer_ssh_prompt( + request.prompt_id, + optional_proto_text(request.prompt_answer), + request.trust_host_key, + ) + .await?; + Ok(terminal_ssh_create_response_to_proto(action, response)) + } + "ssh_latency" => { + self.ensure_terminal_session_in_project( + &request.session_id, + &request.project_path_key, + )?; + let latency = self + .terminal_registry + .ssh_latency(request.session_id) + .await?; + Ok(proto::TerminalResponse { + action, + sessions: Vec::new(), + session: None, + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: latency.latency_ms, + ssh_tabs: None, + }) + } + "cancel_ssh_prompt" => { + self.terminal_registry + .cancel_ssh_prompt(request.prompt_id)?; + Ok(proto::TerminalResponse { + action, + sessions: Vec::new(), + session: None, + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + }) + } + "ssh_tabs_list" => { + let project_path_key = + required_terminal_project_path_key(&request.project_path_key)?; + let snapshot = self + .terminal_registry + .ssh_terminal_tabs_list(project_path_key)?; + Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) + } + "ssh_tab_open" => { + let snapshot = self + .terminal_registry + .ssh_terminal_tab_open(request.session_id, request.tab_kind)?; + Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) + } + "ssh_tab_close" => { + let snapshot = self + .terminal_registry + .ssh_terminal_tab_close(request.tab_id)?; + Ok(terminal_ssh_tabs_response_to_proto(action, snapshot)) + } + "rename" => { + self.ensure_terminal_session_in_project( + &request.session_id, + &request.project_path_key, + )?; + let session = self + .terminal_registry + .rename(request.session_id, request.title)?; + Ok(terminal_record_response_to_proto(action, session)) + } + "close" => { + self.ensure_terminal_session_in_project( + &request.session_id, + &request.project_path_key, + )?; + let session = self.terminal_registry.close(request.session_id)?; + self.sftp_registry.close_session(&session.id); + Ok(terminal_record_response_to_proto(action, session)) + } + "close_project" => { + let project_path_key = + required_terminal_project_path_key(&request.project_path_key)?; + let config = self.config_tx.borrow().clone(); + let sessions: Vec = self + .terminal_registry + .list(Some(project_path_key)) + .sessions + .into_iter() + .filter(|session| { + if session.kind.trim() == "ssh" { + config.enable_web_ssh_terminal + } else { + config.enable_web_terminal + } + }) + .filter_map(|session| self.terminal_registry.close(session.id).ok()) + .collect(); + for session in &sessions { + self.sftp_registry.close_session(&session.id); + } + Ok(terminal_list_response_to_proto(action, sessions)) + } + "" => Err("terminal action is required".to_string()), + other => Err(format!("unsupported terminal action: {other}")), + } + } + + pub(crate) fn ensure_terminal_session_in_project( + &self, + session_id: &str, + project_path_key: &str, + ) -> Result<(), String> { + let project_path_key = required_terminal_project_path_key(project_path_key)?; + let session = self + .terminal_registry + .session_record(session_id.trim().to_string())?; + if !project_path_keys_equal(&session.project_path_key, &project_path_key) { + return Err("terminal session is outside the requested project".to_string()); + } + Ok(()) + } + + pub(crate) fn ensure_terminal_request_allowed( + &self, + action: &str, + request: &proto::TerminalRequest, + ) -> Result<(), String> { + let config = self.config_tx.borrow().clone(); + match action { + "create_ssh" | "answer_ssh_prompt" | "cancel_ssh_prompt" | "ssh_tabs_list" + | "ssh_tab_open" | "ssh_tab_close" => { + if config.enable_web_ssh_terminal { + Ok(()) + } else { + Err("web SSH terminal is disabled in desktop Remote settings".to_string()) + } + } + "list" => { + if config.enable_web_terminal || config.enable_web_ssh_terminal { + Ok(()) + } else { + Err("web terminal is disabled in desktop Remote settings".to_string()) + } + } + "attach" | "input" | "resize" | "rename" | "close" | "ssh_latency" => { + let session = self + .terminal_registry + .session_record(request.session_id.trim().to_string())?; + let allowed = if session.kind.trim() == "ssh" { + config.enable_web_ssh_terminal + } else { + config.enable_web_terminal + }; + if allowed { + Ok(()) + } else if session.kind.trim() == "ssh" { + Err("web SSH terminal is disabled in desktop Remote settings".to_string()) + } else { + Err("web terminal is disabled in desktop Remote settings".to_string()) + } + } + "close_project" => { + if config.enable_web_terminal || config.enable_web_ssh_terminal { + Ok(()) + } else { + Err("web terminal is disabled in desktop Remote settings".to_string()) + } + } + _ => { + if config.enable_web_terminal { + Ok(()) + } else { + Err("web terminal is disabled in desktop Remote settings".to_string()) + } + } + } + } + + pub(crate) fn ensure_terminal_stream_allowed( + &self, + frame: &proto::TerminalStreamFrame, + ) -> Result<(), String> { + let action = frame.kind.trim().to_ascii_lowercase(); + let request = proto::TerminalRequest { + action: action.clone(), + session_id: frame.session_id.clone(), + project_path_key: frame.project_path_key.clone(), + cols: frame.cols, + rows: frame.rows, + max_bytes: frame.max_bytes, + ..Default::default() + }; + match action.as_str() { + "attach" | "input" | "resize" => { + self.ensure_terminal_session_in_project( + &request.session_id, + &request.project_path_key, + )?; + self.ensure_terminal_request_allowed(&action, &request) + } + other => Err(format!("unsupported terminal stream frame: {other}")), + } + } +} + +pub(crate) fn required_terminal_project_path_key(value: &str) -> Result { + let project_path_key = normalize_project_path_key(value); + if project_path_key.is_empty() { + return Err("project_path_key is required".to_string()); + } + Ok(project_path_key) +} + +pub(crate) fn terminal_u128_to_u64(value: u128) -> u64 { + value.min(u128::from(u64::MAX)) as u64 +} + +pub(crate) fn terminal_session_to_proto(session: TerminalSessionRecord) -> proto::TerminalSession { + proto::TerminalSession { + id: session.id, + project_path_key: normalize_project_path_key(&session.project_path_key), + cwd: session.cwd, + shell: session.shell, + title: session.title, + pid: session.pid.unwrap_or_default(), + cols: u32::from(session.cols), + rows: u32::from(session.rows), + created_at: terminal_u128_to_u64(session.created_at), + updated_at: terminal_u128_to_u64(session.updated_at), + finished_at: session + .finished_at + .map(terminal_u128_to_u64) + .unwrap_or_default(), + exit_code: session.exit_code.unwrap_or_default(), + running: session.running, + kind: if session.kind.trim() == "ssh" { + "ssh".to_string() + } else { + "local".to_string() + }, + ssh: session.ssh.map(|ssh| proto::TerminalSshMetadata { + host_id: ssh.host_id, + host_name: ssh.host_name, + username: ssh.username, + host: ssh.host, + port: u32::from(ssh.port), + auth_type: ssh.auth_type, + status: ssh.status, + reconnect_attempt: u32::from(ssh.reconnect_attempt), + reconnect_max_attempts: u32::from(ssh.reconnect_max_attempts), + sftp_enabled: ssh.sftp_enabled, + }), + } +} + +pub(crate) fn terminal_shell_option_to_proto( + option: TerminalShellOption, +) -> proto::TerminalShellOption { + proto::TerminalShellOption { + id: option.id, + label: option.label, + command: option.command, + } +} + +pub(crate) fn terminal_list_response_to_proto( + action: String, + sessions: Vec, +) -> proto::TerminalResponse { + proto::TerminalResponse { + action, + sessions: sessions + .into_iter() + .map(terminal_session_to_proto) + .collect(), + session: None, + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + } +} + +pub(crate) fn terminal_record_response_to_proto( + action: String, + session: TerminalSessionRecord, +) -> proto::TerminalResponse { + proto::TerminalResponse { + action, + sessions: Vec::new(), + session: Some(terminal_session_to_proto(session)), + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + } +} + +pub(crate) fn terminal_create_snapshot_response_to_proto( + action: String, + snapshot: TerminalSnapshotResponse, +) -> proto::TerminalResponse { + proto::TerminalResponse { + action, + sessions: Vec::new(), + session: Some(terminal_session_to_proto(snapshot.session)), + output: snapshot.output_bytes, + truncated: snapshot.truncated, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: snapshot.output_start_offset, + output_end_offset: snapshot.output_end_offset, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: None, + } +} + +pub(crate) fn terminal_ssh_create_response_to_proto( + action: String, + response: TerminalSshCreateResponse, +) -> proto::TerminalResponse { + proto::TerminalResponse { + action, + sessions: Vec::new(), + session: response.session.map(terminal_session_to_proto), + output: response.output_bytes, + truncated: response.truncated, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: response.output_start_offset, + output_end_offset: response.output_end_offset, + ssh_prompt: response.ssh_prompt.map(|prompt| proto::TerminalSshPrompt { + id: prompt.id, + kind: prompt.kind, + host_id: prompt.host_id, + host_name: prompt.host_name, + host: prompt.host, + port: u32::from(prompt.port), + message: prompt.message, + fingerprint_sha256: prompt.fingerprint_sha256, + key_type: prompt.key_type, + answer_echo: prompt.answer_echo, + }), + latency_ms: 0, + ssh_tabs: None, + } +} + +pub(crate) fn terminal_ssh_tabs_response_to_proto( + action: String, + snapshot: SshTerminalTabsSnapshot, +) -> proto::TerminalResponse { + proto::TerminalResponse { + action, + sessions: Vec::new(), + session: None, + output: Vec::new(), + truncated: false, + shell_options: Vec::new(), + default_shell: String::new(), + output_start_offset: 0, + output_end_offset: 0, + ssh_prompt: None, + latency_ms: 0, + ssh_tabs: Some(ssh_terminal_tabs_to_proto(snapshot)), + } +} + +pub(crate) fn terminal_stream_snapshot_to_proto( + stream_id: String, + snapshot: TerminalStreamSnapshotResponse, +) -> proto::TerminalStreamFrame { + let project_path_key = normalize_project_path_key(&snapshot.session.project_path_key); + let session_id = snapshot.session.id.clone(); + proto::TerminalStreamFrame { + kind: "snapshot".to_string(), + stream_id, + session_id, + project_path_key, + seq: 0, + start_offset: snapshot.output_start_offset, + end_offset: snapshot.output_end_offset, + cols: u32::from(snapshot.session.cols), + rows: u32::from(snapshot.session.rows), + max_bytes: 0, + truncated: snapshot.truncated, + error: String::new(), + session: Some(terminal_session_to_proto(snapshot.session)), + data: snapshot.bytes, + } +} + +pub(crate) fn build_terminal_stream_output_frame( + payload: TerminalStreamEventPayload, +) -> proto::TerminalStreamFrame { + proto::TerminalStreamFrame { + kind: payload.kind, + stream_id: String::new(), + session_id: payload.session_id, + project_path_key: normalize_project_path_key(&payload.project_path_key), + seq: 0, + start_offset: payload.start_offset, + end_offset: payload.end_offset, + cols: 0, + rows: 0, + max_bytes: 0, + truncated: false, + error: String::new(), + session: None, + data: payload.bytes, + } +} + +pub(crate) fn terminal_stream_error_frame( + stream_id: String, + session_id: String, + project_path_key: String, + error: String, +) -> proto::TerminalStreamFrame { + proto::TerminalStreamFrame { + kind: "error".to_string(), + stream_id, + session_id, + project_path_key: normalize_project_path_key(&project_path_key), + seq: 0, + start_offset: 0, + end_offset: 0, + cols: 0, + rows: 0, + max_bytes: 0, + truncated: false, + error, + session: None, + data: Vec::new(), + } +} + +pub(crate) fn ssh_terminal_tab_to_proto(tab: SshTerminalTabRecord) -> proto::TerminalSshTab { + proto::TerminalSshTab { + id: tab.id, + session_id: tab.session_id, + project_path_key: normalize_project_path_key(&tab.project_path_key), + kind: tab.kind, + created_at: tab.created_at as u64, + updated_at: tab.updated_at as u64, + } +} + +pub(crate) fn ssh_terminal_tabs_to_proto( + snapshot: SshTerminalTabsSnapshot, +) -> proto::TerminalSshTabsSnapshot { + proto::TerminalSshTabsSnapshot { + project_path_key: normalize_project_path_key(&snapshot.project_path_key), + tabs: snapshot + .tabs + .into_iter() + .map(ssh_terminal_tab_to_proto) + .collect(), + revision: snapshot.revision, + } +} + +pub(crate) fn build_terminal_event_envelope(payload: TerminalEventPayload) -> proto::AgentEnvelope { + proto::AgentEnvelope { + request_id: format!("terminal-event-{}", Uuid::new_v4()), + timestamp: now_unix_seconds(), + payload: Some(proto::agent_envelope::Payload::TerminalEvent( + proto::TerminalEvent { + kind: payload.kind, + session_id: payload.session_id, + project_path_key: normalize_project_path_key(&payload.project_path_key), + session: payload.session.map(terminal_session_to_proto), + data: payload.data.unwrap_or_default(), + output_start_offset: payload.output_start_offset.unwrap_or_default(), + output_end_offset: payload.output_end_offset.unwrap_or_default(), + ssh_tabs: payload.ssh_tabs.map(ssh_terminal_tabs_to_proto), + }, + )), + } +} + +pub(crate) fn queue_terminal_stream_handshake_frame( + sender: &mpsc::Sender, +) -> Result<(), String> { + // Some HTTP/2 proxies do not fully establish a bidi stream until the client + // sends its first DATA frame. `detach` is a gateway no-op and is not forwarded + // to browser terminal subscribers. + sender + .try_send(terminal_stream_noop_frame("desktop-handshake")) + .map_err(|error| format!("queue gateway terminal stream handshake failed: {error}")) +} + +pub(crate) async fn queue_terminal_stream_keepalive_frame( + sender: &mpsc::Sender, +) -> Result<(), String> { + sender + .send(terminal_stream_noop_frame("desktop-keepalive")) + .await + .map_err(|error| format!("queue gateway terminal stream keepalive failed: {error}")) +} + +pub(crate) fn terminal_stream_noop_frame(prefix: &str) -> proto::TerminalStreamFrame { + proto::TerminalStreamFrame { + kind: "detach".to_string(), + stream_id: format!("{}-{}", prefix.trim(), Uuid::new_v4()), + ..Default::default() + } +} + +pub(crate) fn format_gateway_terminal_stream_rpc_error( + phase: &str, + error: &tonic::Status, + config: &RemoteSettingsPayload, +) -> String { + let message = error.to_string(); + if !is_h2_protocol_error(&message) { + return format!("gateway terminal stream {phase} failed: {message}"); + } + + let endpoint = build_grpc_url(config).unwrap_or_else(|_| "invalid endpoint".to_string()); + format!( + "gateway terminal stream {phase} failed: {message}. \ + The gateway terminal stream requires a gRPC endpoint that supports HTTP/2 bidi streams; \ + check Remote gRPC Endpoint / gRPC port. Current endpoint: {endpoint}" + ) +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs new file mode 100644 index 00000000..e917591c --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -0,0 +1,796 @@ + +use super::{ + build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, + build_gateway_runtime_status_envelope, build_grpc_url, + build_local_settings_update_event_payload, chat_event_is_terminal, + format_gateway_terminal_stream_rpc_error, history_share_resolve_error_code, + merge_settings_sync_snapshot, merge_settings_update_into_snapshot, proto, + queue_terminal_stream_handshake_frame, required_terminal_project_path_key, + set_disconnected_status, GatewayChatRequestEvent, GatewayChatRuntimeSnapshot, + GatewayController, GatewayStatusSnapshot, RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, + GATEWAY_CHAT_RUNNING_LEASE_MS, +}; +use crate::commands::settings::RemoteSettingsPayload; +use serde_json::{json, Value}; +use std::time::{Duration, Instant}; + +fn gateway_chat_request( + request_id: &str, + client_request_id: &str, + conversation_id: &str, + message: &str, +) -> GatewayChatRequestEvent { + GatewayChatRequestEvent { + request_id: request_id.to_string(), + conversation_id: conversation_id.to_string(), + client_request_id: client_request_id.to_string(), + message: message.to_string(), + rebased: false, + base_message_ref: None, + selected_model: None, + runtime_controls: None, + execution_mode: String::new(), + workdir: String::new(), + selected_system_tools: Vec::new(), + uploaded_files: Vec::new(), + queue_policy: String::new(), + } +} + +fn remote_chat_record( + request: GatewayChatRequestEvent, + state: &str, + started: bool, + now: Instant, +) -> RemoteChatInboxRecord { + RemoteChatInboxRecord { + request, + state: state.to_string(), + lease_owner: Some("worker-1".to_string()), + lease_expires_at: Some(now + Duration::from_secs(30)), + attempt: 1, + started, + last_error: None, + created_at: now - Duration::from_secs(10), + updated_at: now, + } +} + +#[test] +fn gateway_chat_command_mapping_preserves_rebase_signal() { + let request = proto::ChatRequest { + conversation_id: "conversation-1".to_string(), + client_request_id: "client-1".to_string(), + message: "edited".to_string(), + execution_mode: "tools".to_string(), + workdir: "/workspace".to_string(), + selected_system_tools: vec!["http_get_test".to_string()], + ..Default::default() + }; + + let event = GatewayController::build_gateway_chat_request_event( + "run-1".to_string(), + request, + true, + Some(proto::ChatMessageRef { + segment_index: 2, + message_index: 4, + segment_id: "segment-c".to_string(), + message_id: "user-c".to_string(), + role: "user".to_string(), + content_hash: "fnv1a32:00000000".to_string(), + }), + ); + + assert_eq!(event.request_id, "run-1"); + assert_eq!(event.conversation_id, "conversation-1"); + assert_eq!(event.client_request_id, "client-1"); + assert_eq!(event.message, "edited"); + assert!(event.rebased); + let base_message_ref = event + .base_message_ref + .as_ref() + .expect("base message ref should be preserved"); + assert_eq!(base_message_ref.segment_index, 2); + assert_eq!(base_message_ref.message_index, 4); + assert_eq!(base_message_ref.segment_id, "segment-c"); + assert_eq!(base_message_ref.message_id, "user-c"); + assert_eq!(base_message_ref.role, "user"); + assert_eq!(base_message_ref.content_hash, "fnv1a32:00000000"); + assert_eq!(event.execution_mode, "tools"); + assert_eq!(event.workdir, "/workspace"); + assert_eq!(event.selected_system_tools, vec!["http_get_test"]); +} + +#[test] +fn chat_runtime_snapshot_envelope_preserves_live_projection() { + let envelope = build_chat_runtime_snapshot_envelope(GatewayChatRuntimeSnapshot { + conversation_id: " conversation-1 ".to_string(), + run_id: " run-1 ".to_string(), + client_request_id: Some("client-1".to_string()), + worker_id: Some("worker-1".to_string()), + state: " running ".to_string(), + cwd: Some("/workspace".to_string()), + updated_at: 1_772_000_000_000, + revision: 7, + entries_json: r#"[{"id":"u1","kind":"user","text":"hello","attachments":[]}]"#.to_string(), + tool_status: Some("Thinking...".to_string()), + tool_status_is_compaction: true, + }) + .expect("runtime snapshot envelope should be valid"); + + assert_eq!(envelope.request_id, "chat-runtime-snapshot-run-1-7"); + match envelope.payload { + Some(proto::agent_envelope::Payload::ChatRuntimeSnapshot(snapshot)) => { + assert_eq!(snapshot.conversation_id, "conversation-1"); + assert_eq!(snapshot.run_id, "run-1"); + assert_eq!(snapshot.client_request_id, "client-1"); + assert_eq!(snapshot.worker_id, "worker-1"); + assert_eq!(snapshot.state, "running"); + assert_eq!(snapshot.cwd, "/workspace"); + assert_eq!(snapshot.updated_at, 1_772_000_000_000); + assert_eq!(snapshot.revision, 7); + assert!(snapshot.entries_json.contains("hello")); + assert_eq!(snapshot.tool_status, "Thinking..."); + assert!(snapshot.tool_status_is_compaction); + } + other => panic!("unexpected payload: {other:?}"), + } +} + +#[test] +fn chat_runtime_snapshot_envelope_rejects_missing_identity() { + let err = build_chat_runtime_snapshot_envelope(GatewayChatRuntimeSnapshot { + conversation_id: "conversation-1".to_string(), + run_id: " ".to_string(), + client_request_id: None, + worker_id: None, + state: "running".to_string(), + cwd: None, + updated_at: 0, + revision: 1, + entries_json: String::new(), + tool_status: None, + tool_status_is_compaction: false, + }) + .expect_err("empty run id should be rejected"); + + assert!(err.contains("run_id")); +} + +#[test] +fn remote_chat_started_records_use_running_lease() { + let now = Instant::now(); + let queued = remote_chat_record( + gateway_chat_request("request-1", "client-1", "conversation-1", "hello"), + "queued", + false, + now, + ); + let running = remote_chat_record( + gateway_chat_request("request-2", "client-2", "conversation-2", "hello"), + "running", + true, + now, + ); + + assert_eq!( + GatewayController::remote_chat_record_lease_ms(&queued), + GATEWAY_CHAT_LEASE_MS + ); + assert_eq!( + GatewayController::remote_chat_record_lease_ms(&running), + GATEWAY_CHAT_RUNNING_LEASE_MS + ); + assert!(GATEWAY_CHAT_RUNNING_LEASE_MS > GATEWAY_CHAT_LEASE_MS); +} + +#[test] +fn duplicate_remote_chat_request_preserves_running_record() { + let now = Instant::now(); + let mut record = remote_chat_record( + gateway_chat_request("request-1", "client-1", "conversation-1", "first"), + "running", + true, + now, + ); + let original_lease_owner = record.lease_owner.clone(); + let original_lease_expires_at = record.lease_expires_at; + + GatewayController::merge_duplicate_remote_chat_request( + &mut record, + gateway_chat_request("request-2", "client-1", "conversation-2", "replayed"), + now + Duration::from_secs(1), + ); + + assert_eq!(record.request.request_id, "request-1"); + assert_eq!(record.request.client_request_id, "client-1"); + assert_eq!(record.request.conversation_id, "conversation-1"); + assert_eq!(record.request.message, "first"); + assert_eq!(record.state, "running"); + assert!(record.started); + assert_eq!(record.lease_owner, original_lease_owner); + assert_eq!(record.lease_expires_at, original_lease_expires_at); + assert_eq!( + GatewayController::remote_chat_record_control_type(&record), + "started" + ); + assert!(!GatewayController::remote_chat_record_should_wake_runtime( + &record, + now + Duration::from_secs(1), + )); +} + +#[test] +fn duplicate_queued_remote_chat_request_keeps_canonical_request_id() { + let now = Instant::now(); + let mut record = remote_chat_record( + gateway_chat_request("request-1", "client-1", "conversation-1", "first"), + "queued", + false, + now, + ); + record.lease_owner = None; + record.lease_expires_at = None; + + GatewayController::merge_duplicate_remote_chat_request( + &mut record, + gateway_chat_request("request-2", "client-1", "conversation-2", "replayed"), + now + Duration::from_secs(1), + ); + + assert_eq!(record.request.request_id, "request-1"); + assert_eq!(record.request.client_request_id, "client-1"); + assert_eq!(record.request.conversation_id, "conversation-2"); + assert_eq!(record.request.message, "replayed"); + assert_eq!(record.state, "queued"); + assert!(!record.started); + assert!(GatewayController::remote_chat_record_should_wake_runtime( + &record, + now + Duration::from_secs(1), + )); +} + +#[test] +fn conversation_cancel_preserves_gui_queued_remote_requests() { + let now = Instant::now(); + let queued_in_gui = remote_chat_record( + gateway_chat_request("request-1", "client-1", "conversation-1", "first"), + "queued_in_gui", + false, + now, + ); + let queued = remote_chat_record( + gateway_chat_request("request-2", "client-2", "conversation-1", "second"), + "queued", + false, + now, + ); + let claimed = remote_chat_record( + gateway_chat_request("request-3", "client-3", "conversation-1", "third"), + "claimed", + false, + now, + ); + let running = remote_chat_record( + gateway_chat_request("request-4", "client-4", "conversation-1", "fourth"), + "running", + true, + now, + ); + + assert!(!GatewayController::remote_chat_record_should_cancel_for_conversation(&queued_in_gui)); + assert!(!GatewayController::remote_chat_record_should_cancel_for_conversation(&queued)); + assert!(GatewayController::remote_chat_record_should_cancel_for_conversation(&claimed)); + assert!(GatewayController::remote_chat_record_should_cancel_for_conversation(&running)); +} + +#[test] +fn history_share_resolve_error_code_maps_public_share_failures() { + assert_eq!(history_share_resolve_error_code("分享 token 不能为空"), 400); + assert_eq!( + history_share_resolve_error_code("分享链接不存在或已关闭"), + 404 + ); + assert_eq!( + history_share_resolve_error_code("未找到对应的历史对话"), + 404 + ); + assert_eq!( + history_share_resolve_error_code("读取历史对话分享链接失败:db"), + 500 + ); +} + +#[test] +fn merge_settings_sync_snapshot_keeps_cached_ui_only_fields() { + let db_snapshot = json!({ + "system": { "executionMode": "agent-dev" }, + "cron": [{ "id": "cron-a" }], + "theme": "light", + "locale": "zh-CN", + "skills": {}, + "chatRuntimeControls": { + "thinkingEnabled": true, + "nativeWebSearchEnabled": true, + "reasoning": "high" + }, + "customSettings": {}, + "selectedModel": null, + }); + let cached_snapshot = json!({ + "theme": "dark", + "locale": "en-US", + "skills": { "enabled": true }, + "chatRuntimeControls": { + "thinkingEnabled": false, + "nativeWebSearchEnabled": false, + "reasoning": "xhigh" + }, + "customSettings": { + "conversationTitleModel": { + "customProviderId": "provider-a", + "model": "gpt-5-mini" + } + }, + "selectedModel": { + "customProviderId": "provider-a", + "model": "gpt-5.4" + }, + }); + + let merged = merge_settings_sync_snapshot(db_snapshot, Some(&cached_snapshot)) + .expect("merge settings sync snapshot"); + + assert_eq!(merged["cron"], json!([{ "id": "cron-a" }])); + assert_eq!(merged["theme"], json!("dark")); + assert_eq!(merged["locale"], json!("en-US")); + assert_eq!(merged["skills"], json!({ "enabled": true })); + assert_eq!( + merged["chatRuntimeControls"], + json!({ + "thinkingEnabled": false, + "nativeWebSearchEnabled": false, + "reasoning": "xhigh" + }) + ); + assert_eq!( + merged["customSettings"], + json!({ + "conversationTitleModel": { + "customProviderId": "provider-a", + "model": "gpt-5-mini" + } + }) + ); + assert_eq!( + merged["selectedModel"], + json!({ + "customProviderId": "provider-a", + "model": "gpt-5.4" + }) + ); +} + +#[test] +fn merge_settings_sync_snapshot_without_cache_leaves_ui_only_fields_absent() { + let db_snapshot = json!({ + "system": { "executionMode": "agent-dev" }, + "cron": [{ "id": "cron-a" }], + }); + + let merged = + merge_settings_sync_snapshot(db_snapshot, None).expect("merge settings sync snapshot"); + + let merged_map = merged.as_object().expect("merged snapshot object"); + assert!(!merged_map.contains_key("theme")); + assert!(!merged_map.contains_key("locale")); + assert!(!merged_map.contains_key("selectedModel")); + assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); +} + +#[test] +fn merge_settings_update_into_snapshot_keeps_unrelated_fields() { + let full_snapshot = json!({ + "system": { "executionMode": "agent-dev" }, + "theme": "dark", + "locale": "en-US", + "selectedModel": { + "customProviderId": "provider-a", + "model": "gpt-5.4" + }, + "remote": { "enableWebTerminal": true }, + }); + let partial_update = json!({ + "theme": "system", + "remote": { "enableWebTerminal": false }, + }); + + let merged = merge_settings_update_into_snapshot(full_snapshot, partial_update) + .expect("merge settings update into snapshot"); + + assert_eq!(merged["theme"], json!("system")); + assert_eq!(merged["locale"], json!("en-US")); + assert_eq!( + merged["selectedModel"], + json!({ + "customProviderId": "provider-a", + "model": "gpt-5.4" + }) + ); + assert_eq!(merged["system"], json!({ "executionMode": "agent-dev" })); + // Remote settings are desktop-owned and must not be overwritten by clients. + assert_eq!(merged["remote"], json!({ "enableWebTerminal": true })); +} + +#[test] +fn local_settings_update_event_keeps_private_api_key_updates_only_at_root() { + let payload = json!({ + "customProviders": [ + { + "id": "provider-a", + "name": "A", + "apiKey": "leaked-key" + } + ], + "remote": { + "enableWebTerminal": true + }, + "providerApiKeyUpdates": { + "provider-a": "new-key" + } + }); + + let event_payload = + build_local_settings_update_event_payload(payload).expect("build event payload"); + assert_eq!(event_payload.get("remote"), None); + assert_eq!(event_payload["customProviders"][0]["apiKey"], Value::Null); + assert_eq!( + event_payload["customProviders"][0]["apiKeyConfigured"], + true + ); + assert_eq!( + event_payload["providerApiKeyUpdates"]["provider-a"], + "new-key" + ); +} + +#[test] +fn terminal_project_path_key_is_required_for_gateway_requests() { + assert_eq!( + required_terminal_project_path_key(" /workspace/project ").as_deref(), + Ok("/workspace/project") + ); + assert_eq!( + required_terminal_project_path_key(r" C:\Repo\ ").as_deref(), + Ok("c:/repo") + ); + assert!(required_terminal_project_path_key(" ").is_err()); +} + +#[test] +fn set_disconnected_status_resets_runtime_fields_for_new_config() { + let config = RemoteSettingsPayload { + enabled: true, + gateway_url: "https://gateway.example.com".to_string(), + grpc_port: 50051, + grpc_endpoint: String::new(), + token: "dev-token".to_string(), + agent_id: "agent-new".to_string(), + auto_reconnect: true, + heartbeat_interval: 30, + enable_web_terminal: false, + enable_web_ssh_terminal: false, + enable_web_git: false, + enable_web_tunnels: false, + }; + let mut status = GatewayStatusSnapshot { + online: true, + enabled: true, + configured: true, + gateway_url: "https://old-gateway.example.com".to_string(), + agent_id: "agent-old".to_string(), + session_id: Some("session-123".to_string()), + connected_since: Some(123), + last_heartbeat: Some(456), + last_error: Some("previous error".to_string()), + }; + + set_disconnected_status( + &mut status, + &config, + Some("connect gateway failed".to_string()), + ); + + assert!(!status.online); + assert!(status.enabled); + assert!(status.configured); + assert_eq!(status.gateway_url, "https://gateway.example.com"); + assert_eq!(status.agent_id, "agent-new"); + assert_eq!(status.session_id, None); + assert_eq!(status.connected_since, None); + assert_eq!(status.last_heartbeat, None); + assert_eq!(status.last_error.as_deref(), Some("connect gateway failed")); +} + +#[test] +fn build_https_gateway_endpoint_initializes_tls_provider() { + build_endpoint("https://agent.cnweb.org:443", Duration::from_secs(30)) + .expect("build https gateway endpoint"); +} + +#[test] +fn build_grpc_url_prefers_explicit_endpoint() { + let config = RemoteSettingsPayload { + enabled: true, + gateway_url: "https://gateway.example.com".to_string(), + grpc_port: 50051, + grpc_endpoint: "tcp.proxy.rlwy.net:12345".to_string(), + token: "dev-token".to_string(), + agent_id: "agent".to_string(), + auto_reconnect: true, + heartbeat_interval: 30, + enable_web_terminal: false, + enable_web_ssh_terminal: false, + enable_web_git: false, + enable_web_tunnels: false, + }; + + let grpc_url = build_grpc_url(&config).expect("build explicit gRPC endpoint"); + + assert_eq!(grpc_url, "http://tcp.proxy.rlwy.net:12345"); +} + +#[test] +fn terminal_stream_handshake_frame_is_gateway_noop() { + let (sender, mut receiver) = tokio::sync::mpsc::channel::(1); + + queue_terminal_stream_handshake_frame(&sender).expect("queue terminal stream handshake"); + + let frame = receiver + .try_recv() + .expect("terminal stream handshake frame"); + assert_eq!(frame.kind, "detach"); + assert!(frame.stream_id.starts_with("desktop-handshake-")); + assert!(frame.session_id.is_empty()); +} + +#[test] +fn terminal_stream_h2_error_points_to_grpc_endpoint() { + let config = RemoteSettingsPayload { + enabled: true, + gateway_url: "https://gateway.example.com".to_string(), + grpc_port: 443, + grpc_endpoint: String::new(), + token: "dev-token".to_string(), + agent_id: "agent".to_string(), + auto_reconnect: true, + heartbeat_interval: 30, + enable_web_terminal: true, + enable_web_ssh_terminal: true, + enable_web_git: false, + enable_web_tunnels: false, + }; + + let message = format_gateway_terminal_stream_rpc_error( + "receive", + &tonic::Status::internal("h2 protocol error: http2 error"), + &config, + ); + + assert!(message.contains("receive failed")); + assert!(message.contains("HTTP/2 bidi streams")); + assert!(message.contains("https://gateway.example.com")); +} + +#[test] +fn build_chat_event_envelope_preserves_tool_result_arguments() { + let envelope = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "tool_result", + "conversation_id": "conversation-1", + "id": "bash-call", + "name": "Bash", + "arguments": { + "command": "printf live", + "cwd": "crates/agent-gateway" + }, + "content": [{ "type": "text", "text": "live" }], + "isError": false, + "round": 1 + }), + ) + .expect("build chat event envelope"); + + let chat_event = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + assert_eq!(chat_event.conversation_id, "conversation-1"); + assert_eq!( + chat_event.r#type, + super::proto::chat_event::ChatEventType::ToolResult as i32 + ); + + let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); + assert_eq!(data["arguments"]["command"], "printf live"); + assert_eq!(data["arguments"]["cwd"], "crates/agent-gateway"); +} + +#[test] +fn build_chat_event_envelope_preserves_title_final_flag() { + let envelope = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "token", + "conversation_id": "conversation-1", + "text": "", + "title": "Final title", + "titleFinal": true + }), + ) + .expect("build chat title event envelope"); + + let chat_event = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + assert_eq!(chat_event.conversation_id, "conversation-1"); + assert_eq!( + chat_event.r#type, + super::proto::chat_event::ChatEventType::Token as i32 + ); + + let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); + assert_eq!(data["title"], "Final title"); + assert_eq!(data["titleFinal"], true); +} + +#[test] +fn build_chat_event_envelope_preserves_hosted_search_payload() { + let envelope = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "hosted_search", + "conversation_id": "conversation-1", + "id": "search-1", + "provider": "codex", + "status": "completed", + "queries": ["设计模式定义"], + "sources": [ + { + "url": "https://example.com/pattern", + "title": "设计模式", + "sourceType": "citation" + } + ], + "updatedAt": 1234, + "round": 2 + }), + ) + .expect("build hosted search event envelope"); + + let chat_event = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + assert_eq!(chat_event.conversation_id, "conversation-1"); + assert_eq!( + chat_event.r#type, + super::proto::chat_event::ChatEventType::HostedSearch as i32 + ); + + let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); + assert_eq!(data["id"], "search-1"); + assert_eq!(data["provider"], "codex"); + assert_eq!(data["status"], "completed"); + assert_eq!(data["queries"][0], "设计模式定义"); + assert_eq!(data["sources"][0]["url"], "https://example.com/pattern"); + assert_eq!(data["updatedAt"], 1234); + assert_eq!(data["round"], 2); +} + +#[test] +fn build_chat_event_envelope_preserves_user_message_payload() { + let envelope = build_chat_event_envelope( + "request-1".to_string(), + json!({ + "type": "user_message", + "conversation_id": "conversation-1", + "message": "queued prompt", + "uploaded_files": [ + { + "relativePath": "notes.md", + "absolutePath": "/workspace/notes.md", + "fileName": "notes.md", + "kind": "text", + "sizeBytes": 12 + } + ], + "execution_mode": "agent" + }), + ) + .expect("build user message event envelope"); + + let chat_event = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::ChatEvent(event) => event, + _ => panic!("expected chat event payload"), + }; + assert_eq!(chat_event.conversation_id, "conversation-1"); + assert_eq!( + chat_event.r#type, + super::proto::chat_event::ChatEventType::UserMessage as i32 + ); + + let data: Value = serde_json::from_str(&chat_event.data).expect("chat event data"); + assert_eq!(data["message"], "queued prompt"); + assert_eq!(data["uploaded_files"][0]["relativePath"], "notes.md"); + assert_eq!(data["uploaded_files"][0]["kind"], "text"); + assert_eq!(data["execution_mode"], "agent"); +} + +#[test] +fn chat_event_terminal_detection_covers_done_and_error_only() { + assert!(chat_event_is_terminal(&json!({ "type": "done" }))); + assert!(chat_event_is_terminal( + &json!({ "type": "error", "message": "boom" }) + )); + assert!(chat_event_is_terminal(&json!({ "type": " done " }))); + assert!(!chat_event_is_terminal( + &json!({ "type": "token", "text": "hi" }) + )); + assert!(!chat_event_is_terminal(&json!({ "type": "tool_call" }))); + assert!(!chat_event_is_terminal(&json!({ "kind": "done" }))); + assert!(!chat_event_is_terminal(&json!("done"))); +} + +#[test] +fn runtime_status_envelope_carries_run_reports() { + let active_run = proto::ChatRunReport { + run_id: "run-1".to_string(), + conversation_id: "conversation-1".to_string(), + state: "running".to_string(), + error_code: String::new(), + message: String::new(), + updated_at: 1_772_000_000_000, + }; + let finished_run = proto::ChatRunReport { + run_id: "run-2".to_string(), + conversation_id: "conversation-2".to_string(), + state: "failed".to_string(), + error_code: "desktop_run_lost".to_string(), + message: "The desktop runtime stopped reporting this run.".to_string(), + updated_at: 1_772_000_000_500, + }; + + let envelope = build_gateway_runtime_status_envelope( + "worker-1".to_string(), + "busy".to_string(), + true, + 2, + vec![active_run], + vec![finished_run], + ); + + let status = match envelope.payload.expect("payload") { + super::proto::agent_envelope::Payload::RuntimeStatus(status) => status, + _ => panic!("expected runtime status payload"), + }; + assert_eq!(status.worker_id, "worker-1"); + assert_eq!(status.state, "busy"); + assert!(status.visible); + assert_eq!(status.active_run_count, 2); + assert_eq!(status.active_runs.len(), 1); + assert_eq!(status.active_runs[0].run_id, "run-1"); + assert_eq!(status.active_runs[0].state, "running"); + assert_eq!(status.active_runs[0].updated_at, 1_772_000_000_000); + assert_eq!(status.finished_runs.len(), 1); + assert_eq!(status.finished_runs[0].run_id, "run-2"); + assert_eq!(status.finished_runs[0].state, "failed"); + assert_eq!(status.finished_runs[0].error_code, "desktop_run_lost"); + assert_eq!( + status.finished_runs[0].message, + "The desktop runtime stopped reporting this run." + ); +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/types.rs b/crates/agent-gui/src-tauri/src/services/gateway/types.rs new file mode 100644 index 00000000..7ea64c95 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/types.rs @@ -0,0 +1,193 @@ +use serde::{Deserialize, Serialize}; + +use super::*; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayStatusSnapshot { + pub online: bool, + pub enabled: bool, + pub configured: bool, + pub gateway_url: String, + pub agent_id: String, + pub session_id: Option, + pub connected_since: Option, + pub last_heartbeat: Option, + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewaySelectedModelEvent { + pub custom_provider_id: String, + pub model: String, + pub provider_type: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatRuntimeControlsEvent { + pub thinking_enabled: bool, + pub native_web_search_enabled: bool, + pub reasoning: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayUploadedFileEvent { + pub relative_path: String, + pub absolute_path: String, + pub file_name: String, + pub kind: String, + pub size_bytes: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatMessageRefEvent { + pub segment_index: i32, + pub message_index: i32, + pub segment_id: String, + pub message_id: String, + pub role: String, + pub content_hash: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatRequestEvent { + pub request_id: String, + pub conversation_id: String, + pub client_request_id: String, + pub message: String, + pub rebased: bool, + pub base_message_ref: Option, + pub selected_model: Option, + pub runtime_controls: Option, + pub execution_mode: String, + pub workdir: String, + pub selected_system_tools: Vec, + pub uploaded_files: Vec, + pub queue_policy: String, +} + +pub(crate) fn is_complete_user_chat_message_ref(ref_value: &proto::ChatMessageRef) -> bool { + ref_value.segment_index >= 0 + && ref_value.message_index >= 0 + && !ref_value.segment_id.trim().is_empty() + && !ref_value.message_id.trim().is_empty() + && ref_value.role.trim() == "user" + && !ref_value.content_hash.trim().is_empty() +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GatewayChatCancelEvent { + pub(crate) request_id: String, + pub(crate) conversation_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatQueueRequestEvent { + pub request_id: String, + pub action: String, + pub conversation_id: String, + pub item_id: String, + pub direction: String, + pub revision: u64, + pub draft_json: String, + pub uploaded_files_json: String, + pub request_json: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatQueueResponseInput { + pub request_id: String, + #[serde(default)] + pub accepted: bool, + #[serde(default)] + pub message: String, + #[serde(default)] + pub snapshot_json: String, + #[serde(default)] + pub item_json: String, + #[serde(default)] + pub error_code: String, + #[serde(default)] + pub revision: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatQueueEventInput { + pub conversation_id: String, + pub snapshot_json: String, + #[serde(default)] + pub revision: u64, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatRuntimeSnapshot { + pub conversation_id: String, + pub run_id: String, + #[serde(default)] + pub client_request_id: Option, + #[serde(default)] + pub worker_id: Option, + pub state: String, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub updated_at: i64, + #[serde(default)] + pub revision: i64, + #[serde(default)] + pub entries_json: String, + #[serde(default)] + pub tool_status: Option, + #[serde(default)] + pub tool_status_is_compaction: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayChatClaimedRequest { + pub request_id: String, + pub client_request_id: String, + pub conversation_id: String, + pub state: String, + pub attempt: u32, + pub lease_ms: u64, + pub request: GatewayChatRequestEvent, +} + +pub const CHAT_HISTORY_SYNC_EVENT: &str = "chat-history:changed"; +pub const GATEWAY_SETTINGS_SYNC_EVENT: &str = "gateway:settings-sync"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayHistorySyncConversation { + pub id: String, + pub title: String, + pub provider_id: Option, + pub model: Option, + pub session_id: Option, + pub cwd: Option, + pub created_at: i64, + pub updated_at: i64, + pub message_count: i64, + pub is_pinned: bool, + pub pinned_at: Option, + pub is_shared: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayHistorySyncEvent { + pub kind: String, + pub conversation_id: String, + pub conversation: Option, +} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/util.rs b/crates/agent-gui/src-tauri/src/services/gateway/util.rs new file mode 100644 index 00000000..6dadf5e7 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/gateway/util.rs @@ -0,0 +1,88 @@ +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +pub(crate) fn optional_proto_text(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub(crate) fn optional_proto_u16(value: u32) -> Option { + if value == 0 { + None + } else { + Some(value.min(u32::from(u16::MAX)) as u16) + } +} + +pub(crate) fn optional_proto_usize(value: u32) -> Option { + (value > 0).then_some(value as usize) +} + +pub(crate) fn now_unix_seconds() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + i64::try_from(duration.as_secs()).unwrap_or(i64::MAX) +} + +pub(crate) fn now_unix_millis() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)); + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + +pub(crate) fn chat_run_ledger_now() -> (Instant, i64) { + (Instant::now(), now_unix_millis()) +} + +pub(crate) fn string_field( + object: &serde_json::Map, + key: &str, +) -> Result { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .ok_or_else(|| format!("gateway chat event {key} is required")) +} + +pub(crate) fn required_string_field( + object: &serde_json::Map, + key: &str, +) -> Result { + string_field(object, key) +} + +pub(crate) fn required_raw_string_field( + object: &serde_json::Map, + key: &str, +) -> Result { + object + .get(key) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| format!("gateway chat event {key} is required")) +} + +pub(crate) fn optional_string_field( + object: &serde_json::Map, + key: &str, +) -> Option { + object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn optional_number_field( + object: &serde_json::Map, + key: &str, +) -> Option { + object.get(key).and_then(Value::as_i64) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills.rs b/crates/agent-gui/src-tauri/src/services/skills.rs deleted file mode 100644 index cacfc8a5..00000000 --- a/crates/agent-gui/src-tauri/src/services/skills.rs +++ /dev/null @@ -1,3924 +0,0 @@ -use chrono::Utc; -use reqwest::blocking::Client as HttpClient; -use serde::Serialize; -use serde_json::Value; -use std::collections::HashMap; -use std::fs; -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::path::{Component, Path, PathBuf}; -use std::process::Command; -use std::sync::{Mutex, OnceLock}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use uuid::Uuid; -use walkdir::WalkDir; -use zip::write::FileOptions; -use zip::{CompressionMethod, ZipArchive, ZipWriter}; - -const SKILL_READ_MAX_BYTES: usize = 200 * 1024; // 200KB -const DEFAULT_SKILL_READ_LENGTH_LINES: usize = 200; -const DEFAULT_SKILL_GLOB_MAX_RESULTS: usize = 2000; -const SKILL_METADATA_MAX_BYTES: usize = 200 * 1024; // 200KB -const MAX_SKILL_NAME_LENGTH: usize = 64; -const MAX_SKILL_DESCRIPTION_LENGTH: usize = 1024; -const MAX_SKILL_INSTALL_FILES: usize = 2000; -const MAX_SKILL_INSTALL_BYTES: u64 = 50 * 1024 * 1024; -const MAX_SKILL_FILE_BYTES: u64 = 10 * 1024 * 1024; -const DEFAULT_GITHUB_REF: &str = "main"; -const CLAWHUB_API_BASE: &str = "https://clawhub.ai"; -const DEFAULT_CLAWHUB_SEARCH_LIMIT: usize = 10; -const MAX_CLAWHUB_SEARCH_LIMIT: usize = 20; -const NON_ENGLISH_SCRIPT_RANGES: &[(u32, u32)] = &[ - (0x0370, 0x03FF), // Greek - (0x0400, 0x052F), // Cyrillic - (0x0590, 0x05FF), // Hebrew - (0x0600, 0x06FF), // Arabic - (0x0750, 0x077F), // Arabic Supplement - (0x08A0, 0x08FF), // Arabic Extended-A - (0x0900, 0x0DFF), // Indic scripts - (0x0E00, 0x0E7F), // Thai - (0x0E80, 0x0EFF), // Lao - (0x0F00, 0x0FFF), // Tibetan - (0x1000, 0x109F), // Myanmar - (0x10A0, 0x10FF), // Georgian - (0x1100, 0x11FF), // Hangul Jamo - (0x1780, 0x17FF), // Khmer - (0x3040, 0x30FF), // Hiragana and Katakana - (0x3130, 0x318F), // Hangul Compatibility Jamo - (0x31F0, 0x31FF), // Katakana Phonetic Extensions - (0x3400, 0x4DBF), // CJK Extension A - (0x4E00, 0x9FFF), // CJK Unified Ideographs - (0xAC00, 0xD7AF), // Hangul Syllables - (0xF900, 0xFAFF), // CJK Compatibility Ideographs - (0xFF00, 0xFFEF), // Halfwidth and Fullwidth Forms - (0x20000, 0x2FA1F), // CJK Extensions and compatibility supplements -]; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SystemListSkillFilesResponse { - pub root_dir: String, - pub paths: Vec, - pub truncated: bool, -} - -#[derive(Debug, Serialize)] -pub struct SystemReadSkillTextResponse { - pub content: String, - pub truncated: bool, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemReadSkillMetadataResponse { - pub name: Option, - pub description: Option, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillSourceMetadata { - pub registry: String, - pub slug: String, - pub version: Option, - pub published_at: Option, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemClawHubSkillCard { - pub slug: String, - pub display_name: String, - pub summary: String, - pub latest_version: Option, - pub downloads: u64, - pub stars: u64, - pub installs_current: u64, - pub updated_at: Option, - pub owner_handle: Option, - pub web_url: Option, - pub download_url: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillSummary { - pub name: String, - pub description: String, - pub target: String, - pub skill_file: String, - pub base_dir: String, - pub source: Option, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillInvalidEntry { - pub path: String, - pub error: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillInstallResult { - pub name: String, - pub target: String, - pub backup: Option, - pub skill_file: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillValidationResponse { - pub name: String, - pub target: String, - pub ok: bool, - pub errors: Vec, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillPackageResponse { - pub name: String, - pub target: String, - pub archive: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillDeleteResponse { - pub name: String, - pub target: String, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemBuiltinSkillSeedResponse { - pub name: String, - pub target: String, - pub action: String, - pub backup: Option, -} - -#[derive(Debug, Serialize, Clone)] -#[serde(rename_all = "camelCase")] -pub struct SystemSkillInstallJobSnapshot { - pub job_id: String, - pub phase: String, - pub source: String, - pub label: Option, - pub slug: Option, - pub version: Option, - pub downloaded_bytes: u64, - pub total_bytes: Option, - pub message: Option, - pub error: Option, - pub installed: Option>, - pub started_at: u64, - pub updated_at: u64, - pub finished_at: Option, -} - -#[derive(Debug, Clone)] -struct SkillInstallJobState { - job_id: String, - phase: String, - source: String, - label: Option, - slug: Option, - version: Option, - downloaded_bytes: u64, - total_bytes: Option, - message: Option, - error: Option, - installed: Option>, - started_at: u64, - updated_at: u64, - finished_at: Option, -} - -#[derive(Debug, Clone)] -struct SkillInstallProgressUpdate { - phase: &'static str, - downloaded_bytes: Option, - total_bytes: Option, - message: Option, -} - -static SKILL_INSTALL_JOBS: OnceLock>> = OnceLock::new(); - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SystemManageSkillResponse { - pub action: String, - pub root_dir: String, - pub path: Option, - pub content: Option, - pub truncated: Option, - pub start_line: Option, - pub num_lines: Option, - pub skills: Option>, - pub invalid: Option>, - pub installed: Option>, - pub created: Option, - pub validation: Option, - pub package: Option, - pub deleted: Option, - pub seeded: Option>, - pub install_job: Option, - pub clawhub_results: Option>, - pub clawhub_next_cursor: Option, - pub clawhub_slug: Option, - pub clawhub_download_url: Option, -} - -#[derive(Debug, Clone)] -struct SkillMetadata { - name: String, - description: String, - metadata_file: PathBuf, -} - -#[derive(Debug, Clone)] -struct GithubSource { - owner: String, - repo: String, - git_ref: String, - subpath: Option, -} - -#[derive(Debug, Clone)] -struct SkillValidationResult { - ok: bool, - errors: Vec, - metadata: Option, -} - -struct BuiltinSkillFile { - path: &'static str, - content: &'static str, -} - -struct BuiltinSkill { - name: &'static str, - files: &'static [BuiltinSkillFile], -} - -const SKILLS_INSTALLER_FILES: &[BuiltinSkillFile] = &[ - BuiltinSkillFile { - path: "SKILL.md", - content: include_str!("../../prompt/skills/skills-installer/SKILL.md"), - }, - BuiltinSkillFile { - path: "references/install-sources.md", - content: include_str!("../../prompt/skills/skills-installer/references/install-sources.md"), - }, - BuiltinSkillFile { - path: "references/safety-and-conflicts.md", - content: include_str!( - "../../prompt/skills/skills-installer/references/safety-and-conflicts.md" - ), - }, -]; - -const SKILLS_CREATOR_FILES: &[BuiltinSkillFile] = &[ - BuiltinSkillFile { - path: "SKILL.md", - content: include_str!("../../prompt/skills/skills-creator/SKILL.md"), - }, - BuiltinSkillFile { - path: "references/agent-skill-format.md", - content: include_str!( - "../../prompt/skills/skills-creator/references/agent-skill-format.md" - ), - }, - BuiltinSkillFile { - path: "references/authoring-patterns.md", - content: include_str!( - "../../prompt/skills/skills-creator/references/authoring-patterns.md" - ), - }, -]; - -const BUILTIN_AGENT_SKILLS: &[BuiltinSkill] = &[ - BuiltinSkill { - name: "skills-installer", - files: SKILLS_INSTALLER_FILES, - }, - BuiltinSkill { - name: "skills-creator", - files: SKILLS_CREATOR_FILES, - }, -]; - -fn is_builtin_agent_skill_name(name: &str) -> bool { - BUILTIN_AGENT_SKILLS - .iter() - .any(|skill| skill.name.eq_ignore_ascii_case(name)) -} - -fn ensure_not_builtin_skill_management_target(name: &str, action: &str) -> Result<(), String> { - if is_builtin_agent_skill_name(name) { - return Err(format!( - "SkillsManager action={action} cannot modify built-in Skill \"{name}\". Built-in Skills are managed by LiveAgent; create or update a separate user Skill instead." - )); - } - Ok(()) -} - -struct TempDir { - path: PathBuf, -} - -impl TempDir { - fn new(prefix: &str) -> Result { - let base = std::env::temp_dir(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let path = base.join(format!("{prefix}-{}-{now}", std::process::id())); - fs::create_dir_all(&path) - .map_err(|e| format!("Failed to create temporary directory: {e}"))?; - Ok(Self { path }) - } - - fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TempDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -fn now_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -fn skill_install_jobs() -> &'static Mutex> { - SKILL_INSTALL_JOBS.get_or_init(|| Mutex::new(HashMap::new())) -} - -fn install_job_snapshot(job: &SkillInstallJobState) -> SystemSkillInstallJobSnapshot { - SystemSkillInstallJobSnapshot { - job_id: job.job_id.clone(), - phase: job.phase.clone(), - source: job.source.clone(), - label: job.label.clone(), - slug: job.slug.clone(), - version: job.version.clone(), - downloaded_bytes: job.downloaded_bytes, - total_bytes: job.total_bytes, - message: job.message.clone(), - error: job.error.clone(), - installed: job.installed.clone(), - started_at: job.started_at, - updated_at: job.updated_at, - finished_at: job.finished_at, - } -} - -fn prune_old_install_jobs(jobs: &mut HashMap, now: u64) { - const RETENTION_MS: u64 = 60 * 60 * 1000; - jobs.retain(|_, job| { - job.finished_at - .map(|finished_at| now.saturating_sub(finished_at) <= RETENTION_MS) - .unwrap_or(true) - }); -} - -fn insert_install_job(job: SkillInstallJobState) -> Result { - let snapshot = install_job_snapshot(&job); - let mut jobs = skill_install_jobs() - .lock() - .map_err(|_| "Failed to lock Skill install jobs".to_string())?; - prune_old_install_jobs(&mut jobs, now_millis()); - jobs.insert(job.job_id.clone(), job); - Ok(snapshot) -} - -fn update_install_job(job_id: &str, updater: F) -> Result -where - F: FnOnce(&mut SkillInstallJobState), -{ - let mut jobs = skill_install_jobs() - .lock() - .map_err(|_| "Failed to lock Skill install jobs".to_string())?; - let job = jobs - .get_mut(job_id) - .ok_or_else(|| format!("Skill install job not found: {job_id}"))?; - updater(job); - job.updated_at = now_millis(); - Ok(install_job_snapshot(job)) -} - -fn get_install_job_snapshot(job_id: &str) -> Result { - let mut jobs = skill_install_jobs() - .lock() - .map_err(|_| "Failed to lock Skill install jobs".to_string())?; - prune_old_install_jobs(&mut jobs, now_millis()); - let job = jobs - .get(job_id) - .ok_or_else(|| format!("Skill install job not found: {job_id}"))?; - Ok(install_job_snapshot(job)) -} - -pub fn app_storage_dir() -> Result { - let home = - dirs::home_dir().ok_or_else(|| "Failed to locate the user home directory".to_string())?; - let dir = home.join(format!(".{}", env!("CARGO_PKG_NAME"))); - fs::create_dir_all(&dir) - .map_err(|e| format!("Failed to create the application directory: {e}"))?; - Ok(dir) -} - -pub fn skills_root_dir() -> Result { - let dir = app_storage_dir()?.join("skills"); - fs::create_dir_all(&dir).map_err(|e| format!("Failed to create the skills directory: {e}"))?; - fs::canonicalize(&dir).map_err(|e| format!("Failed to resolve the skills directory: {e}")) -} - -fn skill_root_display(root: &Path) -> String { - let raw = root.to_string_lossy(); - #[cfg(windows)] - { - if let Some(stripped) = raw.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{}", stripped).replace('\\', "/"); - } - if let Some(stripped) = raw.strip_prefix(r"\\?\") { - return stripped.replace('\\', "/"); - } - } - raw.replace('\\', "/") -} - -fn rel_to_root_str(root: &Path, abs: &Path) -> String { - abs.strip_prefix(root) - .unwrap_or(abs) - .to_string_lossy() - .replace('\\', "/") -} - -fn display_path(path: &Path) -> String { - path.to_string_lossy().replace('\\', "/") -} - -fn is_skill_markdown(path: &Path) -> bool { - path.file_name() - .map(|name| name.to_string_lossy().eq_ignore_ascii_case("skill.md")) - .unwrap_or(false) -} - -fn is_readme_markdown(path: &Path) -> bool { - path.file_name() - .map(|name| name.to_string_lossy().eq_ignore_ascii_case("README.md")) - .unwrap_or(false) -} - -fn is_skill_json(path: &Path) -> bool { - path.file_name() - .map(|name| name.to_string_lossy().eq_ignore_ascii_case("skill.json")) - .unwrap_or(false) -} - -fn standard_metadata_file_for(skill_dir: &Path) -> Option { - for name in ["skill.json", "SKILL.md", "skill.md"] { - let candidate = skill_dir.join(name); - if candidate.is_file() { - return Some(candidate); - } - } - let entries = fs::read_dir(skill_dir).ok()?; - for entry in entries.flatten() { - let candidate = entry.path(); - if candidate.is_file() - && candidate - .file_name() - .map(|name| { - let name = name.to_string_lossy(); - name.eq_ignore_ascii_case("skill.json") || name.eq_ignore_ascii_case("skill.md") - }) - .unwrap_or(false) - { - return Some(candidate); - } - } - None -} - -fn is_skill_metadata_candidate(path: &Path) -> bool { - is_skill_markdown(path) || is_skill_json(path) || is_readme_markdown(path) -} - -fn is_archive_path(path: &Path) -> bool { - matches!( - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref(), - Some("zip") | Some("skill") - ) -} - -fn strip_utf8_bom(input: &str) -> &str { - input.strip_prefix('\u{feff}').unwrap_or(input) -} - -fn unquote_yaml_scalar(raw: &str) -> String { - let value = raw.trim(); - if value.len() >= 2 { - let quoted_with_double = value.starts_with('"') && value.ends_with('"'); - let quoted_with_single = value.starts_with('\'') && value.ends_with('\''); - if quoted_with_double || quoted_with_single { - return value[1..value.len() - 1].to_string(); - } - } - value.to_string() -} - -fn normalize_skill_metadata_value(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) -} - -fn parse_yaml_top_level_scalar(yaml: &str, key: &str) -> Option { - if !yaml.contains('\n') { - return parse_inline_yaml_top_level_scalar(yaml, key); - } - - let lines: Vec<&str> = yaml.lines().collect(); - let prefix = format!("{key}:"); - let mut i = 0; - while i < lines.len() { - let line = lines[i]; - let Some(rest) = line.strip_prefix(&prefix) else { - i += 1; - continue; - }; - - let rest = rest.trim(); - if rest == "|" || rest == ">" { - i += 1; - let mut block = Vec::new(); - while i < lines.len() { - let block_line = lines[i]; - let is_indented = block_line - .chars() - .next() - .map(char::is_whitespace) - .unwrap_or(false); - if !is_indented { - break; - } - block.push(block_line.trim_start().to_string()); - i += 1; - } - return Some(block.join("\n")); - } - - return Some(unquote_yaml_scalar(rest)); - } - - None -} - -fn inline_yaml_key_start(yaml: &str, key: &str) -> Option { - let prefix = format!("{key}:"); - yaml.match_indices(&prefix).find_map(|(index, _)| { - let is_boundary = index == 0 - || yaml[..index] - .chars() - .next_back() - .map(char::is_whitespace) - .unwrap_or(false); - is_boundary.then_some(index) - }) -} - -fn parse_inline_yaml_top_level_scalar(yaml: &str, key: &str) -> Option { - let start = inline_yaml_key_start(yaml, key)? + key.len() + 1; - let mut end = yaml.len(); - for other in [ - "name", - "description", - "license", - "allowed-tools", - "metadata", - ] { - if other == key { - continue; - } - if let Some(next) = inline_yaml_key_start(&yaml[start..], other) { - end = end.min(start + next); - } - } - Some(unquote_yaml_scalar(yaml[start..end].trim())) -} - -fn parse_skill_frontmatter_yaml_metadata(yaml: &str) -> SystemReadSkillMetadataResponse { - let name = parse_yaml_top_level_scalar(yaml, "name"); - let description = parse_yaml_top_level_scalar(yaml, "description"); - - SystemReadSkillMetadataResponse { - name: normalize_skill_metadata_value(name), - description: normalize_skill_metadata_value(description), - } -} - -fn parse_skill_json_metadata(json_text: &str) -> SystemReadSkillMetadataResponse { - let Ok(parsed) = serde_json::from_str::(strip_utf8_bom(json_text)) else { - return SystemReadSkillMetadataResponse { - name: None, - description: None, - }; - }; - - let name = parsed - .get("name") - .and_then(Value::as_str) - .map(ToString::to_string); - let description = parsed - .get("description") - .and_then(Value::as_str) - .map(ToString::to_string); - - SystemReadSkillMetadataResponse { - name: normalize_skill_metadata_value(name), - description: normalize_skill_metadata_value(description), - } -} - -fn empty_skill_metadata_response() -> SystemReadSkillMetadataResponse { - SystemReadSkillMetadataResponse { - name: None, - description: None, - } -} - -fn is_missing_frontmatter_error(error: &str) -> bool { - error == "Skill frontmatter must start with ---" -} - -fn fallback_readme_skill_name(skill_dir: &Path) -> Result { - let raw = skill_dir - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "readme-skill".to_string()); - let normalized = normalize_skill_name(&raw); - sanitize_skill_name(&normalized) -} - -fn first_readme_description_line(content: &str) -> Option { - strip_utf8_bom(content).lines().find_map(|line| { - let mut value = line.trim(); - if value.is_empty() { - return None; - } - if value == "---" { - return None; - } - value = value.trim_start_matches('#').trim(); - if value.is_empty() { - return None; - } - let value = value - .trim_matches(|ch: char| ch == '*' || ch == '_' || ch == '`') - .trim(); - (!value.is_empty()).then(|| value.to_string()) - }) -} - -fn fallback_readme_description(readme_file: &Path, name: &str) -> Result { - let content = fs::read_to_string(readme_file) - .map_err(|e| format!("Failed to read README.md fallback: {e}"))?; - let description = first_readme_description_line(&content) - .unwrap_or_else(|| format!("README.md skill instructions for {name}")); - Ok(description - .chars() - .take(MAX_SKILL_DESCRIPTION_LENGTH) - .collect()) -} - -fn read_skill_markdown_frontmatter_yaml(reader: &mut R) -> Result { - let mut buf = Vec::new(); - let mut yaml = String::new(); - let mut is_first_line = true; - - loop { - buf.clear(); - let n = reader - .read_until(b'\n', &mut buf) - .map_err(|e| format!("Failed to read Skill file: {e}"))?; - if n == 0 { - return Err("Skill frontmatter must start with ---".to_string()); - } - - let line = String::from_utf8_lossy(&buf).to_string(); - let line = if is_first_line { - is_first_line = false; - strip_utf8_bom(&line).to_string() - } else { - line - }; - - if line.trim().is_empty() { - continue; - } - - if line.trim() != "---" { - if let Some((yaml, _body)) = split_inline_frontmatter(&line) { - return Ok(yaml); - } - return Err("Skill frontmatter must start with ---".to_string()); - } - break; - } - - let mut found_end = false; - loop { - buf.clear(); - let n = reader - .read_until(b'\n', &mut buf) - .map_err(|e| format!("Failed to read Skill file: {e}"))?; - if n == 0 { - break; - } - - let line = String::from_utf8_lossy(&buf); - if yaml.len().saturating_add(line.len()) > SKILL_METADATA_MAX_BYTES { - return Err(format!( - "Skill frontmatter is too large, over {} bytes", - SKILL_METADATA_MAX_BYTES - )); - } - - if line.trim() == "---" { - found_end = true; - break; - } - yaml.push_str(&line); - } - - if !found_end { - return Err("Skill frontmatter is missing closing ---".to_string()); - } - - Ok(yaml) -} - -fn sanitize_skill_rel_path(input: &str) -> Result { - let raw = input.trim(); - if raw.is_empty() { - return Err("Skill path cannot be empty".to_string()); - } - - let path = Path::new(raw); - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::Prefix(_) | Component::RootDir => { - return Err(format!("Skill path must be relative: {input}")); - } - Component::ParentDir => { - return Err(format!("Skill path must not contain ..: {input}")); - } - Component::CurDir => {} - Component::Normal(segment) => { - let segment = segment.to_string_lossy(); - if segment.contains(':') || is_windows_reserved_path_component(&segment) { - return Err(format!("Invalid Skill path: {input}")); - } - out.push(segment.as_ref()); - } - } - } - - if out.as_os_str().is_empty() { - return Err("Skill path cannot be empty".to_string()); - } - - Ok(out) -} - -fn sanitize_skill_child_rel_path(input: &str) -> Result { - let rel = sanitize_skill_rel_path(input)?; - if rel - .components() - .any(|component| matches!(component, Component::Normal(segment) if segment.to_string_lossy().starts_with('.'))) - { - return Err(format!("Skill file path must not use hidden control directories: {input}")); - } - Ok(rel) -} - -fn sanitize_skill_name(input: &str) -> Result { - let name = input.trim(); - if name.is_empty() { - return Err("Skill name cannot be empty".to_string()); - } - if name.len() > MAX_SKILL_NAME_LENGTH { - return Err(format!( - "Skill name '{name}' is too long; maximum is {MAX_SKILL_NAME_LENGTH}" - )); - } - if !name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') - { - return Err(format!( - "Skill name '{name}' must use lowercase letters, digits, and hyphens only" - )); - } - if name.starts_with('-') || name.ends_with('-') || name.contains("--") { - return Err(format!( - "Skill name '{name}' cannot start/end with hyphen or contain consecutive hyphens" - )); - } - if is_windows_reserved_path_component(name) { - return Err(format!("Skill name '{name}' is reserved on Windows")); - } - Ok(name.to_string()) -} - -fn is_windows_reserved_path_component(input: &str) -> bool { - let stem = input - .split('.') - .next() - .unwrap_or(input) - .trim_matches(|ch| ch == ' ' || ch == '.') - .to_ascii_uppercase(); - matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") - || (stem.len() == 4 - && (stem.starts_with("COM") || stem.starts_with("LPT")) - && stem.as_bytes()[3].is_ascii_digit() - && stem.as_bytes()[3] != b'0') -} - -fn normalize_skill_name(raw_name: &str) -> String { - let mut out = String::new(); - let mut previous_dash = false; - for ch in raw_name.trim().chars().flat_map(char::to_lowercase) { - if ch.is_ascii_lowercase() || ch.is_ascii_digit() { - out.push(ch); - previous_dash = false; - } else if !previous_dash { - out.push('-'); - previous_dash = true; - } - } - out.trim_matches('-').to_string() -} - -fn title_case_skill_name(skill_name: &str) -> String { - skill_name - .split('-') - .filter(|part| !part.is_empty()) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), - None => String::new(), - } - }) - .collect::>() - .join(" ") -} - -fn yaml_quote(value: &str) -> String { - let escaped = value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n"); - format!("\"{escaped}\"") -} - -fn render_skill_template(name: &str, description: &str, body: Option<&str>) -> String { - let body = body.map(str::trim).filter(|value| !value.is_empty()); - let rendered_body = body.map_or_else( - || { - format!( - "# {}\n\n## Language Policy\n\n- Write this skill document and every Markdown reference in English only.\n- Translate non-English source notes into English before adding them here.\n- Preserve code identifiers, filenames, commands, URLs, and literal values exactly when needed.\n\n## Workflow\n\n1. Inspect the user's request and gather the required context.\n2. Follow the workflow this skill is meant to capture.\n3. Validate the result and report changed files or outputs.\n", - title_case_skill_name(name) - ) - }, - |value| value.to_string(), - ); - format!( - "---\nname: {name}\ndescription: {}\n---\n\n{}\n", - yaml_quote(description), - rendered_body - ) -} - -fn is_non_english_script_char(ch: char) -> bool { - let codepoint = ch as u32; - NON_ENGLISH_SCRIPT_RANGES - .iter() - .any(|(start, end)| codepoint >= *start && codepoint <= *end) -} - -fn first_non_english_script_char(content: &str) -> Option { - content.chars().find(|ch| is_non_english_script_char(*ch)) -} - -fn is_markdown_document(rel: &Path) -> bool { - let is_markdown = rel - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("md")) - .unwrap_or(false); - if !is_markdown { - return false; - } - rel.components().count() == 1 || rel.starts_with("references") -} - -fn validate_english_markdown_document(path: &Path, rel: &Path, errors: &mut Vec) { - let content = match fs::read_to_string(path) { - Ok(content) => content, - Err(error) => { - errors.push(format!( - "Failed to read Markdown documentation {}: {error}", - rel.to_string_lossy() - )); - return; - } - }; - if let Some(ch) = first_non_english_script_char(&content) { - errors.push(format!( - "Markdown documentation must be written in English only: {} contains non-English script character U+{:04X}", - rel.to_string_lossy(), - ch as u32 - )); - } -} - -fn ensure_within_skills_root_existing(root: &Path, target: &Path) -> Result { - let canon = - fs::canonicalize(target).map_err(|e| format!("Failed to resolve the Skill file: {e}"))?; - if !canon.starts_with(root) { - return Err(format!( - "Target Skill file is outside the skills root: {}", - canon.display() - )); - } - Ok(canon) -} - -fn should_skip_discovery_path(root: &Path, path: &Path) -> bool { - let rel = path.strip_prefix(root).unwrap_or(path); - rel.components().any(|component| match component { - Component::Normal(segment) => { - let name = segment.to_string_lossy(); - name.starts_with('.') || name.contains(".backup-") || name.starts_with("backup-") - } - _ => false, - }) -} - -fn has_skill_metadata_ancestor(root: &Path, path: &Path) -> bool { - if !path.starts_with(root) { - return false; - } - let mut current = path.parent(); - while let Some(parent) = current { - if !parent.starts_with(root) { - break; - } - if parent == root { - break; - } - if metadata_file_for(parent).is_some() { - return true; - } - current = parent.parent(); - } - false -} - -fn should_include_metadata_candidate(root: &Path, path: &Path) -> bool { - if !is_skill_metadata_candidate(path) { - return false; - } - if !is_readme_markdown(path) { - return true; - } - let Some(parent) = path.parent() else { - return false; - }; - standard_metadata_file_for(parent).is_none() && !has_skill_metadata_ancestor(root, parent) -} - -fn metadata_file_for(skill_dir: &Path) -> Option { - if let Some(candidate) = standard_metadata_file_for(skill_dir) { - return Some(candidate); - } - let readme = skill_dir.join("README.md"); - if readme.is_file() { - return Some(readme); - } - let entries = fs::read_dir(skill_dir).ok()?; - for entry in entries.flatten() { - let candidate = entry.path(); - if candidate.is_file() - && candidate - .file_name() - .map(|name| { - let name = name.to_string_lossy(); - name.eq_ignore_ascii_case("README.md") - }) - .unwrap_or(false) - { - return Some(candidate); - } - } - None -} - -fn is_skill_dir(path: &Path) -> bool { - path.is_dir() && metadata_file_for(path).is_some() -} - -fn read_skill_metadata_from_dir(skill_dir: &Path) -> Result { - let metadata_file = metadata_file_for(skill_dir).ok_or_else(|| { - format!( - "No SKILL.md, skill.md, skill.json, or README.md found in {}", - skill_dir.display() - ) - })?; - let metadata = read_skill_metadata_file(&metadata_file)?; - let name = metadata.name.clone(); - let description = metadata.description.clone(); - if is_readme_markdown(&metadata_file) && name.is_none() && description.is_none() { - let name = fallback_readme_skill_name(skill_dir)?; - let description = fallback_readme_description(&metadata_file, &name)?; - sanitize_skill_name(&name)?; - return Ok(SkillMetadata { - name, - description, - metadata_file, - }); - } - - let name = name.ok_or_else(|| format!("Missing skill name in {}", metadata_file.display()))?; - let description = description - .ok_or_else(|| format!("Missing skill description in {}", metadata_file.display()))?; - sanitize_skill_name(&name)?; - Ok(SkillMetadata { - name, - description, - metadata_file, - }) -} - -fn read_skill_metadata_file(target: &Path) -> Result { - let md = fs::metadata(target).map_err(|e| format!("Failed to read Skill metadata: {e}"))?; - if !md.is_file() { - return Err("Only regular Skill metadata files can be read".to_string()); - } - - if is_skill_json(target) { - let content = - fs::read_to_string(target).map_err(|e| format!("Failed to read skill.json: {e}"))?; - if content.len() > SKILL_METADATA_MAX_BYTES { - return Err(format!( - "skill.json is too large, over {} bytes", - SKILL_METADATA_MAX_BYTES - )); - } - return Ok(parse_skill_json_metadata(&content)); - } - - if !is_skill_markdown(target) && !is_readme_markdown(target) { - return Err( - "Skill metadata files only support skill.json / SKILL.md / skill.md / README.md" - .to_string(), - ); - } - - let file = fs::File::open(target).map_err(|e| format!("Failed to open Skill file: {e}"))?; - let mut reader = BufReader::new(file); - let yaml = match read_skill_markdown_frontmatter_yaml(&mut reader) { - Ok(yaml) => yaml, - Err(error) if is_readme_markdown(target) && is_missing_frontmatter_error(&error) => { - return Ok(empty_skill_metadata_response()); - } - Err(error) => return Err(error), - }; - Ok(parse_skill_frontmatter_yaml_metadata(&yaml)) -} - -fn split_inline_frontmatter(content: &str) -> Option<(String, String)> { - let rest = content.trim_start().strip_prefix("---")?; - if rest.trim_start().starts_with('\n') || rest.trim().is_empty() { - return None; - } - let closing = rest.find("---")?; - let yaml = rest[..closing].trim().to_string(); - let body = rest[closing + 3..].trim_start().to_string(); - Some((yaml, body)) -} - -pub fn system_list_skill_files_sync() -> Result { - let root = skills_root_dir()?; - let root_dir = skill_root_display(&root); - - let mut paths = Vec::new(); - let mut truncated = false; - for entry in WalkDir::new(&root).follow_links(false) { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - - if should_skip_discovery_path(&root, entry.path()) { - continue; - } - if !entry.file_type().is_file() || !should_include_metadata_candidate(&root, entry.path()) { - continue; - } - - if paths.len() >= DEFAULT_SKILL_GLOB_MAX_RESULTS { - truncated = true; - break; - } - - paths.push(rel_to_root_str(&root, entry.path())); - } - - paths.sort(); - Ok(SystemListSkillFilesResponse { - root_dir, - paths, - truncated, - }) -} - -pub fn system_read_skill_metadata_sync( - path: String, -) -> Result { - let root = skills_root_dir()?; - let rel = sanitize_skill_rel_path(&path)?; - let target = root.join(rel); - let target = ensure_within_skills_root_existing(&root, &target)?; - read_skill_metadata_file(&target) -} - -pub fn system_read_skill_text_sync( - path: String, - offset: Option, - length: Option, -) -> Result { - let root = skills_root_dir()?; - read_skill_text_from_root(&root, &path, offset, length) -} - -fn read_skill_text_from_root( - root: &Path, - path: &str, - offset: Option, - length: Option, -) -> Result { - let rel = sanitize_skill_rel_path(path)?; - let target = root.join(rel); - let target = ensure_within_skills_root_existing(root, &target)?; - let md = - fs::metadata(&target).map_err(|e| format!("Failed to read Skill file metadata: {e}"))?; - if !md.is_file() { - return Err("Only regular Skill files can be read (not directories)".to_string()); - } - - let offset = offset.unwrap_or(0); - let length = length.unwrap_or(DEFAULT_SKILL_READ_LENGTH_LINES); - - let file = - fs::File::open(&target).map_err(|e| format!("Failed to open the Skill file: {e}"))?; - let mut reader = BufReader::new(file); - - let mut line_idx: usize = 0; - let mut taken: usize = 0; - let mut out = String::new(); - let mut truncated = false; - let mut buf = Vec::new(); - - loop { - buf.clear(); - let n = reader - .read_until(b'\n', &mut buf) - .map_err(|e| format!("Failed to read the Skill file: {e}"))?; - if n == 0 { - break; - } - - if line_idx < offset { - line_idx += 1; - continue; - } - - if taken >= length { - truncated = true; - break; - } - - if out.len().saturating_add(buf.len()) > SKILL_READ_MAX_BYTES { - truncated = true; - break; - } - - out.push_str(&String::from_utf8_lossy(&buf)); - line_idx += 1; - taken += 1; - } - - Ok(SystemReadSkillTextResponse { - content: out, - truncated, - }) -} - -fn discover_skill_dirs(root: &Path) -> Vec { - let mut root = root.to_path_buf(); - if root.is_file() && is_skill_metadata_candidate(&root) { - if let Some(parent) = root.parent() { - root = parent.to_path_buf(); - } - } - - if root.is_dir() && standard_metadata_file_for(&root).is_some() { - return vec![root]; - } - - let nested_skills = root.join("skills"); - if nested_skills.is_dir() { - let candidates = read_child_skill_dirs(&nested_skills); - if !candidates.is_empty() { - return candidates; - } - } - - if root.is_dir() { - let candidates = read_child_skill_dirs(&root); - if !candidates.is_empty() { - return candidates; - } - } - - if is_skill_dir(&root) { - return vec![root]; - } - - Vec::new() -} - -fn read_child_skill_dirs(root: &Path) -> Vec { - let mut candidates = Vec::new(); - let Ok(entries) = fs::read_dir(root) else { - return candidates; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path - .file_name() - .map(|name| { - let name = name.to_string_lossy(); - name.starts_with('.') || name.contains(".backup-") - }) - .unwrap_or(false) - { - continue; - } - if is_skill_dir(&path) { - candidates.push(path); - } - } - candidates.sort(); - candidates -} - -fn read_skill_source_metadata(skill_dir: &Path) -> Option { - let meta_path = skill_dir.join("_meta.json"); - let content = fs::read_to_string(meta_path).ok()?; - let value = serde_json::from_str::(&content).ok()?; - let slug = value - .get("slug") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())? - .to_string(); - let version = value - .get("version") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - let published_at = value.get("publishedAt").and_then(Value::as_u64); - - Some(SystemSkillSourceMetadata { - registry: "clawhub".to_string(), - slug, - version, - published_at, - }) -} - -fn write_skill_source_metadata_for_install( - root: &Path, - payload: &serde_json::Map, - installed: &[SystemSkillInstallResult], -) -> Result<(), String> { - let Some(slug) = object_string(payload, "slug") else { - return Ok(()); - }; - let version = object_string(payload, "version").map(ToOwned::to_owned); - let published_at = payload.get("publishedAt").and_then(Value::as_u64); - let metadata = serde_json::json!({ - "registry": "clawhub", - "slug": slug, - "version": version, - "publishedAt": published_at, - }); - let bytes = serde_json::to_vec_pretty(&metadata) - .map_err(|e| format!("Failed to serialize Skill source metadata: {e}"))?; - - for item in installed { - let target = root.join(&item.name); - fs::write(target.join("_meta.json"), &bytes) - .map_err(|e| format!("Failed to write Skill source metadata: {e}"))?; - } - - Ok(()) -} - -fn skill_summary_from_dir(root: &Path, skill_dir: &Path) -> Result { - let metadata = read_skill_metadata_from_dir(skill_dir)?; - let skill_file = rel_to_root_str(root, &metadata.metadata_file); - let base_dir = rel_to_root_str(root, skill_dir); - Ok(SystemSkillSummary { - name: metadata.name, - description: metadata.description, - target: display_path(skill_dir), - skill_file, - base_dir, - source: read_skill_source_metadata(skill_dir), - }) -} - -fn list_installed_skills( - root: &Path, -) -> Result<(Vec, Vec), String> { - let mut skills = Vec::new(); - let mut invalid = Vec::new(); - let entries = fs::read_dir(root).map_err(|e| format!("Failed to list Skills root: {e}"))?; - for entry in entries { - let entry = match entry { - Ok(entry) => entry, - Err(error) => { - invalid.push(SystemSkillInvalidEntry { - path: root.display().to_string(), - error: error.to_string(), - }); - continue; - } - }; - let path = entry.path(); - let name = entry.file_name().to_string_lossy().to_string(); - if name.starts_with('.') || name.contains(".backup-") || !path.is_dir() { - continue; - } - match skill_summary_from_dir(root, &path) { - Ok(summary) => skills.push(summary), - Err(error) => invalid.push(SystemSkillInvalidEntry { - path: display_path(&path), - error, - }), - } - } - skills.sort_by(|a, b| a.name.cmp(&b.name)); - Ok((skills, invalid)) -} - -fn backup_existing_path( - dest_root: &Path, - target: &Path, - skill_name: &str, -) -> Result { - let backups_root = dest_root.join(".backups"); - fs::create_dir_all(&backups_root) - .map_err(|e| format!("Failed to create Skills backup directory: {e}"))?; - let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); - let mut backup = backups_root.join(format!("{skill_name}-{stamp}")); - let mut counter = 1usize; - while backup.exists() { - backup = backups_root.join(format!("{skill_name}-{stamp}-{counter}")); - counter += 1; - } - fs::rename(target, &backup).map_err(|e| { - format!( - "Failed to move existing Skill to backup {}: {e}", - backup.display() - ) - })?; - Ok(backup) -} - -fn copy_dir_safely(source_dir: &Path, target: &Path) -> Result<(), String> { - for entry in WalkDir::new(source_dir).follow_links(false).min_depth(1) { - let entry = entry.map_err(|e| format!("Failed to inspect source Skill: {e}"))?; - let source_path = entry.path(); - let rel = source_path - .strip_prefix(source_dir) - .map_err(|e| format!("Failed to compute relative Skill path: {e}"))?; - let target_path = target.join(rel); - let file_type = entry.file_type(); - if file_type.is_symlink() { - return Err(format!( - "Skill source contains a symlink, which is not supported: {}", - source_path.display() - )); - } - if file_type.is_dir() { - fs::create_dir_all(&target_path) - .map_err(|e| format!("Failed to create Skill directory: {e}"))?; - continue; - } - if file_type.is_file() { - let size = entry - .metadata() - .map_err(|e| format!("Failed to read source Skill file metadata: {e}"))? - .len(); - if size > MAX_SKILL_FILE_BYTES { - return Err(format!( - "Skill file is too large: {} ({} bytes)", - source_path.display(), - size - )); - } - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create Skill parent directory: {e}"))?; - } - fs::copy(source_path, &target_path).map_err(|e| { - format!( - "Failed to copy Skill file {} to {}: {e}", - source_path.display(), - target_path.display() - ) - })?; - } - } - Ok(()) -} - -fn copy_skill_with_conflict( - source_dir: &Path, - dest_root: &Path, - skill_name: &str, - conflict: &str, -) -> Result { - let skill_name = sanitize_skill_name(skill_name)?; - fs::create_dir_all(dest_root) - .map_err(|e| format!("Failed to create Skills root directory: {e}"))?; - let target = dest_root.join(&skill_name); - let mut backup = None; - - if target.exists() { - if source_dir.canonicalize().ok() == target.canonicalize().ok() { - let metadata = read_skill_metadata_from_dir(&target)?; - return Ok(SystemSkillInstallResult { - name: skill_name, - target: display_path(&target), - backup: None, - skill_file: rel_to_root_str(dest_root, &metadata.metadata_file), - }); - } - - match conflict { - "fail" => return Err(format!("Destination already exists: {}", target.display())), - "overwrite" => { - let meta = fs::symlink_metadata(&target) - .map_err(|e| format!("Failed to inspect destination: {e}"))?; - if meta.is_dir() { - fs::remove_dir_all(&target).map_err(|e| { - format!("Failed to remove existing Skill {}: {e}", target.display()) - })?; - } else { - fs::remove_file(&target).map_err(|e| { - format!("Failed to remove existing Skill {}: {e}", target.display()) - })?; - } - } - "backup" => { - backup = Some(backup_existing_path(dest_root, &target, &skill_name)?); - } - other => return Err(format!("Unsupported conflict mode: {other}")), - } - } - - fs::create_dir_all(&target) - .map_err(|e| format!("Failed to create target Skill directory: {e}"))?; - copy_dir_safely(source_dir, &target)?; - let metadata = read_skill_metadata_from_dir(&target)?; - if metadata.name != skill_name { - return Err(format!( - "Installed Skill metadata name '{}' does not match target directory '{}'", - metadata.name, skill_name - )); - } - - Ok(SystemSkillInstallResult { - name: skill_name, - target: display_path(&target), - backup: backup.map(|path| display_path(&path)), - skill_file: rel_to_root_str(dest_root, &metadata.metadata_file), - }) -} - -fn safe_extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { - fs::create_dir_all(dest_dir) - .map_err(|e| format!("Failed to create archive extraction directory: {e}"))?; - let file = fs::File::open(zip_path) - .map_err(|e| format!("Failed to open Skill archive {}: {e}", zip_path.display()))?; - let mut archive = ZipArchive::new(file) - .map_err(|e| format!("Failed to read Skill archive {}: {e}", zip_path.display()))?; - - if archive.len() > MAX_SKILL_INSTALL_FILES { - return Err(format!( - "Skill archive contains too many files: {}", - archive.len() - )); - } - - let mut total_bytes = 0u64; - for index in 0..archive.len() { - let mut file = archive - .by_index(index) - .map_err(|e| format!("Failed to read archive entry: {e}"))?; - let Some(enclosed_name) = file.enclosed_name().map(PathBuf::from) else { - return Err(format!( - "Archive entry escapes extraction root: {}", - file.name() - )); - }; - if enclosed_name.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::Prefix(_) | Component::RootDir - ) - }) { - return Err(format!("Archive entry has an unsafe path: {}", file.name())); - } - if file - .unix_mode() - .map(|mode| (mode & 0o170000) == 0o120000) - .unwrap_or(false) - { - return Err(format!("Archive entry is a symlink: {}", file.name())); - } - total_bytes = total_bytes.saturating_add(file.size()); - if total_bytes > MAX_SKILL_INSTALL_BYTES { - return Err(format!( - "Skill archive is too large after extraction, over {} bytes", - MAX_SKILL_INSTALL_BYTES - )); - } - - let out_path = dest_dir.join(enclosed_name); - if file.name().ends_with('/') { - fs::create_dir_all(&out_path) - .map_err(|e| format!("Failed to create archive directory: {e}"))?; - continue; - } - if let Some(parent) = out_path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create archive parent directory: {e}"))?; - } - let mut out_file = fs::File::create(&out_path) - .map_err(|e| format!("Failed to create archive output file: {e}"))?; - io::copy(&mut file, &mut out_file) - .map_err(|e| format!("Failed to extract archive entry: {e}"))?; - } - Ok(()) -} - -fn write_download_to_path_with_progress( - url: &str, - target: &Path, - mut on_progress: F, -) -> Result, String> -where - F: FnMut(u64, Option), -{ - let client = HttpClient::builder() - .timeout(Duration::from_secs(30)) - .user_agent("liveagent-skill-installer") - .build() - .map_err(|e| format!("Failed to create HTTP client: {e}"))?; - let mut response = client - .get(url) - .send() - .map_err(|e| format!("Failed to download Skill source: {e}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("Skill source download failed with HTTP {status}")); - } - let total_bytes = response.content_length(); - if total_bytes - .map(|value| value > MAX_SKILL_INSTALL_BYTES) - .unwrap_or(false) - { - return Err(format!( - "Downloaded Skill source is too large, over {} bytes", - MAX_SKILL_INSTALL_BYTES - )); - } - - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create Skill download directory: {e}"))?; - } - let mut output = fs::File::create(target) - .map_err(|e| format!("Failed to stage downloaded Skill source: {e}"))?; - let mut bytes = Vec::new(); - let mut downloaded = 0u64; - let mut buffer = [0u8; 64 * 1024]; - on_progress(downloaded, total_bytes); - - loop { - let read = response - .read(&mut buffer) - .map_err(|e| format!("Failed to read Skill source response: {e}"))?; - if read == 0 { - break; - } - downloaded = downloaded.saturating_add(read as u64); - if downloaded > MAX_SKILL_INSTALL_BYTES { - return Err(format!( - "Downloaded Skill source is too large, over {} bytes", - MAX_SKILL_INSTALL_BYTES - )); - } - output - .write_all(&buffer[..read]) - .map_err(|e| format!("Failed to write downloaded Skill source: {e}"))?; - bytes.extend_from_slice(&buffer[..read]); - on_progress(downloaded, total_bytes); - } - output - .flush() - .map_err(|e| format!("Failed to flush downloaded Skill source: {e}"))?; - Ok(bytes) -} - -fn write_download_to_path(url: &str, target: &Path) -> Result, String> { - write_download_to_path_with_progress(url, target, |_, _| {}) -} - -fn is_github_source(value: &str) -> bool { - let Ok(url) = reqwest::Url::parse(value) else { - return false; - }; - matches!(url.scheme(), "http" | "https") - && matches!(url.host_str(), Some("github.com" | "www.github.com")) -} - -fn is_http_source(value: &str) -> bool { - let Ok(url) = reqwest::Url::parse(value) else { - return false; - }; - matches!(url.scheme(), "http" | "https") -} - -fn parse_github_url(value: &str, default_ref: &str) -> Result { - let url = reqwest::Url::parse(value).map_err(|e| format!("Invalid GitHub URL: {e}"))?; - if !matches!(url.host_str(), Some("github.com" | "www.github.com")) { - return Err("Only github.com URLs are supported".to_string()); - } - let parts = url - .path_segments() - .ok_or_else(|| "GitHub URL must include owner and repo".to_string())? - .filter(|part| !part.is_empty()) - .collect::>(); - if parts.len() < 2 { - return Err("GitHub URL must include owner and repo".to_string()); - } - let owner = parts[0].to_string(); - let repo = parts[1].trim_end_matches(".git").to_string(); - let mut git_ref = default_ref.trim(); - if git_ref.is_empty() { - git_ref = DEFAULT_GITHUB_REF; - } - let mut subpath = None; - - if parts.len() > 2 { - let marker = parts[2]; - if marker == "tree" || marker == "blob" { - if parts.len() < 4 { - return Err("GitHub tree/blob URL must include a ref".to_string()); - } - git_ref = parts[3]; - if parts.len() > 4 { - subpath = Some(parts[4..].join("/")); - } - } else { - subpath = Some(parts[2..].join("/")); - } - } - - Ok(GithubSource { - owner, - repo, - git_ref: git_ref.to_string(), - subpath, - }) -} - -fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> { - let mut command = Command::new("git"); - crate::runtime::process::configure_child_process_group(&mut command); - command.args(args); - if let Some(cwd) = cwd { - command.current_dir(cwd); - } - let output = command - .output() - .map_err(|e| format!("Failed to start git: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(if stderr.is_empty() { - "git command failed".to_string() - } else { - stderr - }); - } - Ok(()) -} - -fn prepare_github_source( - value: &str, - method: &str, - default_ref: &str, - tmp_root: &Path, -) -> Result { - let source = parse_github_url(value, default_ref)?; - let mut repo_root = None; - if method == "auto" || method == "download" { - let archive = tmp_root.join("github-repo.zip"); - let zip_url = format!( - "https://codeload.github.com/{}/{}/zip/{}", - source.owner, source.repo, source.git_ref - ); - match write_download_to_path(&zip_url, &archive).and_then(|_| { - let extract_dir = tmp_root.join("github-download"); - safe_extract_zip(&archive, &extract_dir)?; - let mut top_levels = fs::read_dir(&extract_dir) - .map_err(|e| format!("Failed to inspect GitHub archive: {e}"))? - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .collect::>(); - top_levels.sort(); - if top_levels.len() != 1 { - return Err("Unexpected GitHub archive layout".to_string()); - } - Ok(top_levels.remove(0)) - }) { - Ok(path) => repo_root = Some(path), - Err(error) if method == "download" => { - return Err(format!("GitHub download failed: {error}")); - } - Err(_) => {} - } - } - - if repo_root.is_none() { - let repo_dir = tmp_root.join("github-repo"); - let repo_url = format!("https://github.com/{}/{}.git", source.owner, source.repo); - let repo_dir_str = repo_dir - .to_str() - .ok_or_else(|| "Temporary git path is not valid UTF-8".to_string())?; - let mut clone_args = vec![ - "clone", - "--depth", - "1", - "--single-branch", - "--branch", - source.git_ref.as_str(), - ]; - if source.subpath.is_some() { - clone_args.push("--filter=blob:none"); - clone_args.push("--sparse"); - } - clone_args.push(repo_url.as_str()); - clone_args.push(repo_dir_str); - run_git(&clone_args, None)?; - if let Some(subpath) = source.subpath.as_deref() { - run_git(&["sparse-checkout", "set", subpath], Some(&repo_dir))?; - run_git(&["checkout", source.git_ref.as_str()], Some(&repo_dir))?; - } - repo_root = Some(repo_dir); - } - - let repo_root = repo_root.ok_or_else(|| "Failed to prepare GitHub source".to_string())?; - if let Some(subpath) = source.subpath { - let selected = repo_root.join(&subpath); - if !selected.exists() { - return Err(format!("GitHub path not found: {subpath}")); - } - return Ok(selected); - } - Ok(repo_root) -} - -fn prepare_http_source_with_progress( - value: &str, - tmp_root: &Path, - mut on_progress: F, -) -> Result -where - F: FnMut(SkillInstallProgressUpdate), -{ - let url = reqwest::Url::parse(value).map_err(|e| format!("Invalid source URL: {e}"))?; - let lower_path = url.path().to_ascii_lowercase(); - let download_path = tmp_root.join("downloaded-skill-source"); - on_progress(SkillInstallProgressUpdate { - phase: "downloading", - downloaded_bytes: Some(0), - total_bytes: None, - message: Some("Downloading Skill archive".to_string()), - }); - let bytes = - write_download_to_path_with_progress(value, &download_path, |downloaded, total| { - on_progress(SkillInstallProgressUpdate { - phase: "downloading", - downloaded_bytes: Some(downloaded), - total_bytes: total, - message: Some("Downloading Skill archive".to_string()), - }); - })?; - let is_zip = lower_path.ends_with(".zip") - || lower_path.ends_with(".skill") - || bytes.starts_with(b"PK\x03\x04"); - - if is_zip { - let extract_dir = tmp_root.join("downloaded-archive"); - on_progress(SkillInstallProgressUpdate { - phase: "extracting", - downloaded_bytes: None, - total_bytes: None, - message: Some("Extracting Skill archive".to_string()), - }); - safe_extract_zip(&download_path, &extract_dir)?; - return Ok(extract_dir); - } - - if lower_path.ends_with("skill.json") - || lower_path.ends_with("skill.md") - || lower_path.ends_with("skill") - || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) - .trim_start() - .starts_with("---") - || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) - .trim_start() - .starts_with('{') - { - let single_dir = tmp_root.join("downloaded-single-skill"); - on_progress(SkillInstallProgressUpdate { - phase: "validating", - downloaded_bytes: None, - total_bytes: None, - message: Some("Preparing downloaded Skill file".to_string()), - }); - fs::create_dir_all(&single_dir) - .map_err(|e| format!("Failed to stage downloaded Skill: {e}"))?; - let file_name = if lower_path.ends_with("skill.json") - || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) - .trim_start() - .starts_with('{') - { - "skill.json" - } else { - "SKILL.md" - }; - fs::write(single_dir.join(file_name), bytes) - .map_err(|e| format!("Failed to write downloaded Skill file: {e}"))?; - return Ok(single_dir); - } - - Err( - "HTTP(S) Skill sources must be .zip/.skill archives or a SKILL.md/skill.json file" - .to_string(), - ) -} - -fn prepare_local_or_archive_source(source: &str, tmp_root: &Path) -> Result { - let source_path = crate::runtime::platform::expand_tilde_path(source); - if !source_path.exists() { - return Err(format!("Source not found: {source}")); - } - let source_path = fs::canonicalize(&source_path).map_err(|e| { - format!( - "Failed to resolve source path {}: {e}", - source_path.display() - ) - })?; - - if source_path.is_dir() { - return Ok(source_path); - } - - if source_path.is_file() && is_archive_path(&source_path) { - let extract_dir = tmp_root.join("archive"); - safe_extract_zip(&source_path, &extract_dir)?; - return Ok(extract_dir); - } - - if source_path.is_file() && is_skill_metadata_candidate(&source_path) { - return source_path - .parent() - .map(Path::to_path_buf) - .ok_or_else(|| "Source Skill file has no parent directory".to_string()); - } - - Err("Source must be a skill directory, .zip/.skill archive, GitHub URL, or HTTP(S) Skill download URL".to_string()) -} - -fn normalize_conflict(value: Option<&str>, default_value: &str) -> Result { - let raw = value.unwrap_or(default_value).trim(); - match raw { - "backup" | "fail" | "overwrite" => Ok(raw.to_string()), - _ => Err(format!("Unsupported conflict mode: {raw}")), - } -} - -fn normalize_method(value: Option<&str>) -> Result { - let raw = value.unwrap_or("auto").trim(); - match raw { - "auto" | "download" | "git" => Ok(raw.to_string()), - _ => Err(format!("Unsupported GitHub method: {raw}")), - } -} - -fn object_string<'a>(payload: &'a serde_json::Map, key: &str) -> Option<&'a str> { - payload - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -fn object_usize(payload: &serde_json::Map, key: &str) -> Option { - payload - .get(key) - .and_then(Value::as_u64) - .and_then(|value| usize::try_from(value).ok()) -} - -fn normalize_clawhub_limit(value: Option) -> usize { - value - .unwrap_or(DEFAULT_CLAWHUB_SEARCH_LIMIT) - .clamp(1, MAX_CLAWHUB_SEARCH_LIMIT) -} - -fn normalize_clawhub_sort(value: Option<&str>) -> Result<&'static str, String> { - match value.unwrap_or("downloads") { - "downloads" => Ok("downloads"), - "stars" => Ok("stars"), - "installs" => Ok("installs"), - "updated" => Ok("updated"), - "newest" => Ok("newest"), - other => Err(format!("Unsupported ClawHub sort: {other}")), - } -} - -fn json_object(value: &Value) -> Option<&serde_json::Map> { - value.as_object() -} - -fn json_string(item: &serde_json::Map, key: &str) -> Option { - item.get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn json_u64(item: &serde_json::Map, key: &str) -> u64 { - match item.get(key) { - Some(Value::Number(number)) => number - .as_u64() - .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())) - .or_else(|| { - number.as_f64().and_then(|value| { - if value.is_finite() && value >= 0.0 { - Some(value as u64) - } else { - None - } - }) - }) - .unwrap_or(0), - _ => 0, - } -} - -fn json_optional_u64(item: &serde_json::Map, key: &str) -> Option { - item.get(key).and_then(|value| match value { - Value::Number(number) => number - .as_u64() - .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())), - _ => None, - }) -} - -fn clawhub_download_url_for_slug(slug: &str, tag: Option<&str>) -> Result { - let slug = slug.trim(); - if slug.is_empty() { - return Err("SkillsManager clawhub_install requires slug".to_string()); - } - let tag = tag - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("latest"); - let mut url = reqwest::Url::parse(CLAWHUB_API_BASE) - .and_then(|base| base.join("/api/v1/download")) - .map_err(|e| format!("Failed to build ClawHub download URL: {e}"))?; - url.query_pairs_mut() - .append_pair("slug", slug) - .append_pair("tag", tag); - Ok(url.into()) -} - -fn normalize_clawhub_skill_card(raw: &Value) -> Option { - let item = json_object(raw)?; - let slug = json_string(item, "slug")?; - let stats = item.get("stats").and_then(json_object); - let latest_version = item - .get("latestVersion") - .and_then(json_object) - .and_then(|value| json_string(value, "version")) - .or_else(|| { - item.get("tags") - .and_then(json_object) - .and_then(|value| json_string(value, "latest")) - }) - .or_else(|| json_string(item, "version")); - let owner_handle = json_string(item, "ownerHandle").or_else(|| { - item.get("owner") - .and_then(json_object) - .and_then(|value| json_string(value, "handle")) - }); - let download_url = clawhub_download_url_for_slug(&slug, None).ok()?; - let web_url = owner_handle - .as_ref() - .map(|owner| format!("{CLAWHUB_API_BASE}/{owner}/{slug}")); - - Some(SystemClawHubSkillCard { - slug: slug.clone(), - display_name: json_string(item, "displayName").unwrap_or(slug), - summary: json_string(item, "summary").unwrap_or_default(), - latest_version, - downloads: stats.map(|value| json_u64(value, "downloads")).unwrap_or(0), - stars: stats.map(|value| json_u64(value, "stars")).unwrap_or(0), - installs_current: stats - .map(|value| json_u64(value, "installsCurrent")) - .unwrap_or(0), - updated_at: json_optional_u64(item, "updatedAt"), - owner_handle, - web_url, - download_url, - }) -} - -fn fetch_clawhub_json(path: &str, params: &[(&str, String)]) -> Result { - let mut url = reqwest::Url::parse(CLAWHUB_API_BASE) - .and_then(|base| base.join(path)) - .map_err(|e| format!("Failed to build ClawHub request URL: {e}"))?; - { - let mut pairs = url.query_pairs_mut(); - for (key, value) in params { - pairs.append_pair(key, value); - } - } - - let client = HttpClient::builder() - .timeout(Duration::from_secs(30)) - .user_agent("liveagent-skillsmanager") - .build() - .map_err(|e| format!("Failed to create ClawHub HTTP client: {e}"))?; - let response = client - .get(url) - .header(reqwest::header::ACCEPT, "application/json") - .send() - .map_err(|e| format!("Failed to request ClawHub Skills: {e}"))?; - let status = response.status(); - if !status.is_success() { - return Err(format!("ClawHub request failed with HTTP {status}")); - } - response - .json::() - .map_err(|e| format!("Failed to parse ClawHub response: {e}")) -} - -fn clawhub_results_from_field(json: &Value, key: &str) -> Vec { - json.get(key) - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(normalize_clawhub_skill_card) - .collect::>() - }) - .unwrap_or_default() -} - -fn search_clawhub_skills_from_payload( - payload: &serde_json::Map, -) -> Result<(Vec, Option), String> { - let limit = normalize_clawhub_limit(object_usize(payload, "limit")); - if let Some(query) = object_string(payload, "query") { - let json = fetch_clawhub_json( - "/api/v1/search", - &[ - ("q", query.to_string()), - ("limit", limit.to_string()), - ("nonSuspiciousOnly", "true".to_string()), - ], - )?; - return Ok((clawhub_results_from_field(&json, "results"), None)); - } - - let sort = normalize_clawhub_sort(object_string(payload, "sort"))?; - let mut params = vec![ - ("limit", limit.to_string()), - ("sort", sort.to_string()), - ("nonSuspiciousOnly", "true".to_string()), - ]; - if let Some(cursor) = object_string(payload, "cursor") { - params.push(("cursor", cursor.to_string())); - } - let json = fetch_clawhub_json("/api/v1/skills", ¶ms)?; - let next_cursor = json - .get("nextCursor") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); - Ok((clawhub_results_from_field(&json, "items"), next_cursor)) -} - -fn action_from_payload(payload: &serde_json::Map) -> Result { - let action = object_string(payload, "action").unwrap_or_else(|| { - if object_string(payload, "path").is_some() { - "read" - } else { - "list" - } - }); - match action { - "read" | "list" | "install" | "install_start" | "install_status" | "create" - | "validate" | "package" | "delete" | "clawhub_search" | "clawhub_install" => { - Ok(action.to_string()) - } - _ => Err(format!("SkillsManager action is not supported: {action}")), - } -} - -fn install_source_from_payload( - root: &Path, - payload: &serde_json::Map, -) -> Result, String> { - install_source_from_payload_with_progress(root, payload, |_| {}) -} - -fn install_clawhub_skill_from_payload( - root: &Path, - payload: &serde_json::Map, -) -> Result<(Vec, String, String), String> { - let slug = object_string(payload, "slug") - .ok_or_else(|| "SkillsManager clawhub_install requires slug".to_string())? - .to_string(); - let version = object_string(payload, "version"); - let download_url = clawhub_download_url_for_slug(&slug, version)?; - let mut install_payload = payload.clone(); - install_payload.insert("action".to_string(), Value::String("install".to_string())); - install_payload.insert("source".to_string(), Value::String(download_url.clone())); - install_payload.insert("slug".to_string(), Value::String(slug.clone())); - - let installed = install_source_from_payload(root, &install_payload)?; - Ok((installed, slug, download_url)) -} - -fn install_source_from_payload_with_progress( - root: &Path, - payload: &serde_json::Map, - mut on_progress: F, -) -> Result, String> -where - F: FnMut(SkillInstallProgressUpdate), -{ - let source = object_string(payload, "source") - .ok_or_else(|| "SkillsManager install requires source".to_string())?; - let conflict = normalize_conflict(object_string(payload, "conflict"), "backup")?; - let method = normalize_method(object_string(payload, "method"))?; - let git_ref = object_string(payload, "ref").unwrap_or(DEFAULT_GITHUB_REF); - let name_override = object_string(payload, "name") - .map(sanitize_skill_name) - .transpose()?; - - let tmp = TempDir::new("liveagent-skill-install")?; - let stage_root = if is_github_source(source) { - on_progress(SkillInstallProgressUpdate { - phase: "downloading", - downloaded_bytes: None, - total_bytes: None, - message: Some("Preparing GitHub Skill source".to_string()), - }); - prepare_github_source(source, &method, git_ref, tmp.path())? - } else if is_http_source(source) { - prepare_http_source_with_progress(source, tmp.path(), |update| on_progress(update))? - } else { - on_progress(SkillInstallProgressUpdate { - phase: "validating", - downloaded_bytes: None, - total_bytes: None, - message: Some("Preparing local Skill source".to_string()), - }); - prepare_local_or_archive_source(source, tmp.path())? - }; - - on_progress(SkillInstallProgressUpdate { - phase: "validating", - downloaded_bytes: None, - total_bytes: None, - message: Some("Validating Skill metadata".to_string()), - }); - let candidates = discover_skill_dirs(&stage_root); - if candidates.is_empty() { - return Err( - "No skill directories found. Expected SKILL.md, skill.md, skill.json, or README.md." - .to_string(), - ); - } - if name_override.is_some() && candidates.len() != 1 { - return Err("name can only be used when exactly one skill is installed".to_string()); - } - - let mut results = Vec::new(); - for candidate in candidates { - let metadata = read_skill_metadata_from_dir(&candidate)?; - let skill_name = name_override.as_deref().unwrap_or(&metadata.name); - ensure_not_builtin_skill_management_target(skill_name, "install")?; - on_progress(SkillInstallProgressUpdate { - phase: "installing", - downloaded_bytes: None, - total_bytes: None, - message: Some(format!("Installing Skill {skill_name}")), - }); - let result = copy_skill_with_conflict(&candidate, root, skill_name, &conflict)?; - results.push(result); - } - write_skill_source_metadata_for_install(root, payload, &results)?; - Ok(results) -} - -fn start_install_job_from_payload( - root: PathBuf, - payload: &serde_json::Map, -) -> Result { - let source = object_string(payload, "source") - .ok_or_else(|| "SkillsManager install_start requires source".to_string())? - .to_string(); - let label = object_string(payload, "label").map(ToOwned::to_owned); - let slug = object_string(payload, "slug").map(ToOwned::to_owned); - let version = object_string(payload, "version").map(ToOwned::to_owned); - normalize_conflict(object_string(payload, "conflict"), "backup")?; - normalize_method(object_string(payload, "method"))?; - - let job_id = Uuid::new_v4().to_string(); - let now = now_millis(); - let snapshot = insert_install_job(SkillInstallJobState { - job_id: job_id.clone(), - phase: "queued".to_string(), - source, - label, - slug, - version, - downloaded_bytes: 0, - total_bytes: None, - message: Some("Queued Skill install".to_string()), - error: None, - installed: None, - started_at: now, - updated_at: now, - finished_at: None, - })?; - - let thread_job_id = job_id.clone(); - let payload = payload.clone(); - thread::spawn(move || { - let progress_job_id = thread_job_id.clone(); - let result = install_source_from_payload_with_progress(&root, &payload, move |update| { - let _ = update_install_job(&progress_job_id, |job| { - job.phase = update.phase.to_string(); - if update.phase == "downloading" { - job.total_bytes = update.total_bytes; - } - if let Some(downloaded_bytes) = update.downloaded_bytes { - job.downloaded_bytes = downloaded_bytes; - } - if let Some(message) = update.message { - job.message = Some(message); - } - job.error = None; - }); - }); - - match result { - Ok(installed) => { - let _ = update_install_job(&thread_job_id, |job| { - job.phase = "done".to_string(); - job.message = Some("Skill installed".to_string()); - job.error = None; - job.installed = Some(installed); - job.finished_at = Some(now_millis()); - }); - } - Err(error) => { - let _ = update_install_job(&thread_job_id, |job| { - job.phase = "error".to_string(); - job.message = Some("Skill install failed".to_string()); - job.error = Some(error); - job.finished_at = Some(now_millis()); - }); - } - } - }); - - Ok(snapshot) -} - -fn create_skill_from_payload( - root: &Path, - payload: &serde_json::Map, -) -> Result { - let raw_name = object_string(payload, "name") - .ok_or_else(|| "SkillsManager create requires name".to_string())?; - let normalized = normalize_skill_name(raw_name); - let name = sanitize_skill_name(&normalized)?; - ensure_not_builtin_skill_management_target(&name, "create")?; - let description = object_string(payload, "description") - .ok_or_else(|| "SkillsManager create requires description".to_string())? - .trim() - .to_string(); - if description.len() > MAX_SKILL_DESCRIPTION_LENGTH { - return Err(format!( - "Skill description is too long; maximum is {MAX_SKILL_DESCRIPTION_LENGTH}" - )); - } - let body = object_string(payload, "body"); - let conflict = normalize_conflict(object_string(payload, "conflict"), "fail")?; - - let tmp = TempDir::new("liveagent-skill-create")?; - let source_dir = tmp.path().join(&name); - fs::create_dir_all(&source_dir) - .map_err(|e| format!("Failed to create staged Skill directory: {e}"))?; - fs::write( - source_dir.join("SKILL.md"), - render_skill_template(&name, &description, body), - ) - .map_err(|e| format!("Failed to write staged SKILL.md: {e}"))?; - - if let Some(files) = payload.get("files") { - let files = files - .as_array() - .ok_or_else(|| "SkillsManager create files must be an array".to_string())?; - for file in files { - let file = file - .as_object() - .ok_or_else(|| "SkillsManager create file entries must be objects".to_string())?; - let rel = object_string(file, "path") - .ok_or_else(|| "SkillsManager create file.path is required".to_string())?; - let rel_path = sanitize_skill_child_rel_path(rel)?; - if is_skill_metadata_candidate(&rel_path) { - return Err("Use name/description/body to create SKILL.md; files must not replace Skill metadata".to_string()); - } - let content = file - .get("content") - .and_then(Value::as_str) - .ok_or_else(|| "SkillsManager create file.content is required".to_string())?; - if content.len() as u64 > MAX_SKILL_FILE_BYTES { - return Err(format!("Skill file is too large: {rel}")); - } - let target = source_dir.join(rel_path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create staged Skill file parent: {e}"))?; - } - fs::write(&target, content).map_err(|e| { - format!( - "Failed to write staged Skill file {}: {e}", - target.display() - ) - })?; - } - } - - let validation = validate_skill_dir(&source_dir); - if !validation.ok { - return Err(format!( - "Created Skill did not validate:\n{}", - validation.errors.join("\n") - )); - } - - copy_skill_with_conflict(&source_dir, root, &name, &conflict) -} - -fn frontmatter_keys(yaml: &str) -> Vec { - if !yaml.contains('\n') { - return [ - "name", - "description", - "license", - "allowed-tools", - "metadata", - ] - .into_iter() - .filter(|key| inline_yaml_key_start(yaml, key).is_some()) - .map(ToString::to_string) - .collect(); - } - - let mut keys = Vec::new(); - for line in yaml.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - if line - .chars() - .next() - .map(char::is_whitespace) - .unwrap_or(false) - { - continue; - } - if let Some((key, _)) = line.split_once(':') { - let key = key.trim(); - if !key.is_empty() - && key - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') - { - keys.push(key.to_string()); - } - } - } - keys -} - -fn split_frontmatter(content: &str) -> Result<(String, String), String> { - let normalized = strip_utf8_bom(content); - if let Some((yaml, body)) = split_inline_frontmatter(normalized) { - return Ok((yaml, body)); - } - - let lines = normalized.split_inclusive('\n').collect::>(); - let mut index = 0usize; - while index < lines.len() && lines[index].trim().is_empty() { - index += 1; - } - if index >= lines.len() || lines[index].trim() != "---" { - return Err("Skill frontmatter must start with ---".to_string()); - } - index += 1; - let mut yaml = String::new(); - while index < lines.len() { - if lines[index].trim() == "---" { - let body = lines[index + 1..].join(""); - return Ok((yaml, body)); - } - yaml.push_str(lines[index]); - index += 1; - } - Err("Skill frontmatter is missing the closing ---".to_string()) -} - -fn validate_skill_dir(skill_dir: &Path) -> SkillValidationResult { - let mut errors = Vec::new(); - if !skill_dir.exists() { - return SkillValidationResult { - ok: false, - errors: vec![format!( - "Skill directory not found: {}", - skill_dir.display() - )], - metadata: None, - }; - } - if !skill_dir.is_dir() { - return SkillValidationResult { - ok: false, - errors: vec![format!("Path is not a directory: {}", skill_dir.display())], - metadata: None, - }; - } - - let skill_md = skill_dir.join("SKILL.md"); - let metadata_file = if skill_md.is_file() { - skill_md - } else { - match metadata_file_for(skill_dir) { - Some(path) => path, - None => { - return SkillValidationResult { - ok: false, - errors: vec![ - "SKILL.md, skill.md, skill.json, or README.md not found".to_string() - ], - metadata: None, - }; - } - } - }; - - let mut metadata = None; - let mut metadata_from_plain_readme = false; - if is_skill_json(&metadata_file) { - match fs::read_to_string(&metadata_file) - .map_err(|e| e.to_string()) - .and_then(|content| { - let parsed = parse_skill_json_metadata(&content); - let name = parsed - .name - .ok_or_else(|| "Missing 'name' in skill.json".to_string())?; - let description = parsed - .description - .ok_or_else(|| "Missing 'description' in skill.json".to_string())?; - Ok(SkillMetadata { - name, - description, - metadata_file: metadata_file.clone(), - }) - }) { - Ok(value) => metadata = Some(value), - Err(error) => errors.push(error), - } - } else { - match fs::read_to_string(&metadata_file) - .map_err(|e| format!("Failed to read {}: {e}", metadata_file.display())) - .and_then(|content| { - let frontmatter = split_frontmatter(&content); - let (yaml, has_frontmatter) = match frontmatter { - Ok((yaml, _body)) => (Some(yaml), true), - Err(error) - if is_readme_markdown(&metadata_file) - && is_missing_frontmatter_error(&error) => - { - (None, false) - } - Err(error) => return Err(error), - }; - - if let Some(yaml) = yaml { - let keys = frontmatter_keys(&yaml); - let allowed = [ - "name", - "description", - "license", - "allowed-tools", - "metadata", - ]; - let unexpected = keys - .iter() - .filter(|key| !allowed.contains(&key.as_str())) - .cloned() - .collect::>(); - if !unexpected.is_empty() { - errors.push(format!( - "Unexpected key(s) in Skill frontmatter: {}", - unexpected.join(", ") - )); - } - let parsed = parse_skill_frontmatter_yaml_metadata(&yaml); - if is_readme_markdown(&metadata_file) - && parsed.name.is_none() - && parsed.description.is_none() - { - metadata_from_plain_readme = true; - let name = fallback_readme_skill_name(skill_dir)?; - let description = first_readme_description_line(&content) - .unwrap_or_else(|| format!("README.md skill instructions for {name}")) - .chars() - .take(MAX_SKILL_DESCRIPTION_LENGTH) - .collect(); - return Ok(SkillMetadata { - name, - description, - metadata_file: metadata_file.clone(), - }); - } - let name = parsed - .name - .ok_or_else(|| "Missing 'name' in frontmatter".to_string())?; - let description = parsed - .description - .ok_or_else(|| "Missing 'description' in frontmatter".to_string())?; - return Ok(SkillMetadata { - name, - description, - metadata_file: metadata_file.clone(), - }); - } - - if !has_frontmatter && is_readme_markdown(&metadata_file) { - metadata_from_plain_readme = true; - let name = fallback_readme_skill_name(skill_dir)?; - let description = first_readme_description_line(&content) - .unwrap_or_else(|| format!("README.md skill instructions for {name}")) - .chars() - .take(MAX_SKILL_DESCRIPTION_LENGTH) - .collect(); - return Ok(SkillMetadata { - name, - description, - metadata_file: metadata_file.clone(), - }); - } - - Err("Missing Skill metadata".to_string()) - }) { - Ok(value) => metadata = Some(value), - Err(error) => errors.push(error), - } - } - - if let Some(metadata) = metadata.as_ref() { - if let Err(error) = sanitize_skill_name(&metadata.name) { - errors.push(error); - } - if metadata.description.contains('<') || metadata.description.contains('>') { - errors.push("Description cannot contain angle brackets (< or >)".to_string()); - } - if let Some(ch) = first_non_english_script_char(&metadata.description) { - errors.push(format!( - "Description must be written in English only; found non-English script character U+{:04X}", - ch as u32 - )); - } - if metadata.description.len() > MAX_SKILL_DESCRIPTION_LENGTH { - errors.push(format!( - "Description is too long; maximum is {MAX_SKILL_DESCRIPTION_LENGTH}" - )); - } - let dir_name = skill_dir - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_default(); - if !metadata_from_plain_readme && dir_name != metadata.name { - errors.push(format!( - "Directory name '{dir_name}' must match frontmatter name '{}'", - metadata.name - )); - } - } - - for entry in WalkDir::new(skill_dir).follow_links(false).min_depth(1) { - let Ok(entry) = entry else { - continue; - }; - if entry.file_type().is_symlink() { - errors.push(format!( - "Symlink is not allowed inside a Skill: {}", - entry.path().display() - )); - continue; - } - if !entry.file_type().is_file() { - continue; - } - let rel = entry.path().strip_prefix(skill_dir).unwrap_or(entry.path()); - if matches!( - entry.file_name().to_string_lossy().as_ref(), - "README.md" | "INSTALLATION_GUIDE.md" | "QUICK_REFERENCE.md" | "CHANGELOG.md" - ) && entry.path() != metadata_file - { - errors.push(format!( - "Forbidden documentation file found: {}", - rel.to_string_lossy() - )); - } - if is_markdown_document(rel) { - validate_english_markdown_document(entry.path(), rel, &mut errors); - } - let ext = entry - .path() - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()); - if matches!(ext.as_deref(), Some("py" | "sh" | "bash")) { - match fs::File::open(entry.path()) { - Ok(file) => { - let mut reader = BufReader::new(file); - let mut line = String::new(); - if reader.read_line(&mut line).is_ok() && !line.starts_with("#!") { - errors.push(format!( - "Script file lacks a shebang: {}", - rel.to_string_lossy() - )); - } - } - Err(error) => errors.push(format!( - "Failed to inspect script file {}: {error}", - rel.to_string_lossy() - )), - } - } - } - - SkillValidationResult { - ok: errors.is_empty(), - errors, - metadata, - } -} - -fn validate_installed_skill( - root: &Path, - name: &str, -) -> Result { - let name = sanitize_skill_name(name)?; - let target = root.join(&name); - let validation = validate_skill_dir(&target); - Ok(SystemSkillValidationResponse { - name, - target: display_path(&target), - ok: validation.ok, - errors: validation.errors, - }) -} - -fn delete_installed_skill(root: &Path, name: &str) -> Result { - let name = sanitize_skill_name(name)?; - ensure_not_builtin_skill_management_target(&name, "delete")?; - let target = root.join(&name); - let metadata = fs::symlink_metadata(&target).map_err(|e| { - format!( - "Skill does not exist or cannot be inspected: {}: {e}", - target.display() - ) - })?; - if metadata.file_type().is_symlink() { - return Err(format!( - "SkillsManager action=delete refuses to delete symlink target: {}", - target.display() - )); - } - if !metadata.is_dir() { - return Err(format!( - "SkillsManager action=delete requires an installed Skill directory: {}", - target.display() - )); - } - fs::remove_dir_all(&target) - .map_err(|e| format!("Failed to delete Skill {}: {e}", target.display()))?; - Ok(SystemSkillDeleteResponse { - name, - target: display_path(&target), - }) -} - -fn package_installed_skill(root: &Path, name: &str) -> Result { - let name = sanitize_skill_name(name)?; - let target = root.join(&name); - let validation = validate_skill_dir(&target); - if !validation.ok { - return Err(format!( - "Validation failed before packaging:\n{}", - validation.errors.join("\n") - )); - } - let packages_root = root.join(".packages"); - fs::create_dir_all(&packages_root) - .map_err(|e| format!("Failed to create Skills packages directory: {e}"))?; - let archive = packages_root.join(format!("{name}.skill")); - let archive_file = fs::File::create(&archive) - .map_err(|e| format!("Failed to create Skill archive {}: {e}", archive.display()))?; - let mut writer = ZipWriter::new(archive_file); - let options = FileOptions::default() - .compression_method(CompressionMethod::Deflated) - .unix_permissions(0o644); - - for entry in WalkDir::new(&target).follow_links(false).min_depth(1) { - let entry = entry.map_err(|e| format!("Failed to inspect Skill for packaging: {e}"))?; - if entry.file_type().is_symlink() { - return Err(format!( - "Cannot package symlink inside Skill: {}", - entry.path().display() - )); - } - if !entry.file_type().is_file() { - continue; - } - let rel = entry - .path() - .strip_prefix(root) - .map_err(|e| format!("Failed to compute archive path: {e}"))? - .to_string_lossy() - .replace('\\', "/"); - writer - .start_file(rel, options) - .map_err(|e| format!("Failed to start archive file: {e}"))?; - let mut file = fs::File::open(entry.path()) - .map_err(|e| format!("Failed to open Skill file for packaging: {e}"))?; - io::copy(&mut file, &mut writer) - .map_err(|e| format!("Failed to write Skill archive: {e}"))?; - } - writer - .finish() - .map_err(|e| format!("Failed to finish Skill archive: {e}"))?; - - Ok(SystemSkillPackageResponse { - name, - target: display_path(&target), - archive: display_path(&archive), - }) -} - -pub fn ensure_builtin_agent_skills_sync() -> Result, String> { - let root = skills_root_dir()?; - ensure_builtin_agent_skills_in_root(&root) -} - -fn builtin_skill_files_match(target: &Path, builtin: &BuiltinSkill) -> Result { - let mut actual_files = Vec::new(); - for entry in WalkDir::new(target).follow_links(false).min_depth(1) { - let entry = entry.map_err(|e| format!("Failed to inspect built-in Skill: {e}"))?; - if !entry.file_type().is_file() { - continue; - } - let rel = entry - .path() - .strip_prefix(target) - .map_err(|e| format!("Failed to compute built-in Skill path: {e}"))? - .to_string_lossy() - .replace('\\', "/"); - actual_files.push(rel); - } - actual_files.sort(); - - let mut expected_files = builtin - .files - .iter() - .map(|file| { - sanitize_skill_child_rel_path(file.path) - .map(|path| path.to_string_lossy().replace('\\', "/")) - }) - .collect::, _>>()?; - expected_files.sort(); - if actual_files != expected_files { - return Ok(false); - } - - for file in builtin.files { - let rel = sanitize_skill_child_rel_path(file.path)?; - let path = target.join(rel); - match fs::read_to_string(&path) { - Ok(content) if content == file.content => {} - Ok(_) => return Ok(false), - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), - Err(error) => { - return Err(format!( - "Failed to read built-in Skill file {}: {error}", - path.display() - )); - } - } - } - Ok(true) -} - -fn ensure_builtin_agent_skills_in_root( - root: &Path, -) -> Result, String> { - fs::create_dir_all(root).map_err(|e| format!("Failed to create Skills root directory: {e}"))?; - let mut results = Vec::new(); - for builtin in BUILTIN_AGENT_SKILLS { - let name = sanitize_skill_name(builtin.name)?; - let target = root.join(&name); - let mut backup = None; - let mut write_action = "created"; - - if target.exists() { - let validation = validate_skill_dir(&target); - let valid_same_name = validation.ok - && validation - .metadata - .as_ref() - .map(|metadata| metadata.name == name) - .unwrap_or(false); - if valid_same_name { - if builtin_skill_files_match(&target, builtin)? { - results.push(SystemBuiltinSkillSeedResponse { - name, - target: display_path(&target), - action: "kept".to_string(), - backup: None, - }); - continue; - } - write_action = "updated"; - } else { - write_action = "replaced_invalid"; - } - backup = Some(backup_existing_path(&root, &target, &name)?); - } - - fs::create_dir_all(&target) - .map_err(|e| format!("Failed to create built-in Skill directory: {e}"))?; - for file in builtin.files { - let rel = sanitize_skill_child_rel_path(file.path)?; - let path = target.join(rel); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create built-in Skill parent: {e}"))?; - } - fs::write(&path, file.content).map_err(|e| { - format!( - "Failed to write built-in Skill file {}: {e}", - path.display() - ) - })?; - } - let validation = validate_skill_dir(&target); - if !validation.ok { - return Err(format!( - "Built-in Skill '{}' did not validate after seeding:\n{}", - builtin.name, - validation.errors.join("\n") - )); - } - results.push(SystemBuiltinSkillSeedResponse { - name, - target: display_path(&target), - action: write_action.to_string(), - backup: backup.map(|path| display_path(&path)), - }); - } - Ok(results) -} - -pub fn system_manage_skill_sync(payload: Value) -> Result { - let root = skills_root_dir()?; - let root_dir = skill_root_display(&root); - let payload = payload - .as_object() - .ok_or_else(|| "SkillsManager payload must be an object".to_string())?; - let action = action_from_payload(payload)?; - - match action.as_str() { - "read" => { - let path = object_string(payload, "path") - .ok_or_else(|| "SkillsManager read requires path".to_string())?; - let offset = object_usize(payload, "offset"); - let length = object_usize(payload, "length"); - let result = read_skill_text_from_root(&root, path, offset, length)?; - let num_lines = result.content.match_indices('\n').count() - + usize::from(!result.content.is_empty() && !result.content.ends_with('\n')); - Ok(SystemManageSkillResponse { - action, - root_dir, - path: Some(path.to_string()), - content: Some(result.content), - truncated: Some(result.truncated), - start_line: Some(offset.unwrap_or(0) + 1), - num_lines: Some(num_lines), - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "list" => { - let (skills, invalid) = list_installed_skills(&root)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: Some(skills), - invalid: Some(invalid), - installed: None, - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "clawhub_search" => { - let (clawhub_results, clawhub_next_cursor) = - search_clawhub_skills_from_payload(payload)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: Some(clawhub_results), - clawhub_next_cursor, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "install" => { - let installed = install_source_from_payload(&root, payload)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: Some(installed), - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "clawhub_install" => { - let (installed, slug, download_url) = - install_clawhub_skill_from_payload(&root, payload)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: Some(installed), - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: Some(slug), - clawhub_download_url: Some(download_url), - }) - } - "install_start" => { - let install_job = start_install_job_from_payload(root.clone(), payload)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: Some(install_job), - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "install_status" => { - let job_id = object_string(payload, "jobId") - .or_else(|| object_string(payload, "job_id")) - .ok_or_else(|| "SkillsManager install_status requires jobId".to_string())?; - let install_job = get_install_job_snapshot(job_id)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: Some(install_job), - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "create" => { - let created = create_skill_from_payload(&root, payload)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: Some(created), - validation: None, - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "validate" => { - let name = object_string(payload, "name") - .ok_or_else(|| "SkillsManager validate requires name".to_string())?; - let validation = validate_installed_skill(&root, name)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: Some(validation), - package: None, - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "package" => { - let name = object_string(payload, "name") - .ok_or_else(|| "SkillsManager package requires name".to_string())?; - let package = package_installed_skill(&root, name)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: Some(package), - deleted: None, - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - "delete" => { - let name = object_string(payload, "name") - .ok_or_else(|| "SkillsManager delete requires name".to_string())?; - let deleted = delete_installed_skill(&root, name)?; - Ok(SystemManageSkillResponse { - action, - root_dir, - path: None, - content: None, - truncated: None, - start_line: None, - num_lines: None, - skills: None, - invalid: None, - installed: None, - created: None, - validation: None, - package: None, - deleted: Some(deleted), - seeded: None, - install_job: None, - clawhub_results: None, - clawhub_next_cursor: None, - clawhub_slug: None, - clawhub_download_url: None, - }) - } - _ => Err(format!("SkillsManager action is not supported: {action}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - fn write_skill(root: &Path, name: &str, description: &str) -> PathBuf { - let dir = root.join(name); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("SKILL.md"), - format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), - ) - .expect("write skill"); - dir - } - - #[test] - fn skill_name_rejects_windows_reserved_names() { - assert!(sanitize_skill_name("safe-skill").is_ok()); - assert!(sanitize_skill_name("con").is_err()); - assert!(sanitize_skill_name("aux").is_err()); - assert!(sanitize_skill_name("com9").is_err()); - assert!(sanitize_skill_name("com0").is_ok()); - } - - #[test] - fn skill_rel_path_rejects_windows_reserved_components() { - assert!(sanitize_skill_child_rel_path("references/notes.md").is_ok()); - assert!(sanitize_skill_child_rel_path("references/con.md").is_err()); - assert!(sanitize_skill_child_rel_path("references/LPT1.txt").is_err()); - assert!(sanitize_skill_child_rel_path("references/com0.txt").is_ok()); - } - - #[test] - fn github_tree_url_parses_ref_and_subpath() { - let source = parse_github_url( - "https://github.com/owner/repo/tree/main/skills/example", - DEFAULT_GITHUB_REF, - ) - .expect("parse github url"); - - assert_eq!(source.owner, "owner"); - assert_eq!(source.repo, "repo"); - assert_eq!(source.git_ref, "main"); - assert_eq!(source.subpath.as_deref(), Some("skills/example")); - } - - #[test] - fn discover_skill_dirs_supports_repo_skills_folder() { - let tmp = TempDir::new("liveagent-skill-discover-test").expect("temp dir"); - let skills_root = tmp.path().join("repo").join("skills"); - write_skill(&skills_root, "first-skill", "First"); - write_skill(&skills_root, "second-skill", "Second"); - - let dirs = discover_skill_dirs(&tmp.path().join("repo")); - let names = dirs - .iter() - .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) - .collect::>(); - - assert_eq!(names, vec!["first-skill", "second-skill"]); - } - - #[test] - fn discover_skill_dirs_does_not_let_root_readme_override_skills_folder() { - let tmp = TempDir::new("liveagent-readme-root-discover-test").expect("temp dir"); - let repo = tmp.path().join("repo"); - fs::create_dir_all(&repo).expect("create repo"); - fs::write(repo.join("README.md"), "# Repo README\n").expect("write repo readme"); - write_skill(&repo.join("skills"), "nested-skill", "Nested"); - - let dirs = discover_skill_dirs(&repo); - let names = dirs - .iter() - .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) - .collect::>(); - - assert_eq!(names, vec!["nested-skill"]); - } - - #[test] - fn readme_frontmatter_is_used_as_skill_metadata_fallback() { - let tmp = TempDir::new("liveagent-readme-frontmatter-test").expect("temp dir"); - let dir = tmp.path().join("readme-skill"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("README.md"), - "---\nname: readme-skill\ndescription: README metadata\n---\n\n# README Skill\n", - ) - .expect("write readme"); - - let metadata = read_skill_metadata_from_dir(&dir).expect("read metadata"); - assert_eq!(metadata.name, "readme-skill"); - assert_eq!(metadata.description, "README metadata"); - assert_eq!(metadata.metadata_file.file_name().unwrap(), "README.md"); - - let validation = validate_skill_dir(&dir); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn readme_without_frontmatter_derives_metadata_for_management() { - let tmp = TempDir::new("liveagent-plain-readme-test").expect("temp dir"); - let dir = tmp.path().join("plain-readme-skill"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("README.md"), - "# Plain README Skill\n\nFollow this README as the skill instructions.\n", - ) - .expect("write readme"); - - let raw_metadata = - read_skill_metadata_file(&dir.join("README.md")).expect("read raw metadata"); - assert!(raw_metadata.name.is_none()); - assert!(raw_metadata.description.is_none()); - - let metadata = read_skill_metadata_from_dir(&dir).expect("derive metadata"); - assert_eq!(metadata.name, "plain-readme-skill"); - assert_eq!(metadata.description, "Plain README Skill"); - - let validation = validate_skill_dir(&dir); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn readme_empty_frontmatter_derives_metadata_for_management() { - let tmp = TempDir::new("liveagent-empty-readme-frontmatter-test").expect("temp dir"); - let dir = tmp.path().join("empty-readme-metadata"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("README.md"), - "---\n---\n\n# Empty README Metadata\n\nUse the README content.\n", - ) - .expect("write readme"); - - let metadata = read_skill_metadata_from_dir(&dir).expect("derive metadata"); - assert_eq!(metadata.name, "empty-readme-metadata"); - assert_eq!(metadata.description, "Empty README Metadata"); - - let validation = validate_skill_dir(&dir); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn readme_partial_frontmatter_is_invalid_metadata() { - let tmp = TempDir::new("liveagent-partial-readme-frontmatter-test").expect("temp dir"); - let dir = tmp.path().join("partial-readme-metadata"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("README.md"), - "---\nname: partial-readme-metadata\n---\n\n# Partial README Metadata\n", - ) - .expect("write readme"); - - let error = read_skill_metadata_from_dir(&dir).expect_err("partial metadata must fail"); - assert!( - error.contains("Missing skill description"), - "unexpected error: {error}" - ); - - let validation = validate_skill_dir(&dir); - assert!(!validation.ok); - assert!( - validation - .errors - .iter() - .any(|error| error.contains("Missing 'description'")), - "{:?}", - validation.errors - ); - } - - #[test] - fn readme_inside_existing_skill_is_not_a_discovery_candidate() { - let tmp = TempDir::new("liveagent-nested-readme-discovery-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let skill_dir = write_skill(&root, "documented-skill", "Documented"); - let reference_dir = skill_dir.join("references"); - fs::create_dir_all(&reference_dir).expect("create references"); - let readme = reference_dir.join("README.md"); - fs::write(&readme, "# Reference README\n").expect("write nested readme"); - - assert!(!should_include_metadata_candidate(&root, &readme)); - assert!(should_include_metadata_candidate( - &root, - &skill_dir.join("SKILL.md") - )); - } - - #[test] - fn copy_skill_with_backup_preserves_existing_target() { - let tmp = TempDir::new("liveagent-skill-backup-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let source_a = tmp.path().join("source-a"); - let source_b = tmp.path().join("source-b"); - write_skill(&source_a, "sample-skill", "Old"); - write_skill(&source_b, "sample-skill", "New"); - - let first = copy_skill_with_conflict( - &source_a.join("sample-skill"), - &root, - "sample-skill", - "fail", - ) - .expect("first install"); - assert!(first.backup.is_none()); - - let second = copy_skill_with_conflict( - &source_b.join("sample-skill"), - &root, - "sample-skill", - "backup", - ) - .expect("second install"); - - assert!(second.backup.is_some()); - assert!(root.join(".backups").exists()); - } - - #[test] - fn builtin_seed_backs_up_invalid_target_before_writing() { - let tmp = TempDir::new("liveagent-builtin-seed-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let invalid_target = root.join("skills-installer"); - fs::create_dir_all(&invalid_target).expect("create invalid target"); - fs::write(invalid_target.join("SKILL.md"), "not valid frontmatter\n") - .expect("write invalid skill"); - - let seeded = ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); - let installer = seeded - .iter() - .find(|item| item.name == "skills-installer") - .expect("installer seed result"); - - assert_eq!(installer.action, "replaced_invalid"); - assert!(installer.backup.is_some()); - assert!(root.join(".backups").exists()); - let validation = validate_skill_dir(&root.join("skills-installer")); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn builtin_seed_updates_changed_valid_target_before_writing() { - let tmp = TempDir::new("liveagent-builtin-update-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let old_target = root.join("skills-creator"); - fs::create_dir_all(&old_target).expect("create old target"); - fs::write( - old_target.join("SKILL.md"), - "---\nname: skills-creator\ndescription: Old valid creator\n---\n\n# Old Creator\n\nUse the old workflow.\n", - ) - .expect("write old skill"); - - let seeded = ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); - let creator = seeded - .iter() - .find(|item| item.name == "skills-creator") - .expect("creator seed result"); - - assert_eq!(creator.action, "updated"); - assert!(creator.backup.is_some()); - let content = - fs::read_to_string(root.join("skills-creator").join("SKILL.md")).expect("read seeded"); - assert!( - content.contains("All generated skill documentation must be written in English only") - ); - let validation = validate_skill_dir(&root.join("skills-creator")); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn builtin_seed_removes_retired_builtin_files() { - let tmp = TempDir::new("liveagent-builtin-retired-file-test").expect("temp dir"); - let root = tmp.path().join("skills"); - - ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); - let creator_dir = root.join("skills-creator"); - let retired_script = creator_dir.join("scripts").join("old_helper.py"); - fs::create_dir_all(retired_script.parent().expect("script parent")) - .expect("create scripts"); - fs::write(&retired_script, "#!/usr/bin/env python3\nprint('old')\n") - .expect("write retired script"); - - let seeded = ensure_builtin_agent_skills_in_root(&root).expect("reseed builtins"); - let creator = seeded - .iter() - .find(|item| item.name == "skills-creator") - .expect("creator seed result"); - - assert_eq!(creator.action, "updated"); - assert!(creator.backup.is_some()); - assert!(!root.join("skills-creator").join("scripts").exists()); - } - - #[test] - fn list_installed_skills_skips_hidden_backup_dirs() { - let tmp = TempDir::new("liveagent-skill-list-test").expect("temp dir"); - let root = tmp.path().join("skills"); - write_skill(&root, "active-skill", "Active"); - write_skill(&root.join(".backups"), "backup-skill", "Backup"); - - let (skills, invalid) = list_installed_skills(&root).expect("list skills"); - - assert!(invalid.is_empty(), "{invalid:?}"); - assert_eq!( - skills - .iter() - .map(|skill| skill.name.as_str()) - .collect::>(), - vec!["active-skill"] - ); - } - - #[test] - fn install_source_from_local_skill_archive_installs_skill() { - let tmp = TempDir::new("liveagent-skill-archive-install-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let archive = tmp.path().join("archive-skill.skill"); - { - let file = fs::File::create(&archive).expect("archive file"); - let mut writer = ZipWriter::new(file); - let options = FileOptions::default().compression_method(CompressionMethod::Deflated); - writer - .start_file("archive-skill/SKILL.md", options) - .expect("start skill file"); - writer - .write_all( - b"---\nname: archive-skill\ndescription: Archive install\n---\n\n# Archive Skill\n", - ) - .expect("write skill file"); - writer.finish().expect("finish archive"); - } - let payload = json!({ - "source": archive.to_string_lossy(), - "conflict": "fail" - }); - let payload = payload.as_object().expect("payload object"); - - let installed = install_source_from_payload(&root, payload).expect("install archive"); - - assert_eq!(installed.len(), 1); - assert_eq!(installed[0].name, "archive-skill"); - assert!(root.join("archive-skill").join("SKILL.md").is_file()); - } - - #[test] - fn clawhub_download_url_preserves_slug_and_tag_params() { - let url = clawhub_download_url_for_slug("owner/example-skill", Some("v1.2.3")) - .expect("download url"); - let parsed = reqwest::Url::parse(&url).expect("parse url"); - let pairs = parsed - .query_pairs() - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect::>(); - - assert_eq!(parsed.scheme(), "https"); - assert_eq!(parsed.host_str(), Some("clawhub.ai")); - assert_eq!(parsed.path(), "/api/v1/download"); - assert_eq!( - pairs.get("slug").map(String::as_str), - Some("owner/example-skill") - ); - assert_eq!(pairs.get("tag").map(String::as_str), Some("v1.2.3")); - } - - #[test] - fn normalize_clawhub_skill_card_supports_search_shape() { - let raw = json!({ - "slug": "owner/example-skill", - "displayName": "Example Skill", - "summary": "Example summary", - "latestVersion": { "version": "1.0.0" }, - "stats": { - "downloads": 11, - "stars": 7, - "installsCurrent": 3 - }, - "updatedAt": 12345, - "owner": { "handle": "owner" } - }); - - let card = normalize_clawhub_skill_card(&raw).expect("normalize card"); - - assert_eq!(card.slug, "owner/example-skill"); - assert_eq!(card.display_name, "Example Skill"); - assert_eq!(card.latest_version.as_deref(), Some("1.0.0")); - assert_eq!(card.downloads, 11); - assert_eq!(card.stars, 7); - assert_eq!(card.installs_current, 3); - assert_eq!(card.owner_handle.as_deref(), Some("owner")); - assert!(card.download_url.contains("/api/v1/download")); - } - - #[test] - fn install_source_persists_clawhub_metadata_when_slug_is_present() { - let tmp = TempDir::new("liveagent-skill-clawhub-meta-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let source = tmp.path().join("source"); - write_skill(&source, "clawhub-skill", "ClawHub install"); - let payload = json!({ - "source": source.to_string_lossy(), - "conflict": "fail", - "slug": "owner/clawhub-skill", - "version": "1.0.0", - "publishedAt": 12345 - }); - let payload = payload.as_object().expect("payload object"); - - let installed = install_source_from_payload(&root, payload).expect("install skill"); - let source_metadata = - read_skill_source_metadata(&root.join(&installed[0].name)).expect("read source meta"); - - assert_eq!(source_metadata.registry, "clawhub"); - assert_eq!(source_metadata.slug, "owner/clawhub-skill"); - assert_eq!(source_metadata.version.as_deref(), Some("1.0.0")); - assert_eq!(source_metadata.published_at, Some(12345)); - } - - #[test] - fn validate_and_package_round_trip() { - let tmp = TempDir::new("liveagent-skill-package-test").expect("temp dir"); - let root = tmp.path().join("skills"); - write_skill(&root, "package-skill", "Package test"); - - let validation = - validate_installed_skill(&root, "package-skill").expect("validate installed skill"); - assert!(validation.ok, "{:?}", validation.errors); - - let package = package_installed_skill(&root, "package-skill").expect("package skill"); - assert!(Path::new(&package.archive).exists()); - } - - #[test] - fn delete_installed_skill_removes_user_skill() { - let tmp = TempDir::new("liveagent-skill-delete-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let skill_dir = write_skill(&root, "delete-skill", "Delete test"); - - let deleted = delete_installed_skill(&root, "delete-skill").expect("delete skill"); - - assert_eq!(deleted.name, "delete-skill"); - assert_eq!(deleted.target, display_path(&skill_dir)); - assert!(!skill_dir.exists()); - } - - #[test] - fn delete_installed_skill_rejects_builtin_skill() { - let tmp = TempDir::new("liveagent-skill-delete-builtin-test").expect("temp dir"); - let root = tmp.path().join("skills"); - write_skill(&root, "skills-installer", "Built-in replacement"); - - let error = - delete_installed_skill(&root, "skills-installer").expect_err("delete should fail"); - - assert!( - error.contains("cannot modify built-in Skill"), - "unexpected error: {error}" - ); - assert!(root.join("skills-installer").exists()); - } - - #[test] - fn delete_installed_skill_rejects_missing_skill() { - let tmp = TempDir::new("liveagent-skill-delete-missing-test").expect("temp dir"); - let root = tmp.path().join("skills"); - - let error = delete_installed_skill(&root, "missing-skill").expect_err("delete should fail"); - - assert!( - error.contains("does not exist") || error.contains("cannot be inspected"), - "unexpected error: {error}" - ); - } - - #[test] - fn delete_installed_skill_rejects_non_directory_target() { - let tmp = TempDir::new("liveagent-skill-delete-file-test").expect("temp dir"); - let root = tmp.path().join("skills"); - fs::create_dir_all(&root).expect("create skills root"); - let file = root.join("file-skill"); - fs::write(&file, "not a directory").expect("write file target"); - - let error = delete_installed_skill(&root, "file-skill").expect_err("delete should fail"); - - assert!( - error.contains("requires an installed Skill directory"), - "unexpected error: {error}" - ); - assert!(file.exists()); - } - - #[test] - fn validate_allows_nested_metadata_frontmatter() { - let tmp = TempDir::new("liveagent-skill-frontmatter-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let dir = root.join("metadata-skill"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("SKILL.md"), - "---\nname: metadata-skill\ndescription: Metadata test\nmetadata:\n short-description: Nested metadata\n---\n\n# Metadata Skill\n", - ) - .expect("write skill"); - - let validation = validate_installed_skill(&root, "metadata-skill").expect("validate skill"); - - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn validate_allows_single_line_frontmatter() { - let tmp = TempDir::new("liveagent-skill-inline-frontmatter-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let dir = root.join("security-threat-model"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("SKILL.md"), - "--- name: security-threat-model description: Develop threat models and security analysis for software systems --- Use this skill when reviewing security risks.\n", - ) - .expect("write skill"); - - let metadata = read_skill_metadata_from_dir(&dir).expect("read metadata"); - assert_eq!(metadata.name, "security-threat-model"); - assert_eq!( - metadata.description, - "Develop threat models and security analysis for software systems" - ); - - let validation = - validate_installed_skill(&root, "security-threat-model").expect("validate skill"); - assert!(validation.ok, "{:?}", validation.errors); - } - - #[test] - fn validate_rejects_non_english_markdown_documentation() { - let tmp = TempDir::new("liveagent-skill-english-doc-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let dir = root.join("english-only-skill"); - fs::create_dir_all(&dir).expect("create skill dir"); - fs::write( - dir.join("SKILL.md"), - "---\nname: english-only-skill\ndescription: English documentation test\n---\n\n# English Only Skill\n\nTranslate \u{4E2D} before saving the skill.\n", - ) - .expect("write skill"); - - let validation = - validate_installed_skill(&root, "english-only-skill").expect("validate skill"); - - assert!(!validation.ok); - assert!( - validation - .errors - .iter() - .any(|error| error.contains("English only")), - "{:?}", - validation.errors - ); - } - - #[test] - fn create_skill_rejects_non_english_body() { - let tmp = TempDir::new("liveagent-skill-create-english-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let payload = json!({ - "name": "english-create-skill", - "description": "Create only English documentation", - "body": "# English Create Skill\n\nTranslate \u{4E2D} before writing docs.", - "conflict": "fail" - }); - let payload = payload.as_object().expect("payload object"); - - let error = create_skill_from_payload(&root, payload).expect_err("create should fail"); - - assert!(error.contains("English only"), "unexpected error: {error}"); - } - - #[test] - fn create_skill_rejects_builtin_skill_names() { - let tmp = TempDir::new("liveagent-skill-create-builtin-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let payload = json!({ - "name": "skills-creator", - "description": "Attempt to replace built-in creator", - "body": "# Replacement\n\nDo not allow this.", - "conflict": "overwrite" - }); - let payload = payload.as_object().expect("payload object"); - - let error = create_skill_from_payload(&root, payload).expect_err("create should fail"); - - assert!( - error.contains("cannot modify built-in Skill"), - "unexpected error: {error}" - ); - } - - #[test] - fn install_source_rejects_builtin_skill_names() { - let tmp = TempDir::new("liveagent-skill-install-builtin-test").expect("temp dir"); - let root = tmp.path().join("skills"); - let source = tmp.path().join("source"); - write_skill(&source, "skills-installer", "Replacement"); - let payload = json!({ - "source": source.to_string_lossy(), - "conflict": "overwrite" - }); - let payload = payload.as_object().expect("payload object"); - - let error = install_source_from_payload(&root, payload).expect_err("install should fail"); - - assert!( - error.contains("cannot modify built-in Skill"), - "unexpected error: {error}" - ); - } - - #[test] - fn safe_extract_zip_rejects_parent_traversal() { - let tmp = TempDir::new("liveagent-skill-zip-test").expect("temp dir"); - let archive = tmp.path().join("bad.skill"); - { - let file = fs::File::create(&archive).expect("archive file"); - let mut writer = ZipWriter::new(file); - let options = FileOptions::default().compression_method(CompressionMethod::Deflated); - writer - .start_file("../evil.txt", options) - .expect("start unsafe file"); - writer.write_all(b"bad").expect("write unsafe file"); - writer.finish().expect("finish archive"); - } - - let error = safe_extract_zip(&archive, &tmp.path().join("out")) - .expect_err("zip slip should be rejected"); - assert!(error.contains("escapes") || error.contains("unsafe")); - } -} diff --git a/crates/agent-gui/src-tauri/src/services/skills/builtin.rs b/crates/agent-gui/src-tauri/src/services/skills/builtin.rs new file mode 100644 index 00000000..5920d2dc --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/builtin.rs @@ -0,0 +1,211 @@ +//! 内置 Agent Skill:内嵌文件定义、修改保护与启动时种子写入。 + +use std::fs; +use std::io; +use std::path::Path; +use walkdir::WalkDir; + +use super::*; + +pub(crate) struct BuiltinSkillFile { + pub(crate) path: &'static str, + pub(crate) content: &'static str, +} + +pub(crate) struct BuiltinSkill { + pub(crate) name: &'static str, + pub(crate) files: &'static [BuiltinSkillFile], +} + +const SKILLS_INSTALLER_FILES: &[BuiltinSkillFile] = &[ + BuiltinSkillFile { + path: "SKILL.md", + content: include_str!("../../../prompt/skills/skills-installer/SKILL.md"), + }, + BuiltinSkillFile { + path: "references/install-sources.md", + content: include_str!( + "../../../prompt/skills/skills-installer/references/install-sources.md" + ), + }, + BuiltinSkillFile { + path: "references/safety-and-conflicts.md", + content: include_str!( + "../../../prompt/skills/skills-installer/references/safety-and-conflicts.md" + ), + }, +]; + +const SKILLS_CREATOR_FILES: &[BuiltinSkillFile] = &[ + BuiltinSkillFile { + path: "SKILL.md", + content: include_str!("../../../prompt/skills/skills-creator/SKILL.md"), + }, + BuiltinSkillFile { + path: "references/agent-skill-format.md", + content: include_str!( + "../../../prompt/skills/skills-creator/references/agent-skill-format.md" + ), + }, + BuiltinSkillFile { + path: "references/authoring-patterns.md", + content: include_str!( + "../../../prompt/skills/skills-creator/references/authoring-patterns.md" + ), + }, +]; + +pub(crate) const BUILTIN_AGENT_SKILLS: &[BuiltinSkill] = &[ + BuiltinSkill { + name: "skills-installer", + files: SKILLS_INSTALLER_FILES, + }, + BuiltinSkill { + name: "skills-creator", + files: SKILLS_CREATOR_FILES, + }, +]; + +pub(crate) fn is_builtin_agent_skill_name(name: &str) -> bool { + BUILTIN_AGENT_SKILLS + .iter() + .any(|skill| skill.name.eq_ignore_ascii_case(name)) +} + +pub(crate) fn ensure_not_builtin_skill_management_target( + name: &str, + action: &str, +) -> Result<(), String> { + if is_builtin_agent_skill_name(name) { + return Err(format!( + "SkillsManager action={action} cannot modify built-in Skill \"{name}\". Built-in Skills are managed by LiveAgent; create or update a separate user Skill instead." + )); + } + Ok(()) +} + +pub fn ensure_builtin_agent_skills_sync() -> Result, String> { + let root = skills_root_dir()?; + ensure_builtin_agent_skills_in_root(&root) +} + +pub(crate) fn builtin_skill_files_match( + target: &Path, + builtin: &BuiltinSkill, +) -> Result { + let mut actual_files = Vec::new(); + for entry in WalkDir::new(target).follow_links(false).min_depth(1) { + let entry = entry.map_err(|e| format!("Failed to inspect built-in Skill: {e}"))?; + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(target) + .map_err(|e| format!("Failed to compute built-in Skill path: {e}"))? + .to_string_lossy() + .replace('\\', "/"); + actual_files.push(rel); + } + actual_files.sort(); + + let mut expected_files = builtin + .files + .iter() + .map(|file| { + sanitize_skill_child_rel_path(file.path) + .map(|path| path.to_string_lossy().replace('\\', "/")) + }) + .collect::, _>>()?; + expected_files.sort(); + if actual_files != expected_files { + return Ok(false); + } + + for file in builtin.files { + let rel = sanitize_skill_child_rel_path(file.path)?; + let path = target.join(rel); + match fs::read_to_string(&path) { + Ok(content) if content == file.content => {} + Ok(_) => return Ok(false), + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "Failed to read built-in Skill file {}: {error}", + path.display() + )); + } + } + } + Ok(true) +} + +pub(crate) fn ensure_builtin_agent_skills_in_root( + root: &Path, +) -> Result, String> { + fs::create_dir_all(root).map_err(|e| format!("Failed to create Skills root directory: {e}"))?; + let mut results = Vec::new(); + for builtin in BUILTIN_AGENT_SKILLS { + let name = sanitize_skill_name(builtin.name)?; + let target = root.join(&name); + let mut backup = None; + let mut write_action = "created"; + + if target.exists() { + let validation = validate_skill_dir(&target); + let valid_same_name = validation.ok + && validation + .metadata + .as_ref() + .map(|metadata| metadata.name == name) + .unwrap_or(false); + if valid_same_name { + if builtin_skill_files_match(&target, builtin)? { + results.push(SystemBuiltinSkillSeedResponse { + name, + target: display_path(&target), + action: "kept".to_string(), + backup: None, + }); + continue; + } + write_action = "updated"; + } else { + write_action = "replaced_invalid"; + } + backup = Some(backup_existing_path(&root, &target, &name)?); + } + + fs::create_dir_all(&target) + .map_err(|e| format!("Failed to create built-in Skill directory: {e}"))?; + for file in builtin.files { + let rel = sanitize_skill_child_rel_path(file.path)?; + let path = target.join(rel); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create built-in Skill parent: {e}"))?; + } + fs::write(&path, file.content).map_err(|e| { + format!( + "Failed to write built-in Skill file {}: {e}", + path.display() + ) + })?; + } + let validation = validate_skill_dir(&target); + if !validation.ok { + return Err(format!( + "Built-in Skill '{}' did not validate after seeding:\n{}", + builtin.name, + validation.errors.join("\n") + )); + } + results.push(SystemBuiltinSkillSeedResponse { + name, + target: display_path(&target), + action: write_action.to_string(), + backup: backup.map(|path| display_path(&path)), + }); + } + Ok(results) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs b/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs new file mode 100644 index 00000000..c944426c --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/clawhub.rs @@ -0,0 +1,223 @@ +//! ClawHub 注册表集成:搜索、卡片归一化、下载 URL 与安装。 + +use reqwest::blocking::Client as HttpClient; +use serde_json::Value; +use std::path::Path; +use std::time::Duration; + +use super::*; + +const CLAWHUB_API_BASE: &str = "https://clawhub.ai"; +const DEFAULT_CLAWHUB_SEARCH_LIMIT: usize = 10; +const MAX_CLAWHUB_SEARCH_LIMIT: usize = 20; + +pub(crate) fn normalize_clawhub_limit(value: Option) -> usize { + value + .unwrap_or(DEFAULT_CLAWHUB_SEARCH_LIMIT) + .clamp(1, MAX_CLAWHUB_SEARCH_LIMIT) +} + +pub(crate) fn normalize_clawhub_sort(value: Option<&str>) -> Result<&'static str, String> { + match value.unwrap_or("downloads") { + "downloads" => Ok("downloads"), + "stars" => Ok("stars"), + "installs" => Ok("installs"), + "updated" => Ok("updated"), + "newest" => Ok("newest"), + other => Err(format!("Unsupported ClawHub sort: {other}")), + } +} + +pub(crate) fn json_object(value: &Value) -> Option<&serde_json::Map> { + value.as_object() +} + +pub(crate) fn json_string(item: &serde_json::Map, key: &str) -> Option { + item.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub(crate) fn json_u64(item: &serde_json::Map, key: &str) -> u64 { + match item.get(key) { + Some(Value::Number(number)) => number + .as_u64() + .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())) + .or_else(|| { + number.as_f64().and_then(|value| { + if value.is_finite() && value >= 0.0 { + Some(value as u64) + } else { + None + } + }) + }) + .unwrap_or(0), + _ => 0, + } +} + +pub(crate) fn json_optional_u64(item: &serde_json::Map, key: &str) -> Option { + item.get(key).and_then(|value| match value { + Value::Number(number) => number + .as_u64() + .or_else(|| number.as_i64().and_then(|value| u64::try_from(value).ok())), + _ => None, + }) +} + +pub(crate) fn clawhub_download_url_for_slug(slug: &str, tag: Option<&str>) -> Result { + let slug = slug.trim(); + if slug.is_empty() { + return Err("SkillsManager clawhub_install requires slug".to_string()); + } + let tag = tag + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("latest"); + let mut url = reqwest::Url::parse(CLAWHUB_API_BASE) + .and_then(|base| base.join("/api/v1/download")) + .map_err(|e| format!("Failed to build ClawHub download URL: {e}"))?; + url.query_pairs_mut() + .append_pair("slug", slug) + .append_pair("tag", tag); + Ok(url.into()) +} + +pub(crate) fn normalize_clawhub_skill_card(raw: &Value) -> Option { + let item = json_object(raw)?; + let slug = json_string(item, "slug")?; + let stats = item.get("stats").and_then(json_object); + let latest_version = item + .get("latestVersion") + .and_then(json_object) + .and_then(|value| json_string(value, "version")) + .or_else(|| { + item.get("tags") + .and_then(json_object) + .and_then(|value| json_string(value, "latest")) + }) + .or_else(|| json_string(item, "version")); + let owner_handle = json_string(item, "ownerHandle").or_else(|| { + item.get("owner") + .and_then(json_object) + .and_then(|value| json_string(value, "handle")) + }); + let download_url = clawhub_download_url_for_slug(&slug, None).ok()?; + let web_url = owner_handle + .as_ref() + .map(|owner| format!("{CLAWHUB_API_BASE}/{owner}/{slug}")); + + Some(SystemClawHubSkillCard { + slug: slug.clone(), + display_name: json_string(item, "displayName").unwrap_or(slug), + summary: json_string(item, "summary").unwrap_or_default(), + latest_version, + downloads: stats.map(|value| json_u64(value, "downloads")).unwrap_or(0), + stars: stats.map(|value| json_u64(value, "stars")).unwrap_or(0), + installs_current: stats + .map(|value| json_u64(value, "installsCurrent")) + .unwrap_or(0), + updated_at: json_optional_u64(item, "updatedAt"), + owner_handle, + web_url, + download_url, + }) +} + +pub(crate) fn fetch_clawhub_json(path: &str, params: &[(&str, String)]) -> Result { + let mut url = reqwest::Url::parse(CLAWHUB_API_BASE) + .and_then(|base| base.join(path)) + .map_err(|e| format!("Failed to build ClawHub request URL: {e}"))?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in params { + pairs.append_pair(key, value); + } + } + + let client = HttpClient::builder() + .timeout(Duration::from_secs(30)) + .user_agent("liveagent-skillsmanager") + .build() + .map_err(|e| format!("Failed to create ClawHub HTTP client: {e}"))?; + let response = client + .get(url) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .map_err(|e| format!("Failed to request ClawHub Skills: {e}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("ClawHub request failed with HTTP {status}")); + } + response + .json::() + .map_err(|e| format!("Failed to parse ClawHub response: {e}")) +} + +pub(crate) fn clawhub_results_from_field(json: &Value, key: &str) -> Vec { + json.get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(normalize_clawhub_skill_card) + .collect::>() + }) + .unwrap_or_default() +} + +pub(crate) fn search_clawhub_skills_from_payload( + payload: &serde_json::Map, +) -> Result<(Vec, Option), String> { + let limit = normalize_clawhub_limit(object_usize(payload, "limit")); + if let Some(query) = object_string(payload, "query") { + let json = fetch_clawhub_json( + "/api/v1/search", + &[ + ("q", query.to_string()), + ("limit", limit.to_string()), + ("nonSuspiciousOnly", "true".to_string()), + ], + )?; + return Ok((clawhub_results_from_field(&json, "results"), None)); + } + + let sort = normalize_clawhub_sort(object_string(payload, "sort"))?; + let mut params = vec![ + ("limit", limit.to_string()), + ("sort", sort.to_string()), + ("nonSuspiciousOnly", "true".to_string()), + ]; + if let Some(cursor) = object_string(payload, "cursor") { + params.push(("cursor", cursor.to_string())); + } + let json = fetch_clawhub_json("/api/v1/skills", ¶ms)?; + let next_cursor = json + .get("nextCursor") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + Ok((clawhub_results_from_field(&json, "items"), next_cursor)) +} + +pub(crate) fn install_clawhub_skill_from_payload( + root: &Path, + payload: &serde_json::Map, +) -> Result<(Vec, String, String), String> { + let slug = object_string(payload, "slug") + .ok_or_else(|| "SkillsManager clawhub_install requires slug".to_string())? + .to_string(); + let version = object_string(payload, "version"); + let download_url = clawhub_download_url_for_slug(&slug, version)?; + let mut install_payload = payload.clone(); + install_payload.insert("action".to_string(), Value::String("install".to_string())); + install_payload.insert("source".to_string(), Value::String(download_url.clone())); + install_payload.insert("slug".to_string(), Value::String(slug.clone())); + + let installed = install_source_from_payload(root, &install_payload)?; + Ok((installed, slug, download_url)) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/create.rs b/crates/agent-gui/src-tauri/src/services/skills/create.rs new file mode 100644 index 00000000..2ff34007 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/create.rs @@ -0,0 +1,110 @@ +//! 创建 Skill:SKILL.md 模板渲染与 create payload 编排。 + +use serde_json::Value; +use std::fs; +use std::path::Path; + +use super::*; + +pub(crate) fn yaml_quote(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n"); + format!("\"{escaped}\"") +} + +pub(crate) fn render_skill_template(name: &str, description: &str, body: Option<&str>) -> String { + let body = body.map(str::trim).filter(|value| !value.is_empty()); + let rendered_body = body.map_or_else( + || { + format!( + "# {}\n\n## Language Policy\n\n- Write this skill document and every Markdown reference in English only.\n- Translate non-English source notes into English before adding them here.\n- Preserve code identifiers, filenames, commands, URLs, and literal values exactly when needed.\n\n## Workflow\n\n1. Inspect the user's request and gather the required context.\n2. Follow the workflow this skill is meant to capture.\n3. Validate the result and report changed files or outputs.\n", + title_case_skill_name(name) + ) + }, + |value| value.to_string(), + ); + format!( + "---\nname: {name}\ndescription: {}\n---\n\n{}\n", + yaml_quote(description), + rendered_body + ) +} + +pub(crate) fn create_skill_from_payload( + root: &Path, + payload: &serde_json::Map, +) -> Result { + let raw_name = object_string(payload, "name") + .ok_or_else(|| "SkillsManager create requires name".to_string())?; + let normalized = normalize_skill_name(raw_name); + let name = sanitize_skill_name(&normalized)?; + ensure_not_builtin_skill_management_target(&name, "create")?; + let description = object_string(payload, "description") + .ok_or_else(|| "SkillsManager create requires description".to_string())? + .trim() + .to_string(); + if description.len() > MAX_SKILL_DESCRIPTION_LENGTH { + return Err(format!( + "Skill description is too long; maximum is {MAX_SKILL_DESCRIPTION_LENGTH}" + )); + } + let body = object_string(payload, "body"); + let conflict = normalize_conflict(object_string(payload, "conflict"), "fail")?; + + let tmp = TempDir::new("liveagent-skill-create")?; + let source_dir = tmp.path().join(&name); + fs::create_dir_all(&source_dir) + .map_err(|e| format!("Failed to create staged Skill directory: {e}"))?; + fs::write( + source_dir.join("SKILL.md"), + render_skill_template(&name, &description, body), + ) + .map_err(|e| format!("Failed to write staged SKILL.md: {e}"))?; + + if let Some(files) = payload.get("files") { + let files = files + .as_array() + .ok_or_else(|| "SkillsManager create files must be an array".to_string())?; + for file in files { + let file = file + .as_object() + .ok_or_else(|| "SkillsManager create file entries must be objects".to_string())?; + let rel = object_string(file, "path") + .ok_or_else(|| "SkillsManager create file.path is required".to_string())?; + let rel_path = sanitize_skill_child_rel_path(rel)?; + if is_skill_metadata_candidate(&rel_path) { + return Err("Use name/description/body to create SKILL.md; files must not replace Skill metadata".to_string()); + } + let content = file + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "SkillsManager create file.content is required".to_string())?; + if content.len() as u64 > MAX_SKILL_FILE_BYTES { + return Err(format!("Skill file is too large: {rel}")); + } + let target = source_dir.join(rel_path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create staged Skill file parent: {e}"))?; + } + fs::write(&target, content).map_err(|e| { + format!( + "Failed to write staged Skill file {}: {e}", + target.display() + ) + })?; + } + } + + let validation = validate_skill_dir(&source_dir); + if !validation.ok { + return Err(format!( + "Created Skill did not validate:\n{}", + validation.errors.join("\n") + )); + } + + copy_skill_with_conflict(&source_dir, root, &name, &conflict) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/install.rs b/crates/agent-gui/src-tauri/src/services/skills/install.rs new file mode 100644 index 00000000..dabe2762 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/install.rs @@ -0,0 +1,241 @@ +//! 安装编排:备份、带冲突策略的复制与 install payload 处理。 + +use chrono::Utc; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; + +use super::*; + +pub(crate) fn backup_existing_path( + dest_root: &Path, + target: &Path, + skill_name: &str, +) -> Result { + let backups_root = dest_root.join(".backups"); + fs::create_dir_all(&backups_root) + .map_err(|e| format!("Failed to create Skills backup directory: {e}"))?; + let stamp = Utc::now().format("%Y%m%d-%H%M%S").to_string(); + let mut backup = backups_root.join(format!("{skill_name}-{stamp}")); + let mut counter = 1usize; + while backup.exists() { + backup = backups_root.join(format!("{skill_name}-{stamp}-{counter}")); + counter += 1; + } + fs::rename(target, &backup).map_err(|e| { + format!( + "Failed to move existing Skill to backup {}: {e}", + backup.display() + ) + })?; + Ok(backup) +} + +pub(crate) fn copy_dir_safely(source_dir: &Path, target: &Path) -> Result<(), String> { + for entry in WalkDir::new(source_dir).follow_links(false).min_depth(1) { + let entry = entry.map_err(|e| format!("Failed to inspect source Skill: {e}"))?; + let source_path = entry.path(); + let rel = source_path + .strip_prefix(source_dir) + .map_err(|e| format!("Failed to compute relative Skill path: {e}"))?; + let target_path = target.join(rel); + let file_type = entry.file_type(); + if file_type.is_symlink() { + return Err(format!( + "Skill source contains a symlink, which is not supported: {}", + source_path.display() + )); + } + if file_type.is_dir() { + fs::create_dir_all(&target_path) + .map_err(|e| format!("Failed to create Skill directory: {e}"))?; + continue; + } + if file_type.is_file() { + let size = entry + .metadata() + .map_err(|e| format!("Failed to read source Skill file metadata: {e}"))? + .len(); + if size > MAX_SKILL_FILE_BYTES { + return Err(format!( + "Skill file is too large: {} ({} bytes)", + source_path.display(), + size + )); + } + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create Skill parent directory: {e}"))?; + } + fs::copy(source_path, &target_path).map_err(|e| { + format!( + "Failed to copy Skill file {} to {}: {e}", + source_path.display(), + target_path.display() + ) + })?; + } + } + Ok(()) +} + +pub(crate) fn copy_skill_with_conflict( + source_dir: &Path, + dest_root: &Path, + skill_name: &str, + conflict: &str, +) -> Result { + let skill_name = sanitize_skill_name(skill_name)?; + fs::create_dir_all(dest_root) + .map_err(|e| format!("Failed to create Skills root directory: {e}"))?; + let target = dest_root.join(&skill_name); + let mut backup = None; + + if target.exists() { + if source_dir.canonicalize().ok() == target.canonicalize().ok() { + let metadata = read_skill_metadata_from_dir(&target)?; + return Ok(SystemSkillInstallResult { + name: skill_name, + target: display_path(&target), + backup: None, + skill_file: rel_to_root_str(dest_root, &metadata.metadata_file), + }); + } + + match conflict { + "fail" => return Err(format!("Destination already exists: {}", target.display())), + "overwrite" => { + let meta = fs::symlink_metadata(&target) + .map_err(|e| format!("Failed to inspect destination: {e}"))?; + if meta.is_dir() { + fs::remove_dir_all(&target).map_err(|e| { + format!("Failed to remove existing Skill {}: {e}", target.display()) + })?; + } else { + fs::remove_file(&target).map_err(|e| { + format!("Failed to remove existing Skill {}: {e}", target.display()) + })?; + } + } + "backup" => { + backup = Some(backup_existing_path(dest_root, &target, &skill_name)?); + } + other => return Err(format!("Unsupported conflict mode: {other}")), + } + } + + fs::create_dir_all(&target) + .map_err(|e| format!("Failed to create target Skill directory: {e}"))?; + copy_dir_safely(source_dir, &target)?; + let metadata = read_skill_metadata_from_dir(&target)?; + if metadata.name != skill_name { + return Err(format!( + "Installed Skill metadata name '{}' does not match target directory '{}'", + metadata.name, skill_name + )); + } + + Ok(SystemSkillInstallResult { + name: skill_name, + target: display_path(&target), + backup: backup.map(|path| display_path(&path)), + skill_file: rel_to_root_str(dest_root, &metadata.metadata_file), + }) +} + +pub(crate) fn normalize_conflict(value: Option<&str>, default_value: &str) -> Result { + let raw = value.unwrap_or(default_value).trim(); + match raw { + "backup" | "fail" | "overwrite" => Ok(raw.to_string()), + _ => Err(format!("Unsupported conflict mode: {raw}")), + } +} + +pub(crate) fn normalize_method(value: Option<&str>) -> Result { + let raw = value.unwrap_or("auto").trim(); + match raw { + "auto" | "download" | "git" => Ok(raw.to_string()), + _ => Err(format!("Unsupported GitHub method: {raw}")), + } +} + +pub(crate) fn install_source_from_payload( + root: &Path, + payload: &serde_json::Map, +) -> Result, String> { + install_source_from_payload_with_progress(root, payload, |_| {}) +} + +pub(crate) fn install_source_from_payload_with_progress( + root: &Path, + payload: &serde_json::Map, + mut on_progress: F, +) -> Result, String> +where + F: FnMut(SkillInstallProgressUpdate), +{ + let source = object_string(payload, "source") + .ok_or_else(|| "SkillsManager install requires source".to_string())?; + let conflict = normalize_conflict(object_string(payload, "conflict"), "backup")?; + let method = normalize_method(object_string(payload, "method"))?; + let git_ref = object_string(payload, "ref").unwrap_or(DEFAULT_GITHUB_REF); + let name_override = object_string(payload, "name") + .map(sanitize_skill_name) + .transpose()?; + + let tmp = TempDir::new("liveagent-skill-install")?; + let stage_root = if is_github_source(source) { + on_progress(SkillInstallProgressUpdate { + phase: "downloading", + downloaded_bytes: None, + total_bytes: None, + message: Some("Preparing GitHub Skill source".to_string()), + }); + prepare_github_source(source, &method, git_ref, tmp.path())? + } else if is_http_source(source) { + prepare_http_source_with_progress(source, tmp.path(), |update| on_progress(update))? + } else { + on_progress(SkillInstallProgressUpdate { + phase: "validating", + downloaded_bytes: None, + total_bytes: None, + message: Some("Preparing local Skill source".to_string()), + }); + prepare_local_or_archive_source(source, tmp.path())? + }; + + on_progress(SkillInstallProgressUpdate { + phase: "validating", + downloaded_bytes: None, + total_bytes: None, + message: Some("Validating Skill metadata".to_string()), + }); + let candidates = discover_skill_dirs(&stage_root); + if candidates.is_empty() { + return Err( + "No skill directories found. Expected SKILL.md, skill.md, skill.json, or README.md." + .to_string(), + ); + } + if name_override.is_some() && candidates.len() != 1 { + return Err("name can only be used when exactly one skill is installed".to_string()); + } + + let mut results = Vec::new(); + for candidate in candidates { + let metadata = read_skill_metadata_from_dir(&candidate)?; + let skill_name = name_override.as_deref().unwrap_or(&metadata.name); + ensure_not_builtin_skill_management_target(skill_name, "install")?; + on_progress(SkillInstallProgressUpdate { + phase: "installing", + downloaded_bytes: None, + total_bytes: None, + message: Some(format!("Installing Skill {skill_name}")), + }); + let result = copy_skill_with_conflict(&candidate, root, skill_name, &conflict)?; + results.push(result); + } + write_skill_source_metadata_for_install(root, payload, &results)?; + Ok(results) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/jobs.rs b/crates/agent-gui/src-tauri/src/services/skills/jobs.rs new file mode 100644 index 00000000..bed9faa1 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/jobs.rs @@ -0,0 +1,189 @@ +//! 后台安装任务:任务注册表、进度快照与 install_start 工作线程。 + +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use uuid::Uuid; + +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct SkillInstallJobState { + pub(crate) job_id: String, + pub(crate) phase: String, + pub(crate) source: String, + pub(crate) label: Option, + pub(crate) slug: Option, + pub(crate) version: Option, + pub(crate) downloaded_bytes: u64, + pub(crate) total_bytes: Option, + pub(crate) message: Option, + pub(crate) error: Option, + pub(crate) installed: Option>, + pub(crate) started_at: u64, + pub(crate) updated_at: u64, + pub(crate) finished_at: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SkillInstallProgressUpdate { + pub(crate) phase: &'static str, + pub(crate) downloaded_bytes: Option, + pub(crate) total_bytes: Option, + pub(crate) message: Option, +} + +static SKILL_INSTALL_JOBS: OnceLock>> = OnceLock::new(); + +pub(crate) fn skill_install_jobs() -> &'static Mutex> { + SKILL_INSTALL_JOBS.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub(crate) fn install_job_snapshot(job: &SkillInstallJobState) -> SystemSkillInstallJobSnapshot { + SystemSkillInstallJobSnapshot { + job_id: job.job_id.clone(), + phase: job.phase.clone(), + source: job.source.clone(), + label: job.label.clone(), + slug: job.slug.clone(), + version: job.version.clone(), + downloaded_bytes: job.downloaded_bytes, + total_bytes: job.total_bytes, + message: job.message.clone(), + error: job.error.clone(), + installed: job.installed.clone(), + started_at: job.started_at, + updated_at: job.updated_at, + finished_at: job.finished_at, + } +} + +pub(crate) fn prune_old_install_jobs(jobs: &mut HashMap, now: u64) { + const RETENTION_MS: u64 = 60 * 60 * 1000; + jobs.retain(|_, job| { + job.finished_at + .map(|finished_at| now.saturating_sub(finished_at) <= RETENTION_MS) + .unwrap_or(true) + }); +} + +pub(crate) fn insert_install_job( + job: SkillInstallJobState, +) -> Result { + let snapshot = install_job_snapshot(&job); + let mut jobs = skill_install_jobs() + .lock() + .map_err(|_| "Failed to lock Skill install jobs".to_string())?; + prune_old_install_jobs(&mut jobs, now_millis()); + jobs.insert(job.job_id.clone(), job); + Ok(snapshot) +} + +pub(crate) fn update_install_job( + job_id: &str, + updater: F, +) -> Result +where + F: FnOnce(&mut SkillInstallJobState), +{ + let mut jobs = skill_install_jobs() + .lock() + .map_err(|_| "Failed to lock Skill install jobs".to_string())?; + let job = jobs + .get_mut(job_id) + .ok_or_else(|| format!("Skill install job not found: {job_id}"))?; + updater(job); + job.updated_at = now_millis(); + Ok(install_job_snapshot(job)) +} + +pub(crate) fn get_install_job_snapshot( + job_id: &str, +) -> Result { + let mut jobs = skill_install_jobs() + .lock() + .map_err(|_| "Failed to lock Skill install jobs".to_string())?; + prune_old_install_jobs(&mut jobs, now_millis()); + let job = jobs + .get(job_id) + .ok_or_else(|| format!("Skill install job not found: {job_id}"))?; + Ok(install_job_snapshot(job)) +} + +pub(crate) fn start_install_job_from_payload( + root: PathBuf, + payload: &serde_json::Map, +) -> Result { + let source = object_string(payload, "source") + .ok_or_else(|| "SkillsManager install_start requires source".to_string())? + .to_string(); + let label = object_string(payload, "label").map(ToOwned::to_owned); + let slug = object_string(payload, "slug").map(ToOwned::to_owned); + let version = object_string(payload, "version").map(ToOwned::to_owned); + normalize_conflict(object_string(payload, "conflict"), "backup")?; + normalize_method(object_string(payload, "method"))?; + + let job_id = Uuid::new_v4().to_string(); + let now = now_millis(); + let snapshot = insert_install_job(SkillInstallJobState { + job_id: job_id.clone(), + phase: "queued".to_string(), + source, + label, + slug, + version, + downloaded_bytes: 0, + total_bytes: None, + message: Some("Queued Skill install".to_string()), + error: None, + installed: None, + started_at: now, + updated_at: now, + finished_at: None, + })?; + + let thread_job_id = job_id.clone(); + let payload = payload.clone(); + thread::spawn(move || { + let progress_job_id = thread_job_id.clone(); + let result = install_source_from_payload_with_progress(&root, &payload, move |update| { + let _ = update_install_job(&progress_job_id, |job| { + job.phase = update.phase.to_string(); + if update.phase == "downloading" { + job.total_bytes = update.total_bytes; + } + if let Some(downloaded_bytes) = update.downloaded_bytes { + job.downloaded_bytes = downloaded_bytes; + } + if let Some(message) = update.message { + job.message = Some(message); + } + job.error = None; + }); + }); + + match result { + Ok(installed) => { + let _ = update_install_job(&thread_job_id, |job| { + job.phase = "done".to_string(); + job.message = Some("Skill installed".to_string()); + job.error = None; + job.installed = Some(installed); + job.finished_at = Some(now_millis()); + }); + } + Err(error) => { + let _ = update_install_job(&thread_job_id, |job| { + job.phase = "error".to_string(); + job.message = Some("Skill install failed".to_string()); + job.error = Some(error); + job.finished_at = Some(now_millis()); + }); + } + } + }); + + Ok(snapshot) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/library.rs b/crates/agent-gui/src-tauri/src/services/skills/library.rs new file mode 100644 index 00000000..885eb982 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/library.rs @@ -0,0 +1,437 @@ +//! 已安装 Skill 库:目录发现、列表、文本读取、删除、打包与 `_meta.json` 源信息。 + +use serde_json::Value; +use std::fs; +use std::io::{self, BufRead, BufReader}; +use std::path::{Component, Path, PathBuf}; +use walkdir::WalkDir; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; + +use super::*; + +const SKILL_READ_MAX_BYTES: usize = 200 * 1024; // 200KB +const DEFAULT_SKILL_READ_LENGTH_LINES: usize = 200; +const DEFAULT_SKILL_GLOB_MAX_RESULTS: usize = 2000; + +pub(crate) fn should_skip_discovery_path(root: &Path, path: &Path) -> bool { + let rel = path.strip_prefix(root).unwrap_or(path); + rel.components().any(|component| match component { + Component::Normal(segment) => { + let name = segment.to_string_lossy(); + name.starts_with('.') || name.contains(".backup-") || name.starts_with("backup-") + } + _ => false, + }) +} + +pub(crate) fn has_skill_metadata_ancestor(root: &Path, path: &Path) -> bool { + if !path.starts_with(root) { + return false; + } + let mut current = path.parent(); + while let Some(parent) = current { + if !parent.starts_with(root) { + break; + } + if parent == root { + break; + } + if metadata_file_for(parent).is_some() { + return true; + } + current = parent.parent(); + } + false +} + +pub(crate) fn should_include_metadata_candidate(root: &Path, path: &Path) -> bool { + if !is_skill_metadata_candidate(path) { + return false; + } + if !is_readme_markdown(path) { + return true; + } + let Some(parent) = path.parent() else { + return false; + }; + standard_metadata_file_for(parent).is_none() && !has_skill_metadata_ancestor(root, parent) +} + +pub(crate) fn is_skill_dir(path: &Path) -> bool { + path.is_dir() && metadata_file_for(path).is_some() +} + +pub fn system_list_skill_files_sync() -> Result { + let root = skills_root_dir()?; + let root_dir = skill_root_display(&root); + + let mut paths = Vec::new(); + let mut truncated = false; + for entry in WalkDir::new(&root).follow_links(false) { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + if should_skip_discovery_path(&root, entry.path()) { + continue; + } + if !entry.file_type().is_file() || !should_include_metadata_candidate(&root, entry.path()) { + continue; + } + + if paths.len() >= DEFAULT_SKILL_GLOB_MAX_RESULTS { + truncated = true; + break; + } + + paths.push(rel_to_root_str(&root, entry.path())); + } + + paths.sort(); + Ok(SystemListSkillFilesResponse { + root_dir, + paths, + truncated, + }) +} + +pub fn system_read_skill_metadata_sync( + path: String, +) -> Result { + let root = skills_root_dir()?; + let rel = sanitize_skill_rel_path(&path)?; + let target = root.join(rel); + let target = ensure_within_skills_root_existing(&root, &target)?; + read_skill_metadata_file(&target) +} + +pub fn system_read_skill_text_sync( + path: String, + offset: Option, + length: Option, +) -> Result { + let root = skills_root_dir()?; + read_skill_text_from_root(&root, &path, offset, length) +} + +pub(crate) fn read_skill_text_from_root( + root: &Path, + path: &str, + offset: Option, + length: Option, +) -> Result { + let rel = sanitize_skill_rel_path(path)?; + let target = root.join(rel); + let target = ensure_within_skills_root_existing(root, &target)?; + let md = + fs::metadata(&target).map_err(|e| format!("Failed to read Skill file metadata: {e}"))?; + if !md.is_file() { + return Err("Only regular Skill files can be read (not directories)".to_string()); + } + + let offset = offset.unwrap_or(0); + let length = length.unwrap_or(DEFAULT_SKILL_READ_LENGTH_LINES); + + let file = + fs::File::open(&target).map_err(|e| format!("Failed to open the Skill file: {e}"))?; + let mut reader = BufReader::new(file); + + let mut line_idx: usize = 0; + let mut taken: usize = 0; + let mut out = String::new(); + let mut truncated = false; + let mut buf = Vec::new(); + + loop { + buf.clear(); + let n = reader + .read_until(b'\n', &mut buf) + .map_err(|e| format!("Failed to read the Skill file: {e}"))?; + if n == 0 { + break; + } + + if line_idx < offset { + line_idx += 1; + continue; + } + + if taken >= length { + truncated = true; + break; + } + + if out.len().saturating_add(buf.len()) > SKILL_READ_MAX_BYTES { + truncated = true; + break; + } + + out.push_str(&String::from_utf8_lossy(&buf)); + line_idx += 1; + taken += 1; + } + + Ok(SystemReadSkillTextResponse { + content: out, + truncated, + }) +} + +pub(crate) fn discover_skill_dirs(root: &Path) -> Vec { + let mut root = root.to_path_buf(); + if root.is_file() && is_skill_metadata_candidate(&root) { + if let Some(parent) = root.parent() { + root = parent.to_path_buf(); + } + } + + if root.is_dir() && standard_metadata_file_for(&root).is_some() { + return vec![root]; + } + + let nested_skills = root.join("skills"); + if nested_skills.is_dir() { + let candidates = read_child_skill_dirs(&nested_skills); + if !candidates.is_empty() { + return candidates; + } + } + + if root.is_dir() { + let candidates = read_child_skill_dirs(&root); + if !candidates.is_empty() { + return candidates; + } + } + + if is_skill_dir(&root) { + return vec![root]; + } + + Vec::new() +} + +pub(crate) fn read_child_skill_dirs(root: &Path) -> Vec { + let mut candidates = Vec::new(); + let Ok(entries) = fs::read_dir(root) else { + return candidates; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .file_name() + .map(|name| { + let name = name.to_string_lossy(); + name.starts_with('.') || name.contains(".backup-") + }) + .unwrap_or(false) + { + continue; + } + if is_skill_dir(&path) { + candidates.push(path); + } + } + candidates.sort(); + candidates +} + +pub(crate) fn read_skill_source_metadata(skill_dir: &Path) -> Option { + let meta_path = skill_dir.join("_meta.json"); + let content = fs::read_to_string(meta_path).ok()?; + let value = serde_json::from_str::(&content).ok()?; + let slug = value + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let version = value + .get("version") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let published_at = value.get("publishedAt").and_then(Value::as_u64); + + Some(SystemSkillSourceMetadata { + registry: "clawhub".to_string(), + slug, + version, + published_at, + }) +} + +pub(crate) fn write_skill_source_metadata_for_install( + root: &Path, + payload: &serde_json::Map, + installed: &[SystemSkillInstallResult], +) -> Result<(), String> { + let Some(slug) = object_string(payload, "slug") else { + return Ok(()); + }; + let version = object_string(payload, "version").map(ToOwned::to_owned); + let published_at = payload.get("publishedAt").and_then(Value::as_u64); + let metadata = serde_json::json!({ + "registry": "clawhub", + "slug": slug, + "version": version, + "publishedAt": published_at, + }); + let bytes = serde_json::to_vec_pretty(&metadata) + .map_err(|e| format!("Failed to serialize Skill source metadata: {e}"))?; + + for item in installed { + let target = root.join(&item.name); + fs::write(target.join("_meta.json"), &bytes) + .map_err(|e| format!("Failed to write Skill source metadata: {e}"))?; + } + + Ok(()) +} + +pub(crate) fn skill_summary_from_dir( + root: &Path, + skill_dir: &Path, +) -> Result { + let metadata = read_skill_metadata_from_dir(skill_dir)?; + let skill_file = rel_to_root_str(root, &metadata.metadata_file); + let base_dir = rel_to_root_str(root, skill_dir); + Ok(SystemSkillSummary { + name: metadata.name, + description: metadata.description, + target: display_path(skill_dir), + skill_file, + base_dir, + source: read_skill_source_metadata(skill_dir), + }) +} + +pub(crate) fn list_installed_skills( + root: &Path, +) -> Result<(Vec, Vec), String> { + let mut skills = Vec::new(); + let mut invalid = Vec::new(); + let entries = fs::read_dir(root).map_err(|e| format!("Failed to list Skills root: {e}"))?; + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + invalid.push(SystemSkillInvalidEntry { + path: root.display().to_string(), + error: error.to_string(), + }); + continue; + } + }; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') || name.contains(".backup-") || !path.is_dir() { + continue; + } + match skill_summary_from_dir(root, &path) { + Ok(summary) => skills.push(summary), + Err(error) => invalid.push(SystemSkillInvalidEntry { + path: display_path(&path), + error, + }), + } + } + skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok((skills, invalid)) +} + +pub(crate) fn delete_installed_skill( + root: &Path, + name: &str, +) -> Result { + let name = sanitize_skill_name(name)?; + ensure_not_builtin_skill_management_target(&name, "delete")?; + let target = root.join(&name); + let metadata = fs::symlink_metadata(&target).map_err(|e| { + format!( + "Skill does not exist or cannot be inspected: {}: {e}", + target.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "SkillsManager action=delete refuses to delete symlink target: {}", + target.display() + )); + } + if !metadata.is_dir() { + return Err(format!( + "SkillsManager action=delete requires an installed Skill directory: {}", + target.display() + )); + } + fs::remove_dir_all(&target) + .map_err(|e| format!("Failed to delete Skill {}: {e}", target.display()))?; + Ok(SystemSkillDeleteResponse { + name, + target: display_path(&target), + }) +} + +pub(crate) fn package_installed_skill( + root: &Path, + name: &str, +) -> Result { + let name = sanitize_skill_name(name)?; + let target = root.join(&name); + let validation = validate_skill_dir(&target); + if !validation.ok { + return Err(format!( + "Validation failed before packaging:\n{}", + validation.errors.join("\n") + )); + } + let packages_root = root.join(".packages"); + fs::create_dir_all(&packages_root) + .map_err(|e| format!("Failed to create Skills packages directory: {e}"))?; + let archive = packages_root.join(format!("{name}.skill")); + let archive_file = fs::File::create(&archive) + .map_err(|e| format!("Failed to create Skill archive {}: {e}", archive.display()))?; + let mut writer = ZipWriter::new(archive_file); + let options = FileOptions::default() + .compression_method(CompressionMethod::Deflated) + .unix_permissions(0o644); + + for entry in WalkDir::new(&target).follow_links(false).min_depth(1) { + let entry = entry.map_err(|e| format!("Failed to inspect Skill for packaging: {e}"))?; + if entry.file_type().is_symlink() { + return Err(format!( + "Cannot package symlink inside Skill: {}", + entry.path().display() + )); + } + if !entry.file_type().is_file() { + continue; + } + let rel = entry + .path() + .strip_prefix(root) + .map_err(|e| format!("Failed to compute archive path: {e}"))? + .to_string_lossy() + .replace('\\', "/"); + writer + .start_file(rel, options) + .map_err(|e| format!("Failed to start archive file: {e}"))?; + let mut file = fs::File::open(entry.path()) + .map_err(|e| format!("Failed to open Skill file for packaging: {e}"))?; + io::copy(&mut file, &mut writer) + .map_err(|e| format!("Failed to write Skill archive: {e}"))?; + } + writer + .finish() + .map_err(|e| format!("Failed to finish Skill archive: {e}"))?; + + Ok(SystemSkillPackageResponse { + name, + target: display_path(&target), + archive: display_path(&archive), + }) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/manager.rs b/crates/agent-gui/src-tauri/src/services/skills/manager.rs new file mode 100644 index 00000000..a54290e5 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/manager.rs @@ -0,0 +1,329 @@ +//! SkillsManager 入口:payload 动作解析与 `system_manage_skill_sync` 分发。 + +use serde_json::Value; + +use super::*; + +pub(crate) fn action_from_payload( + payload: &serde_json::Map, +) -> Result { + let action = object_string(payload, "action").unwrap_or_else(|| { + if object_string(payload, "path").is_some() { + "read" + } else { + "list" + } + }); + match action { + "read" | "list" | "install" | "install_start" | "install_status" | "create" + | "validate" | "package" | "delete" | "clawhub_search" | "clawhub_install" => { + Ok(action.to_string()) + } + _ => Err(format!("SkillsManager action is not supported: {action}")), + } +} + +pub fn system_manage_skill_sync(payload: Value) -> Result { + let root = skills_root_dir()?; + let root_dir = skill_root_display(&root); + let payload = payload + .as_object() + .ok_or_else(|| "SkillsManager payload must be an object".to_string())?; + let action = action_from_payload(payload)?; + + match action.as_str() { + "read" => { + let path = object_string(payload, "path") + .ok_or_else(|| "SkillsManager read requires path".to_string())?; + let offset = object_usize(payload, "offset"); + let length = object_usize(payload, "length"); + let result = read_skill_text_from_root(&root, path, offset, length)?; + let num_lines = result.content.match_indices('\n').count() + + usize::from(!result.content.is_empty() && !result.content.ends_with('\n')); + Ok(SystemManageSkillResponse { + action, + root_dir, + path: Some(path.to_string()), + content: Some(result.content), + truncated: Some(result.truncated), + start_line: Some(offset.unwrap_or(0) + 1), + num_lines: Some(num_lines), + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "list" => { + let (skills, invalid) = list_installed_skills(&root)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: Some(skills), + invalid: Some(invalid), + installed: None, + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "clawhub_search" => { + let (clawhub_results, clawhub_next_cursor) = + search_clawhub_skills_from_payload(payload)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: Some(clawhub_results), + clawhub_next_cursor, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "install" => { + let installed = install_source_from_payload(&root, payload)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: Some(installed), + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "clawhub_install" => { + let (installed, slug, download_url) = + install_clawhub_skill_from_payload(&root, payload)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: Some(installed), + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: Some(slug), + clawhub_download_url: Some(download_url), + }) + } + "install_start" => { + let install_job = start_install_job_from_payload(root.clone(), payload)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: Some(install_job), + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "install_status" => { + let job_id = object_string(payload, "jobId") + .or_else(|| object_string(payload, "job_id")) + .ok_or_else(|| "SkillsManager install_status requires jobId".to_string())?; + let install_job = get_install_job_snapshot(job_id)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: Some(install_job), + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "create" => { + let created = create_skill_from_payload(&root, payload)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: Some(created), + validation: None, + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "validate" => { + let name = object_string(payload, "name") + .ok_or_else(|| "SkillsManager validate requires name".to_string())?; + let validation = validate_installed_skill(&root, name)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: Some(validation), + package: None, + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "package" => { + let name = object_string(payload, "name") + .ok_or_else(|| "SkillsManager package requires name".to_string())?; + let package = package_installed_skill(&root, name)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: Some(package), + deleted: None, + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + "delete" => { + let name = object_string(payload, "name") + .ok_or_else(|| "SkillsManager delete requires name".to_string())?; + let deleted = delete_installed_skill(&root, name)?; + Ok(SystemManageSkillResponse { + action, + root_dir, + path: None, + content: None, + truncated: None, + start_line: None, + num_lines: None, + skills: None, + invalid: None, + installed: None, + created: None, + validation: None, + package: None, + deleted: Some(deleted), + seeded: None, + install_job: None, + clawhub_results: None, + clawhub_next_cursor: None, + clawhub_slug: None, + clawhub_download_url: None, + }) + } + _ => Err(format!("SkillsManager action is not supported: {action}")), + } +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/metadata.rs b/crates/agent-gui/src-tauri/src/services/skills/metadata.rs new file mode 100644 index 00000000..4e5e3d81 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/metadata.rs @@ -0,0 +1,485 @@ +//! Skill 元数据:frontmatter / skill.json 解析、README 回退与元数据文件定位。 + +use serde_json::Value; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +use super::*; + +const SKILL_METADATA_MAX_BYTES: usize = 200 * 1024; // 200KB + +pub(crate) fn is_skill_markdown(path: &Path) -> bool { + path.file_name() + .map(|name| name.to_string_lossy().eq_ignore_ascii_case("skill.md")) + .unwrap_or(false) +} + +pub(crate) fn is_readme_markdown(path: &Path) -> bool { + path.file_name() + .map(|name| name.to_string_lossy().eq_ignore_ascii_case("README.md")) + .unwrap_or(false) +} + +pub(crate) fn is_skill_json(path: &Path) -> bool { + path.file_name() + .map(|name| name.to_string_lossy().eq_ignore_ascii_case("skill.json")) + .unwrap_or(false) +} + +pub(crate) fn standard_metadata_file_for(skill_dir: &Path) -> Option { + for name in ["skill.json", "SKILL.md", "skill.md"] { + let candidate = skill_dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + let entries = fs::read_dir(skill_dir).ok()?; + for entry in entries.flatten() { + let candidate = entry.path(); + if candidate.is_file() + && candidate + .file_name() + .map(|name| { + let name = name.to_string_lossy(); + name.eq_ignore_ascii_case("skill.json") || name.eq_ignore_ascii_case("skill.md") + }) + .unwrap_or(false) + { + return Some(candidate); + } + } + None +} + +pub(crate) fn is_skill_metadata_candidate(path: &Path) -> bool { + is_skill_markdown(path) || is_skill_json(path) || is_readme_markdown(path) +} + +pub(crate) fn unquote_yaml_scalar(raw: &str) -> String { + let value = raw.trim(); + if value.len() >= 2 { + let quoted_with_double = value.starts_with('"') && value.ends_with('"'); + let quoted_with_single = value.starts_with('\'') && value.ends_with('\''); + if quoted_with_double || quoted_with_single { + return value[1..value.len() - 1].to_string(); + } + } + value.to_string() +} + +pub(crate) fn normalize_skill_metadata_value(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +pub(crate) fn parse_yaml_top_level_scalar(yaml: &str, key: &str) -> Option { + if !yaml.contains('\n') { + return parse_inline_yaml_top_level_scalar(yaml, key); + } + + let lines: Vec<&str> = yaml.lines().collect(); + let prefix = format!("{key}:"); + let mut i = 0; + while i < lines.len() { + let line = lines[i]; + let Some(rest) = line.strip_prefix(&prefix) else { + i += 1; + continue; + }; + + let rest = rest.trim(); + if rest == "|" || rest == ">" { + i += 1; + let mut block = Vec::new(); + while i < lines.len() { + let block_line = lines[i]; + let is_indented = block_line + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false); + if !is_indented { + break; + } + block.push(block_line.trim_start().to_string()); + i += 1; + } + return Some(block.join("\n")); + } + + return Some(unquote_yaml_scalar(rest)); + } + + None +} + +pub(crate) fn inline_yaml_key_start(yaml: &str, key: &str) -> Option { + let prefix = format!("{key}:"); + yaml.match_indices(&prefix).find_map(|(index, _)| { + let is_boundary = index == 0 + || yaml[..index] + .chars() + .next_back() + .map(char::is_whitespace) + .unwrap_or(false); + is_boundary.then_some(index) + }) +} + +pub(crate) fn parse_inline_yaml_top_level_scalar(yaml: &str, key: &str) -> Option { + let start = inline_yaml_key_start(yaml, key)? + key.len() + 1; + let mut end = yaml.len(); + for other in [ + "name", + "description", + "license", + "allowed-tools", + "metadata", + ] { + if other == key { + continue; + } + if let Some(next) = inline_yaml_key_start(&yaml[start..], other) { + end = end.min(start + next); + } + } + Some(unquote_yaml_scalar(yaml[start..end].trim())) +} + +pub(crate) fn parse_skill_frontmatter_yaml_metadata(yaml: &str) -> SystemReadSkillMetadataResponse { + let name = parse_yaml_top_level_scalar(yaml, "name"); + let description = parse_yaml_top_level_scalar(yaml, "description"); + + SystemReadSkillMetadataResponse { + name: normalize_skill_metadata_value(name), + description: normalize_skill_metadata_value(description), + } +} + +pub(crate) fn parse_skill_json_metadata(json_text: &str) -> SystemReadSkillMetadataResponse { + let Ok(parsed) = serde_json::from_str::(strip_utf8_bom(json_text)) else { + return SystemReadSkillMetadataResponse { + name: None, + description: None, + }; + }; + + let name = parsed + .get("name") + .and_then(Value::as_str) + .map(ToString::to_string); + let description = parsed + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string); + + SystemReadSkillMetadataResponse { + name: normalize_skill_metadata_value(name), + description: normalize_skill_metadata_value(description), + } +} + +pub(crate) fn empty_skill_metadata_response() -> SystemReadSkillMetadataResponse { + SystemReadSkillMetadataResponse { + name: None, + description: None, + } +} + +pub(crate) fn is_missing_frontmatter_error(error: &str) -> bool { + error == "Skill frontmatter must start with ---" +} + +pub(crate) fn fallback_readme_skill_name(skill_dir: &Path) -> Result { + let raw = skill_dir + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "readme-skill".to_string()); + let normalized = normalize_skill_name(&raw); + sanitize_skill_name(&normalized) +} + +pub(crate) fn first_readme_description_line(content: &str) -> Option { + strip_utf8_bom(content).lines().find_map(|line| { + let mut value = line.trim(); + if value.is_empty() { + return None; + } + if value == "---" { + return None; + } + value = value.trim_start_matches('#').trim(); + if value.is_empty() { + return None; + } + let value = value + .trim_matches(|ch: char| ch == '*' || ch == '_' || ch == '`') + .trim(); + (!value.is_empty()).then(|| value.to_string()) + }) +} + +pub(crate) fn fallback_readme_description(readme_file: &Path, name: &str) -> Result { + let content = fs::read_to_string(readme_file) + .map_err(|e| format!("Failed to read README.md fallback: {e}"))?; + let description = first_readme_description_line(&content) + .unwrap_or_else(|| format!("README.md skill instructions for {name}")); + Ok(description + .chars() + .take(MAX_SKILL_DESCRIPTION_LENGTH) + .collect()) +} + +pub(crate) fn read_skill_markdown_frontmatter_yaml( + reader: &mut R, +) -> Result { + let mut buf = Vec::new(); + let mut yaml = String::new(); + let mut is_first_line = true; + + loop { + buf.clear(); + let n = reader + .read_until(b'\n', &mut buf) + .map_err(|e| format!("Failed to read Skill file: {e}"))?; + if n == 0 { + return Err("Skill frontmatter must start with ---".to_string()); + } + + let line = String::from_utf8_lossy(&buf).to_string(); + let line = if is_first_line { + is_first_line = false; + strip_utf8_bom(&line).to_string() + } else { + line + }; + + if line.trim().is_empty() { + continue; + } + + if line.trim() != "---" { + if let Some((yaml, _body)) = split_inline_frontmatter(&line) { + return Ok(yaml); + } + return Err("Skill frontmatter must start with ---".to_string()); + } + break; + } + + let mut found_end = false; + loop { + buf.clear(); + let n = reader + .read_until(b'\n', &mut buf) + .map_err(|e| format!("Failed to read Skill file: {e}"))?; + if n == 0 { + break; + } + + let line = String::from_utf8_lossy(&buf); + if yaml.len().saturating_add(line.len()) > SKILL_METADATA_MAX_BYTES { + return Err(format!( + "Skill frontmatter is too large, over {} bytes", + SKILL_METADATA_MAX_BYTES + )); + } + + if line.trim() == "---" { + found_end = true; + break; + } + yaml.push_str(&line); + } + + if !found_end { + return Err("Skill frontmatter is missing closing ---".to_string()); + } + + Ok(yaml) +} + +pub(crate) fn metadata_file_for(skill_dir: &Path) -> Option { + if let Some(candidate) = standard_metadata_file_for(skill_dir) { + return Some(candidate); + } + let readme = skill_dir.join("README.md"); + if readme.is_file() { + return Some(readme); + } + let entries = fs::read_dir(skill_dir).ok()?; + for entry in entries.flatten() { + let candidate = entry.path(); + if candidate.is_file() + && candidate + .file_name() + .map(|name| { + let name = name.to_string_lossy(); + name.eq_ignore_ascii_case("README.md") + }) + .unwrap_or(false) + { + return Some(candidate); + } + } + None +} + +pub(crate) fn read_skill_metadata_from_dir(skill_dir: &Path) -> Result { + let metadata_file = metadata_file_for(skill_dir).ok_or_else(|| { + format!( + "No SKILL.md, skill.md, skill.json, or README.md found in {}", + skill_dir.display() + ) + })?; + let metadata = read_skill_metadata_file(&metadata_file)?; + let name = metadata.name.clone(); + let description = metadata.description.clone(); + if is_readme_markdown(&metadata_file) && name.is_none() && description.is_none() { + let name = fallback_readme_skill_name(skill_dir)?; + let description = fallback_readme_description(&metadata_file, &name)?; + sanitize_skill_name(&name)?; + return Ok(SkillMetadata { + name, + description, + metadata_file, + }); + } + + let name = name.ok_or_else(|| format!("Missing skill name in {}", metadata_file.display()))?; + let description = description + .ok_or_else(|| format!("Missing skill description in {}", metadata_file.display()))?; + sanitize_skill_name(&name)?; + Ok(SkillMetadata { + name, + description, + metadata_file, + }) +} + +pub(crate) fn read_skill_metadata_file( + target: &Path, +) -> Result { + let md = fs::metadata(target).map_err(|e| format!("Failed to read Skill metadata: {e}"))?; + if !md.is_file() { + return Err("Only regular Skill metadata files can be read".to_string()); + } + + if is_skill_json(target) { + let content = + fs::read_to_string(target).map_err(|e| format!("Failed to read skill.json: {e}"))?; + if content.len() > SKILL_METADATA_MAX_BYTES { + return Err(format!( + "skill.json is too large, over {} bytes", + SKILL_METADATA_MAX_BYTES + )); + } + return Ok(parse_skill_json_metadata(&content)); + } + + if !is_skill_markdown(target) && !is_readme_markdown(target) { + return Err( + "Skill metadata files only support skill.json / SKILL.md / skill.md / README.md" + .to_string(), + ); + } + + let file = fs::File::open(target).map_err(|e| format!("Failed to open Skill file: {e}"))?; + let mut reader = BufReader::new(file); + let yaml = match read_skill_markdown_frontmatter_yaml(&mut reader) { + Ok(yaml) => yaml, + Err(error) if is_readme_markdown(target) && is_missing_frontmatter_error(&error) => { + return Ok(empty_skill_metadata_response()); + } + Err(error) => return Err(error), + }; + Ok(parse_skill_frontmatter_yaml_metadata(&yaml)) +} + +pub(crate) fn split_inline_frontmatter(content: &str) -> Option<(String, String)> { + let rest = content.trim_start().strip_prefix("---")?; + if rest.trim_start().starts_with('\n') || rest.trim().is_empty() { + return None; + } + let closing = rest.find("---")?; + let yaml = rest[..closing].trim().to_string(); + let body = rest[closing + 3..].trim_start().to_string(); + Some((yaml, body)) +} + +pub(crate) fn frontmatter_keys(yaml: &str) -> Vec { + if !yaml.contains('\n') { + return [ + "name", + "description", + "license", + "allowed-tools", + "metadata", + ] + .into_iter() + .filter(|key| inline_yaml_key_start(yaml, key).is_some()) + .map(ToString::to_string) + .collect(); + } + + let mut keys = Vec::new(); + for line in yaml.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if line + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false) + { + continue; + } + if let Some((key, _)) = line.split_once(':') { + let key = key.trim(); + if !key.is_empty() + && key + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + keys.push(key.to_string()); + } + } + } + keys +} + +pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), String> { + let normalized = strip_utf8_bom(content); + if let Some((yaml, body)) = split_inline_frontmatter(normalized) { + return Ok((yaml, body)); + } + + let lines = normalized.split_inclusive('\n').collect::>(); + let mut index = 0usize; + while index < lines.len() && lines[index].trim().is_empty() { + index += 1; + } + if index >= lines.len() || lines[index].trim() != "---" { + return Err("Skill frontmatter must start with ---".to_string()); + } + index += 1; + let mut yaml = String::new(); + while index < lines.len() { + if lines[index].trim() == "---" { + let body = lines[index + 1..].join(""); + return Ok((yaml, body)); + } + yaml.push_str(lines[index]); + index += 1; + } + Err("Skill frontmatter is missing the closing ---".to_string()) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/mod.rs b/crates/agent-gui/src-tauri/src/services/skills/mod.rs new file mode 100644 index 00000000..6da8f3cc --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/mod.rs @@ -0,0 +1,54 @@ +//! Skills 服务模块(拆分自原单文件 skills.rs,代码逐字迁移,行为不变)。 +//! +//! - [`types`]:对外 `System*` 响应 DTO 与内部数据类型 +//! - [`util`]:临时目录、时间戳与 payload 字段工具 +//! - [`paths`]:skills 根目录解析、路径回显与路径 / 名称清洗 +//! - [`metadata`]:frontmatter / skill.json 元数据解析与元数据文件定位 +//! - [`library`]:已安装 Skill 库(发现 / 列表 / 读取 / 删除 / 打包 / `_meta.json`) +//! - [`sources`]:安装源准备(GitHub / HTTP / 本地 / 压缩包)、下载与安全解压 +//! - [`install`]:备份、带冲突策略的复制与 install payload 编排 +//! - [`jobs`]:后台安装任务注册表与 install_start 工作线程 +//! - [`clawhub`]:ClawHub 注册表搜索与安装 +//! - [`create`]:SKILL.md 模板渲染与 create 编排 +//! - [`validate`]:Skill 目录校验与英文文档检查 +//! - [`builtin`]:内置 Agent Skill 定义、修改保护与种子写入 +//! - [`manager`]:`system_manage_skill_sync` 动作分发入口 + +mod builtin; +mod clawhub; +mod create; +mod install; +mod jobs; +mod library; +mod manager; +mod metadata; +mod paths; +mod sources; +#[cfg(test)] +mod tests; +mod types; +mod util; +mod validate; + +pub use builtin::ensure_builtin_agent_skills_sync; +pub(crate) use builtin::*; +pub(crate) use clawhub::*; +pub(crate) use create::*; +pub(crate) use install::*; +pub(crate) use jobs::*; +pub use library::{ + system_list_skill_files_sync, system_read_skill_metadata_sync, system_read_skill_text_sync, +}; +pub(crate) use library::*; +pub use manager::system_manage_skill_sync; +pub(crate) use metadata::*; +pub use paths::skills_root_dir; +pub(crate) use paths::*; +pub(crate) use sources::*; +pub use types::*; +pub(crate) use util::*; +pub(crate) use validate::*; + +/// 跨子模块共享的 Skill 限制常量。 +pub(crate) const MAX_SKILL_DESCRIPTION_LENGTH: usize = 1024; +pub(crate) const MAX_SKILL_FILE_BYTES: u64 = 10 * 1024 * 1024; diff --git a/crates/agent-gui/src-tauri/src/services/skills/paths.rs b/crates/agent-gui/src-tauri/src/services/skills/paths.rs new file mode 100644 index 00000000..6fa3b1a7 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/paths.rs @@ -0,0 +1,179 @@ +//! Skills 根目录解析、路径回显与路径 / 名称清洗。 + +use std::fs; +use std::path::{Component, Path, PathBuf}; + +const MAX_SKILL_NAME_LENGTH: usize = 64; + +pub fn app_storage_dir() -> Result { + let home = + dirs::home_dir().ok_or_else(|| "Failed to locate the user home directory".to_string())?; + let dir = home.join(format!(".{}", env!("CARGO_PKG_NAME"))); + fs::create_dir_all(&dir) + .map_err(|e| format!("Failed to create the application directory: {e}"))?; + Ok(dir) +} + +pub fn skills_root_dir() -> Result { + let dir = app_storage_dir()?.join("skills"); + fs::create_dir_all(&dir).map_err(|e| format!("Failed to create the skills directory: {e}"))?; + fs::canonicalize(&dir).map_err(|e| format!("Failed to resolve the skills directory: {e}")) +} + +pub(crate) fn skill_root_display(root: &Path) -> String { + let raw = root.to_string_lossy(); + #[cfg(windows)] + { + if let Some(stripped) = raw.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{}", stripped).replace('\\', "/"); + } + if let Some(stripped) = raw.strip_prefix(r"\\?\") { + return stripped.replace('\\', "/"); + } + } + raw.replace('\\', "/") +} + +pub(crate) fn rel_to_root_str(root: &Path, abs: &Path) -> String { + abs.strip_prefix(root) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/") +} + +pub(crate) fn display_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub(crate) fn ensure_within_skills_root_existing( + root: &Path, + target: &Path, +) -> Result { + let canon = + fs::canonicalize(target).map_err(|e| format!("Failed to resolve the Skill file: {e}"))?; + if !canon.starts_with(root) { + return Err(format!( + "Target Skill file is outside the skills root: {}", + canon.display() + )); + } + Ok(canon) +} + +pub(crate) fn sanitize_skill_rel_path(input: &str) -> Result { + let raw = input.trim(); + if raw.is_empty() { + return Err("Skill path cannot be empty".to_string()); + } + + let path = Path::new(raw); + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => { + return Err(format!("Skill path must be relative: {input}")); + } + Component::ParentDir => { + return Err(format!("Skill path must not contain ..: {input}")); + } + Component::CurDir => {} + Component::Normal(segment) => { + let segment = segment.to_string_lossy(); + if segment.contains(':') || is_windows_reserved_path_component(&segment) { + return Err(format!("Invalid Skill path: {input}")); + } + out.push(segment.as_ref()); + } + } + } + + if out.as_os_str().is_empty() { + return Err("Skill path cannot be empty".to_string()); + } + + Ok(out) +} + +pub(crate) fn sanitize_skill_child_rel_path(input: &str) -> Result { + let rel = sanitize_skill_rel_path(input)?; + if rel + .components() + .any(|component| matches!(component, Component::Normal(segment) if segment.to_string_lossy().starts_with('.'))) + { + return Err(format!("Skill file path must not use hidden control directories: {input}")); + } + Ok(rel) +} + +pub(crate) fn sanitize_skill_name(input: &str) -> Result { + let name = input.trim(); + if name.is_empty() { + return Err("Skill name cannot be empty".to_string()); + } + if name.len() > MAX_SKILL_NAME_LENGTH { + return Err(format!( + "Skill name '{name}' is too long; maximum is {MAX_SKILL_NAME_LENGTH}" + )); + } + if !name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(format!( + "Skill name '{name}' must use lowercase letters, digits, and hyphens only" + )); + } + if name.starts_with('-') || name.ends_with('-') || name.contains("--") { + return Err(format!( + "Skill name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + )); + } + if is_windows_reserved_path_component(name) { + return Err(format!("Skill name '{name}' is reserved on Windows")); + } + Ok(name.to_string()) +} + +pub(crate) fn is_windows_reserved_path_component(input: &str) -> bool { + let stem = input + .split('.') + .next() + .unwrap_or(input) + .trim_matches(|ch| ch == ' ' || ch == '.') + .to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && stem.as_bytes()[3].is_ascii_digit() + && stem.as_bytes()[3] != b'0') +} + +pub(crate) fn normalize_skill_name(raw_name: &str) -> String { + let mut out = String::new(); + let mut previous_dash = false; + for ch in raw_name.trim().chars().flat_map(char::to_lowercase) { + if ch.is_ascii_lowercase() || ch.is_ascii_digit() { + out.push(ch); + previous_dash = false; + } else if !previous_dash { + out.push('-'); + previous_dash = true; + } + } + out.trim_matches('-').to_string() +} + +pub(crate) fn title_case_skill_name(skill_name: &str) -> String { + skill_name + .split('-') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/sources.rs b/crates/agent-gui/src-tauri/src/services/skills/sources.rs new file mode 100644 index 00000000..096f47c9 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/sources.rs @@ -0,0 +1,437 @@ +//! 安装源准备:GitHub / HTTP / 本地目录 / 压缩包,含下载与安全解压。 + +use reqwest::blocking::Client as HttpClient; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::Duration; +use zip::ZipArchive; + +use super::*; + +const MAX_SKILL_INSTALL_FILES: usize = 2000; +const MAX_SKILL_INSTALL_BYTES: u64 = 50 * 1024 * 1024; +pub(crate) const DEFAULT_GITHUB_REF: &str = "main"; + +pub(crate) fn is_archive_path(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref(), + Some("zip") | Some("skill") + ) +} + +pub(crate) fn safe_extract_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { + fs::create_dir_all(dest_dir) + .map_err(|e| format!("Failed to create archive extraction directory: {e}"))?; + let file = fs::File::open(zip_path) + .map_err(|e| format!("Failed to open Skill archive {}: {e}", zip_path.display()))?; + let mut archive = ZipArchive::new(file) + .map_err(|e| format!("Failed to read Skill archive {}: {e}", zip_path.display()))?; + + if archive.len() > MAX_SKILL_INSTALL_FILES { + return Err(format!( + "Skill archive contains too many files: {}", + archive.len() + )); + } + + let mut total_bytes = 0u64; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .map_err(|e| format!("Failed to read archive entry: {e}"))?; + let Some(enclosed_name) = file.enclosed_name().map(PathBuf::from) else { + return Err(format!( + "Archive entry escapes extraction root: {}", + file.name() + )); + }; + if enclosed_name.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::Prefix(_) | Component::RootDir + ) + }) { + return Err(format!("Archive entry has an unsafe path: {}", file.name())); + } + if file + .unix_mode() + .map(|mode| (mode & 0o170000) == 0o120000) + .unwrap_or(false) + { + return Err(format!("Archive entry is a symlink: {}", file.name())); + } + total_bytes = total_bytes.saturating_add(file.size()); + if total_bytes > MAX_SKILL_INSTALL_BYTES { + return Err(format!( + "Skill archive is too large after extraction, over {} bytes", + MAX_SKILL_INSTALL_BYTES + )); + } + + let out_path = dest_dir.join(enclosed_name); + if file.name().ends_with('/') { + fs::create_dir_all(&out_path) + .map_err(|e| format!("Failed to create archive directory: {e}"))?; + continue; + } + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create archive parent directory: {e}"))?; + } + let mut out_file = fs::File::create(&out_path) + .map_err(|e| format!("Failed to create archive output file: {e}"))?; + io::copy(&mut file, &mut out_file) + .map_err(|e| format!("Failed to extract archive entry: {e}"))?; + } + Ok(()) +} + +pub(crate) fn write_download_to_path_with_progress( + url: &str, + target: &Path, + mut on_progress: F, +) -> Result, String> +where + F: FnMut(u64, Option), +{ + let client = HttpClient::builder() + .timeout(Duration::from_secs(30)) + .user_agent("liveagent-skill-installer") + .build() + .map_err(|e| format!("Failed to create HTTP client: {e}"))?; + let mut response = client + .get(url) + .send() + .map_err(|e| format!("Failed to download Skill source: {e}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("Skill source download failed with HTTP {status}")); + } + let total_bytes = response.content_length(); + if total_bytes + .map(|value| value > MAX_SKILL_INSTALL_BYTES) + .unwrap_or(false) + { + return Err(format!( + "Downloaded Skill source is too large, over {} bytes", + MAX_SKILL_INSTALL_BYTES + )); + } + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create Skill download directory: {e}"))?; + } + let mut output = fs::File::create(target) + .map_err(|e| format!("Failed to stage downloaded Skill source: {e}"))?; + let mut bytes = Vec::new(); + let mut downloaded = 0u64; + let mut buffer = [0u8; 64 * 1024]; + on_progress(downloaded, total_bytes); + + loop { + let read = response + .read(&mut buffer) + .map_err(|e| format!("Failed to read Skill source response: {e}"))?; + if read == 0 { + break; + } + downloaded = downloaded.saturating_add(read as u64); + if downloaded > MAX_SKILL_INSTALL_BYTES { + return Err(format!( + "Downloaded Skill source is too large, over {} bytes", + MAX_SKILL_INSTALL_BYTES + )); + } + output + .write_all(&buffer[..read]) + .map_err(|e| format!("Failed to write downloaded Skill source: {e}"))?; + bytes.extend_from_slice(&buffer[..read]); + on_progress(downloaded, total_bytes); + } + output + .flush() + .map_err(|e| format!("Failed to flush downloaded Skill source: {e}"))?; + Ok(bytes) +} + +pub(crate) fn write_download_to_path(url: &str, target: &Path) -> Result, String> { + write_download_to_path_with_progress(url, target, |_, _| {}) +} + +pub(crate) fn is_github_source(value: &str) -> bool { + let Ok(url) = reqwest::Url::parse(value) else { + return false; + }; + matches!(url.scheme(), "http" | "https") + && matches!(url.host_str(), Some("github.com" | "www.github.com")) +} + +pub(crate) fn is_http_source(value: &str) -> bool { + let Ok(url) = reqwest::Url::parse(value) else { + return false; + }; + matches!(url.scheme(), "http" | "https") +} + +pub(crate) fn parse_github_url(value: &str, default_ref: &str) -> Result { + let url = reqwest::Url::parse(value).map_err(|e| format!("Invalid GitHub URL: {e}"))?; + if !matches!(url.host_str(), Some("github.com" | "www.github.com")) { + return Err("Only github.com URLs are supported".to_string()); + } + let parts = url + .path_segments() + .ok_or_else(|| "GitHub URL must include owner and repo".to_string())? + .filter(|part| !part.is_empty()) + .collect::>(); + if parts.len() < 2 { + return Err("GitHub URL must include owner and repo".to_string()); + } + let owner = parts[0].to_string(); + let repo = parts[1].trim_end_matches(".git").to_string(); + let mut git_ref = default_ref.trim(); + if git_ref.is_empty() { + git_ref = DEFAULT_GITHUB_REF; + } + let mut subpath = None; + + if parts.len() > 2 { + let marker = parts[2]; + if marker == "tree" || marker == "blob" { + if parts.len() < 4 { + return Err("GitHub tree/blob URL must include a ref".to_string()); + } + git_ref = parts[3]; + if parts.len() > 4 { + subpath = Some(parts[4..].join("/")); + } + } else { + subpath = Some(parts[2..].join("/")); + } + } + + Ok(GithubSource { + owner, + repo, + git_ref: git_ref.to_string(), + subpath, + }) +} + +pub(crate) fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<(), String> { + let mut command = Command::new("git"); + crate::runtime::process::configure_child_process_group(&mut command); + command.args(args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + let output = command + .output() + .map_err(|e| format!("Failed to start git: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + "git command failed".to_string() + } else { + stderr + }); + } + Ok(()) +} + +pub(crate) fn prepare_github_source( + value: &str, + method: &str, + default_ref: &str, + tmp_root: &Path, +) -> Result { + let source = parse_github_url(value, default_ref)?; + let mut repo_root = None; + if method == "auto" || method == "download" { + let archive = tmp_root.join("github-repo.zip"); + let zip_url = format!( + "https://codeload.github.com/{}/{}/zip/{}", + source.owner, source.repo, source.git_ref + ); + match write_download_to_path(&zip_url, &archive).and_then(|_| { + let extract_dir = tmp_root.join("github-download"); + safe_extract_zip(&archive, &extract_dir)?; + let mut top_levels = fs::read_dir(&extract_dir) + .map_err(|e| format!("Failed to inspect GitHub archive: {e}"))? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect::>(); + top_levels.sort(); + if top_levels.len() != 1 { + return Err("Unexpected GitHub archive layout".to_string()); + } + Ok(top_levels.remove(0)) + }) { + Ok(path) => repo_root = Some(path), + Err(error) if method == "download" => { + return Err(format!("GitHub download failed: {error}")); + } + Err(_) => {} + } + } + + if repo_root.is_none() { + let repo_dir = tmp_root.join("github-repo"); + let repo_url = format!("https://github.com/{}/{}.git", source.owner, source.repo); + let repo_dir_str = repo_dir + .to_str() + .ok_or_else(|| "Temporary git path is not valid UTF-8".to_string())?; + let mut clone_args = vec![ + "clone", + "--depth", + "1", + "--single-branch", + "--branch", + source.git_ref.as_str(), + ]; + if source.subpath.is_some() { + clone_args.push("--filter=blob:none"); + clone_args.push("--sparse"); + } + clone_args.push(repo_url.as_str()); + clone_args.push(repo_dir_str); + run_git(&clone_args, None)?; + if let Some(subpath) = source.subpath.as_deref() { + run_git(&["sparse-checkout", "set", subpath], Some(&repo_dir))?; + run_git(&["checkout", source.git_ref.as_str()], Some(&repo_dir))?; + } + repo_root = Some(repo_dir); + } + + let repo_root = repo_root.ok_or_else(|| "Failed to prepare GitHub source".to_string())?; + if let Some(subpath) = source.subpath { + let selected = repo_root.join(&subpath); + if !selected.exists() { + return Err(format!("GitHub path not found: {subpath}")); + } + return Ok(selected); + } + Ok(repo_root) +} + +pub(crate) fn prepare_http_source_with_progress( + value: &str, + tmp_root: &Path, + mut on_progress: F, +) -> Result +where + F: FnMut(SkillInstallProgressUpdate), +{ + let url = reqwest::Url::parse(value).map_err(|e| format!("Invalid source URL: {e}"))?; + let lower_path = url.path().to_ascii_lowercase(); + let download_path = tmp_root.join("downloaded-skill-source"); + on_progress(SkillInstallProgressUpdate { + phase: "downloading", + downloaded_bytes: Some(0), + total_bytes: None, + message: Some("Downloading Skill archive".to_string()), + }); + let bytes = + write_download_to_path_with_progress(value, &download_path, |downloaded, total| { + on_progress(SkillInstallProgressUpdate { + phase: "downloading", + downloaded_bytes: Some(downloaded), + total_bytes: total, + message: Some("Downloading Skill archive".to_string()), + }); + })?; + let is_zip = lower_path.ends_with(".zip") + || lower_path.ends_with(".skill") + || bytes.starts_with(b"PK\x03\x04"); + + if is_zip { + let extract_dir = tmp_root.join("downloaded-archive"); + on_progress(SkillInstallProgressUpdate { + phase: "extracting", + downloaded_bytes: None, + total_bytes: None, + message: Some("Extracting Skill archive".to_string()), + }); + safe_extract_zip(&download_path, &extract_dir)?; + return Ok(extract_dir); + } + + if lower_path.ends_with("skill.json") + || lower_path.ends_with("skill.md") + || lower_path.ends_with("skill") + || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) + .trim_start() + .starts_with("---") + || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) + .trim_start() + .starts_with('{') + { + let single_dir = tmp_root.join("downloaded-single-skill"); + on_progress(SkillInstallProgressUpdate { + phase: "validating", + downloaded_bytes: None, + total_bytes: None, + message: Some("Preparing downloaded Skill file".to_string()), + }); + fs::create_dir_all(&single_dir) + .map_err(|e| format!("Failed to stage downloaded Skill: {e}"))?; + let file_name = if lower_path.ends_with("skill.json") + || strip_utf8_bom(&String::from_utf8_lossy(&bytes)) + .trim_start() + .starts_with('{') + { + "skill.json" + } else { + "SKILL.md" + }; + fs::write(single_dir.join(file_name), bytes) + .map_err(|e| format!("Failed to write downloaded Skill file: {e}"))?; + return Ok(single_dir); + } + + Err( + "HTTP(S) Skill sources must be .zip/.skill archives or a SKILL.md/skill.json file" + .to_string(), + ) +} + +pub(crate) fn prepare_local_or_archive_source( + source: &str, + tmp_root: &Path, +) -> Result { + let source_path = crate::runtime::platform::expand_tilde_path(source); + if !source_path.exists() { + return Err(format!("Source not found: {source}")); + } + let source_path = fs::canonicalize(&source_path).map_err(|e| { + format!( + "Failed to resolve source path {}: {e}", + source_path.display() + ) + })?; + + if source_path.is_dir() { + return Ok(source_path); + } + + if source_path.is_file() && is_archive_path(&source_path) { + let extract_dir = tmp_root.join("archive"); + safe_extract_zip(&source_path, &extract_dir)?; + return Ok(extract_dir); + } + + if source_path.is_file() && is_skill_metadata_candidate(&source_path) { + return source_path + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "Source Skill file has no parent directory".to_string()); + } + + Err("Source must be a skill directory, .zip/.skill archive, GitHub URL, or HTTP(S) Skill download URL".to_string()) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/tests.rs b/crates/agent-gui/src-tauri/src/services/skills/tests.rs new file mode 100644 index 00000000..d7932881 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/tests.rs @@ -0,0 +1,638 @@ +use super::*; +use serde_json::json; +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use zip::write::FileOptions; +use zip::{CompressionMethod, ZipWriter}; + +fn write_skill(root: &Path, name: &str, description: &str) -> PathBuf { + let dir = root.join(name); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write skill"); + dir +} + +#[test] +fn skill_name_rejects_windows_reserved_names() { + assert!(sanitize_skill_name("safe-skill").is_ok()); + assert!(sanitize_skill_name("con").is_err()); + assert!(sanitize_skill_name("aux").is_err()); + assert!(sanitize_skill_name("com9").is_err()); + assert!(sanitize_skill_name("com0").is_ok()); +} + +#[test] +fn skill_rel_path_rejects_windows_reserved_components() { + assert!(sanitize_skill_child_rel_path("references/notes.md").is_ok()); + assert!(sanitize_skill_child_rel_path("references/con.md").is_err()); + assert!(sanitize_skill_child_rel_path("references/LPT1.txt").is_err()); + assert!(sanitize_skill_child_rel_path("references/com0.txt").is_ok()); +} + +#[test] +fn github_tree_url_parses_ref_and_subpath() { + let source = parse_github_url( + "https://github.com/owner/repo/tree/main/skills/example", + DEFAULT_GITHUB_REF, + ) + .expect("parse github url"); + + assert_eq!(source.owner, "owner"); + assert_eq!(source.repo, "repo"); + assert_eq!(source.git_ref, "main"); + assert_eq!(source.subpath.as_deref(), Some("skills/example")); +} + +#[test] +fn discover_skill_dirs_supports_repo_skills_folder() { + let tmp = TempDir::new("liveagent-skill-discover-test").expect("temp dir"); + let skills_root = tmp.path().join("repo").join("skills"); + write_skill(&skills_root, "first-skill", "First"); + write_skill(&skills_root, "second-skill", "Second"); + + let dirs = discover_skill_dirs(&tmp.path().join("repo")); + let names = dirs + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + + assert_eq!(names, vec!["first-skill", "second-skill"]); +} + +#[test] +fn discover_skill_dirs_does_not_let_root_readme_override_skills_folder() { + let tmp = TempDir::new("liveagent-readme-root-discover-test").expect("temp dir"); + let repo = tmp.path().join("repo"); + fs::create_dir_all(&repo).expect("create repo"); + fs::write(repo.join("README.md"), "# Repo README\n").expect("write repo readme"); + write_skill(&repo.join("skills"), "nested-skill", "Nested"); + + let dirs = discover_skill_dirs(&repo); + let names = dirs + .iter() + .map(|path| path.file_name().unwrap().to_string_lossy().to_string()) + .collect::>(); + + assert_eq!(names, vec!["nested-skill"]); +} + +#[test] +fn readme_frontmatter_is_used_as_skill_metadata_fallback() { + let tmp = TempDir::new("liveagent-readme-frontmatter-test").expect("temp dir"); + let dir = tmp.path().join("readme-skill"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("README.md"), + "---\nname: readme-skill\ndescription: README metadata\n---\n\n# README Skill\n", + ) + .expect("write readme"); + + let metadata = read_skill_metadata_from_dir(&dir).expect("read metadata"); + assert_eq!(metadata.name, "readme-skill"); + assert_eq!(metadata.description, "README metadata"); + assert_eq!(metadata.metadata_file.file_name().unwrap(), "README.md"); + + let validation = validate_skill_dir(&dir); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn readme_without_frontmatter_derives_metadata_for_management() { + let tmp = TempDir::new("liveagent-plain-readme-test").expect("temp dir"); + let dir = tmp.path().join("plain-readme-skill"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("README.md"), + "# Plain README Skill\n\nFollow this README as the skill instructions.\n", + ) + .expect("write readme"); + + let raw_metadata = + read_skill_metadata_file(&dir.join("README.md")).expect("read raw metadata"); + assert!(raw_metadata.name.is_none()); + assert!(raw_metadata.description.is_none()); + + let metadata = read_skill_metadata_from_dir(&dir).expect("derive metadata"); + assert_eq!(metadata.name, "plain-readme-skill"); + assert_eq!(metadata.description, "Plain README Skill"); + + let validation = validate_skill_dir(&dir); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn readme_empty_frontmatter_derives_metadata_for_management() { + let tmp = TempDir::new("liveagent-empty-readme-frontmatter-test").expect("temp dir"); + let dir = tmp.path().join("empty-readme-metadata"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("README.md"), + "---\n---\n\n# Empty README Metadata\n\nUse the README content.\n", + ) + .expect("write readme"); + + let metadata = read_skill_metadata_from_dir(&dir).expect("derive metadata"); + assert_eq!(metadata.name, "empty-readme-metadata"); + assert_eq!(metadata.description, "Empty README Metadata"); + + let validation = validate_skill_dir(&dir); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn readme_partial_frontmatter_is_invalid_metadata() { + let tmp = TempDir::new("liveagent-partial-readme-frontmatter-test").expect("temp dir"); + let dir = tmp.path().join("partial-readme-metadata"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("README.md"), + "---\nname: partial-readme-metadata\n---\n\n# Partial README Metadata\n", + ) + .expect("write readme"); + + let error = read_skill_metadata_from_dir(&dir).expect_err("partial metadata must fail"); + assert!( + error.contains("Missing skill description"), + "unexpected error: {error}" + ); + + let validation = validate_skill_dir(&dir); + assert!(!validation.ok); + assert!( + validation + .errors + .iter() + .any(|error| error.contains("Missing 'description'")), + "{:?}", + validation.errors + ); +} + +#[test] +fn readme_inside_existing_skill_is_not_a_discovery_candidate() { + let tmp = TempDir::new("liveagent-nested-readme-discovery-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let skill_dir = write_skill(&root, "documented-skill", "Documented"); + let reference_dir = skill_dir.join("references"); + fs::create_dir_all(&reference_dir).expect("create references"); + let readme = reference_dir.join("README.md"); + fs::write(&readme, "# Reference README\n").expect("write nested readme"); + + assert!(!should_include_metadata_candidate(&root, &readme)); + assert!(should_include_metadata_candidate( + &root, + &skill_dir.join("SKILL.md") + )); +} + +#[test] +fn copy_skill_with_backup_preserves_existing_target() { + let tmp = TempDir::new("liveagent-skill-backup-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let source_a = tmp.path().join("source-a"); + let source_b = tmp.path().join("source-b"); + write_skill(&source_a, "sample-skill", "Old"); + write_skill(&source_b, "sample-skill", "New"); + + let first = copy_skill_with_conflict( + &source_a.join("sample-skill"), + &root, + "sample-skill", + "fail", + ) + .expect("first install"); + assert!(first.backup.is_none()); + + let second = copy_skill_with_conflict( + &source_b.join("sample-skill"), + &root, + "sample-skill", + "backup", + ) + .expect("second install"); + + assert!(second.backup.is_some()); + assert!(root.join(".backups").exists()); +} + +#[test] +fn builtin_seed_backs_up_invalid_target_before_writing() { + let tmp = TempDir::new("liveagent-builtin-seed-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let invalid_target = root.join("skills-installer"); + fs::create_dir_all(&invalid_target).expect("create invalid target"); + fs::write(invalid_target.join("SKILL.md"), "not valid frontmatter\n") + .expect("write invalid skill"); + + let seeded = ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); + let installer = seeded + .iter() + .find(|item| item.name == "skills-installer") + .expect("installer seed result"); + + assert_eq!(installer.action, "replaced_invalid"); + assert!(installer.backup.is_some()); + assert!(root.join(".backups").exists()); + let validation = validate_skill_dir(&root.join("skills-installer")); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn builtin_seed_updates_changed_valid_target_before_writing() { + let tmp = TempDir::new("liveagent-builtin-update-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let old_target = root.join("skills-creator"); + fs::create_dir_all(&old_target).expect("create old target"); + fs::write( + old_target.join("SKILL.md"), + "---\nname: skills-creator\ndescription: Old valid creator\n---\n\n# Old Creator\n\nUse the old workflow.\n", + ) + .expect("write old skill"); + + let seeded = ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); + let creator = seeded + .iter() + .find(|item| item.name == "skills-creator") + .expect("creator seed result"); + + assert_eq!(creator.action, "updated"); + assert!(creator.backup.is_some()); + let content = + fs::read_to_string(root.join("skills-creator").join("SKILL.md")).expect("read seeded"); + assert!( + content.contains("All generated skill documentation must be written in English only") + ); + let validation = validate_skill_dir(&root.join("skills-creator")); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn builtin_seed_removes_retired_builtin_files() { + let tmp = TempDir::new("liveagent-builtin-retired-file-test").expect("temp dir"); + let root = tmp.path().join("skills"); + + ensure_builtin_agent_skills_in_root(&root).expect("seed builtins"); + let creator_dir = root.join("skills-creator"); + let retired_script = creator_dir.join("scripts").join("old_helper.py"); + fs::create_dir_all(retired_script.parent().expect("script parent")) + .expect("create scripts"); + fs::write(&retired_script, "#!/usr/bin/env python3\nprint('old')\n") + .expect("write retired script"); + + let seeded = ensure_builtin_agent_skills_in_root(&root).expect("reseed builtins"); + let creator = seeded + .iter() + .find(|item| item.name == "skills-creator") + .expect("creator seed result"); + + assert_eq!(creator.action, "updated"); + assert!(creator.backup.is_some()); + assert!(!root.join("skills-creator").join("scripts").exists()); +} + +#[test] +fn list_installed_skills_skips_hidden_backup_dirs() { + let tmp = TempDir::new("liveagent-skill-list-test").expect("temp dir"); + let root = tmp.path().join("skills"); + write_skill(&root, "active-skill", "Active"); + write_skill(&root.join(".backups"), "backup-skill", "Backup"); + + let (skills, invalid) = list_installed_skills(&root).expect("list skills"); + + assert!(invalid.is_empty(), "{invalid:?}"); + assert_eq!( + skills + .iter() + .map(|skill| skill.name.as_str()) + .collect::>(), + vec!["active-skill"] + ); +} + +#[test] +fn install_source_from_local_skill_archive_installs_skill() { + let tmp = TempDir::new("liveagent-skill-archive-install-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let archive = tmp.path().join("archive-skill.skill"); + { + let file = fs::File::create(&archive).expect("archive file"); + let mut writer = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + writer + .start_file("archive-skill/SKILL.md", options) + .expect("start skill file"); + writer + .write_all( + b"---\nname: archive-skill\ndescription: Archive install\n---\n\n# Archive Skill\n", + ) + .expect("write skill file"); + writer.finish().expect("finish archive"); + } + let payload = json!({ + "source": archive.to_string_lossy(), + "conflict": "fail" + }); + let payload = payload.as_object().expect("payload object"); + + let installed = install_source_from_payload(&root, payload).expect("install archive"); + + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].name, "archive-skill"); + assert!(root.join("archive-skill").join("SKILL.md").is_file()); +} + +#[test] +fn clawhub_download_url_preserves_slug_and_tag_params() { + let url = clawhub_download_url_for_slug("owner/example-skill", Some("v1.2.3")) + .expect("download url"); + let parsed = reqwest::Url::parse(&url).expect("parse url"); + let pairs = parsed + .query_pairs() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect::>(); + + assert_eq!(parsed.scheme(), "https"); + assert_eq!(parsed.host_str(), Some("clawhub.ai")); + assert_eq!(parsed.path(), "/api/v1/download"); + assert_eq!( + pairs.get("slug").map(String::as_str), + Some("owner/example-skill") + ); + assert_eq!(pairs.get("tag").map(String::as_str), Some("v1.2.3")); +} + +#[test] +fn normalize_clawhub_skill_card_supports_search_shape() { + let raw = json!({ + "slug": "owner/example-skill", + "displayName": "Example Skill", + "summary": "Example summary", + "latestVersion": { "version": "1.0.0" }, + "stats": { + "downloads": 11, + "stars": 7, + "installsCurrent": 3 + }, + "updatedAt": 12345, + "owner": { "handle": "owner" } + }); + + let card = normalize_clawhub_skill_card(&raw).expect("normalize card"); + + assert_eq!(card.slug, "owner/example-skill"); + assert_eq!(card.display_name, "Example Skill"); + assert_eq!(card.latest_version.as_deref(), Some("1.0.0")); + assert_eq!(card.downloads, 11); + assert_eq!(card.stars, 7); + assert_eq!(card.installs_current, 3); + assert_eq!(card.owner_handle.as_deref(), Some("owner")); + assert!(card.download_url.contains("/api/v1/download")); +} + +#[test] +fn install_source_persists_clawhub_metadata_when_slug_is_present() { + let tmp = TempDir::new("liveagent-skill-clawhub-meta-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let source = tmp.path().join("source"); + write_skill(&source, "clawhub-skill", "ClawHub install"); + let payload = json!({ + "source": source.to_string_lossy(), + "conflict": "fail", + "slug": "owner/clawhub-skill", + "version": "1.0.0", + "publishedAt": 12345 + }); + let payload = payload.as_object().expect("payload object"); + + let installed = install_source_from_payload(&root, payload).expect("install skill"); + let source_metadata = + read_skill_source_metadata(&root.join(&installed[0].name)).expect("read source meta"); + + assert_eq!(source_metadata.registry, "clawhub"); + assert_eq!(source_metadata.slug, "owner/clawhub-skill"); + assert_eq!(source_metadata.version.as_deref(), Some("1.0.0")); + assert_eq!(source_metadata.published_at, Some(12345)); +} + +#[test] +fn validate_and_package_round_trip() { + let tmp = TempDir::new("liveagent-skill-package-test").expect("temp dir"); + let root = tmp.path().join("skills"); + write_skill(&root, "package-skill", "Package test"); + + let validation = + validate_installed_skill(&root, "package-skill").expect("validate installed skill"); + assert!(validation.ok, "{:?}", validation.errors); + + let package = package_installed_skill(&root, "package-skill").expect("package skill"); + assert!(Path::new(&package.archive).exists()); +} + +#[test] +fn delete_installed_skill_removes_user_skill() { + let tmp = TempDir::new("liveagent-skill-delete-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let skill_dir = write_skill(&root, "delete-skill", "Delete test"); + + let deleted = delete_installed_skill(&root, "delete-skill").expect("delete skill"); + + assert_eq!(deleted.name, "delete-skill"); + assert_eq!(deleted.target, display_path(&skill_dir)); + assert!(!skill_dir.exists()); +} + +#[test] +fn delete_installed_skill_rejects_builtin_skill() { + let tmp = TempDir::new("liveagent-skill-delete-builtin-test").expect("temp dir"); + let root = tmp.path().join("skills"); + write_skill(&root, "skills-installer", "Built-in replacement"); + + let error = + delete_installed_skill(&root, "skills-installer").expect_err("delete should fail"); + + assert!( + error.contains("cannot modify built-in Skill"), + "unexpected error: {error}" + ); + assert!(root.join("skills-installer").exists()); +} + +#[test] +fn delete_installed_skill_rejects_missing_skill() { + let tmp = TempDir::new("liveagent-skill-delete-missing-test").expect("temp dir"); + let root = tmp.path().join("skills"); + + let error = delete_installed_skill(&root, "missing-skill").expect_err("delete should fail"); + + assert!( + error.contains("does not exist") || error.contains("cannot be inspected"), + "unexpected error: {error}" + ); +} + +#[test] +fn delete_installed_skill_rejects_non_directory_target() { + let tmp = TempDir::new("liveagent-skill-delete-file-test").expect("temp dir"); + let root = tmp.path().join("skills"); + fs::create_dir_all(&root).expect("create skills root"); + let file = root.join("file-skill"); + fs::write(&file, "not a directory").expect("write file target"); + + let error = delete_installed_skill(&root, "file-skill").expect_err("delete should fail"); + + assert!( + error.contains("requires an installed Skill directory"), + "unexpected error: {error}" + ); + assert!(file.exists()); +} + +#[test] +fn validate_allows_nested_metadata_frontmatter() { + let tmp = TempDir::new("liveagent-skill-frontmatter-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let dir = root.join("metadata-skill"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + "---\nname: metadata-skill\ndescription: Metadata test\nmetadata:\n short-description: Nested metadata\n---\n\n# Metadata Skill\n", + ) + .expect("write skill"); + + let validation = validate_installed_skill(&root, "metadata-skill").expect("validate skill"); + + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn validate_allows_single_line_frontmatter() { + let tmp = TempDir::new("liveagent-skill-inline-frontmatter-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let dir = root.join("security-threat-model"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + "--- name: security-threat-model description: Develop threat models and security analysis for software systems --- Use this skill when reviewing security risks.\n", + ) + .expect("write skill"); + + let metadata = read_skill_metadata_from_dir(&dir).expect("read metadata"); + assert_eq!(metadata.name, "security-threat-model"); + assert_eq!( + metadata.description, + "Develop threat models and security analysis for software systems" + ); + + let validation = + validate_installed_skill(&root, "security-threat-model").expect("validate skill"); + assert!(validation.ok, "{:?}", validation.errors); +} + +#[test] +fn validate_rejects_non_english_markdown_documentation() { + let tmp = TempDir::new("liveagent-skill-english-doc-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let dir = root.join("english-only-skill"); + fs::create_dir_all(&dir).expect("create skill dir"); + fs::write( + dir.join("SKILL.md"), + "---\nname: english-only-skill\ndescription: English documentation test\n---\n\n# English Only Skill\n\nTranslate \u{4E2D} before saving the skill.\n", + ) + .expect("write skill"); + + let validation = + validate_installed_skill(&root, "english-only-skill").expect("validate skill"); + + assert!(!validation.ok); + assert!( + validation + .errors + .iter() + .any(|error| error.contains("English only")), + "{:?}", + validation.errors + ); +} + +#[test] +fn create_skill_rejects_non_english_body() { + let tmp = TempDir::new("liveagent-skill-create-english-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let payload = json!({ + "name": "english-create-skill", + "description": "Create only English documentation", + "body": "# English Create Skill\n\nTranslate \u{4E2D} before writing docs.", + "conflict": "fail" + }); + let payload = payload.as_object().expect("payload object"); + + let error = create_skill_from_payload(&root, payload).expect_err("create should fail"); + + assert!(error.contains("English only"), "unexpected error: {error}"); +} + +#[test] +fn create_skill_rejects_builtin_skill_names() { + let tmp = TempDir::new("liveagent-skill-create-builtin-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let payload = json!({ + "name": "skills-creator", + "description": "Attempt to replace built-in creator", + "body": "# Replacement\n\nDo not allow this.", + "conflict": "overwrite" + }); + let payload = payload.as_object().expect("payload object"); + + let error = create_skill_from_payload(&root, payload).expect_err("create should fail"); + + assert!( + error.contains("cannot modify built-in Skill"), + "unexpected error: {error}" + ); +} + +#[test] +fn install_source_rejects_builtin_skill_names() { + let tmp = TempDir::new("liveagent-skill-install-builtin-test").expect("temp dir"); + let root = tmp.path().join("skills"); + let source = tmp.path().join("source"); + write_skill(&source, "skills-installer", "Replacement"); + let payload = json!({ + "source": source.to_string_lossy(), + "conflict": "overwrite" + }); + let payload = payload.as_object().expect("payload object"); + + let error = install_source_from_payload(&root, payload).expect_err("install should fail"); + + assert!( + error.contains("cannot modify built-in Skill"), + "unexpected error: {error}" + ); +} + +#[test] +fn safe_extract_zip_rejects_parent_traversal() { + let tmp = TempDir::new("liveagent-skill-zip-test").expect("temp dir"); + let archive = tmp.path().join("bad.skill"); + { + let file = fs::File::create(&archive).expect("archive file"); + let mut writer = ZipWriter::new(file); + let options = FileOptions::default().compression_method(CompressionMethod::Deflated); + writer + .start_file("../evil.txt", options) + .expect("start unsafe file"); + writer.write_all(b"bad").expect("write unsafe file"); + writer.finish().expect("finish archive"); + } + + let error = safe_extract_zip(&archive, &tmp.path().join("out")) + .expect_err("zip slip should be rejected"); + assert!(error.contains("escapes") || error.contains("unsafe")); +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/types.rs b/crates/agent-gui/src-tauri/src/services/skills/types.rs new file mode 100644 index 00000000..ea706395 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/types.rs @@ -0,0 +1,176 @@ +//! Skills 模块对外响应 DTO 与内部数据类型。 + +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemListSkillFilesResponse { + pub root_dir: String, + pub paths: Vec, + pub truncated: bool, +} + +#[derive(Debug, Serialize)] +pub struct SystemReadSkillTextResponse { + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemReadSkillMetadataResponse { + pub name: Option, + pub description: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillSourceMetadata { + pub registry: String, + pub slug: String, + pub version: Option, + pub published_at: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemClawHubSkillCard { + pub slug: String, + pub display_name: String, + pub summary: String, + pub latest_version: Option, + pub downloads: u64, + pub stars: u64, + pub installs_current: u64, + pub updated_at: Option, + pub owner_handle: Option, + pub web_url: Option, + pub download_url: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillSummary { + pub name: String, + pub description: String, + pub target: String, + pub skill_file: String, + pub base_dir: String, + pub source: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillInvalidEntry { + pub path: String, + pub error: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillInstallResult { + pub name: String, + pub target: String, + pub backup: Option, + pub skill_file: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillValidationResponse { + pub name: String, + pub target: String, + pub ok: bool, + pub errors: Vec, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillPackageResponse { + pub name: String, + pub target: String, + pub archive: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillDeleteResponse { + pub name: String, + pub target: String, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemBuiltinSkillSeedResponse { + pub name: String, + pub target: String, + pub action: String, + pub backup: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SystemSkillInstallJobSnapshot { + pub job_id: String, + pub phase: String, + pub source: String, + pub label: Option, + pub slug: Option, + pub version: Option, + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub message: Option, + pub error: Option, + pub installed: Option>, + pub started_at: u64, + pub updated_at: u64, + pub finished_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SystemManageSkillResponse { + pub action: String, + pub root_dir: String, + pub path: Option, + pub content: Option, + pub truncated: Option, + pub start_line: Option, + pub num_lines: Option, + pub skills: Option>, + pub invalid: Option>, + pub installed: Option>, + pub created: Option, + pub validation: Option, + pub package: Option, + pub deleted: Option, + pub seeded: Option>, + pub install_job: Option, + pub clawhub_results: Option>, + pub clawhub_next_cursor: Option, + pub clawhub_slug: Option, + pub clawhub_download_url: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SkillMetadata { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) metadata_file: PathBuf, +} + +#[derive(Debug, Clone)] +pub(crate) struct GithubSource { + pub(crate) owner: String, + pub(crate) repo: String, + pub(crate) git_ref: String, + pub(crate) subpath: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct SkillValidationResult { + pub(crate) ok: bool, + pub(crate) errors: Vec, + pub(crate) metadata: Option, +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/util.rs b/crates/agent-gui/src-tauri/src/services/skills/util.rs new file mode 100644 index 00000000..1138770b --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/util.rs @@ -0,0 +1,65 @@ +//! 通用工具:临时目录、时间戳与 SkillsManager payload 字段读取。 + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) struct TempDir { + path: PathBuf, +} + +impl TempDir { + pub(crate) fn new(prefix: &str) -> Result { + let base = std::env::temp_dir(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = base.join(format!("{prefix}-{}-{now}", std::process::id())); + fs::create_dir_all(&path) + .map_err(|e| format!("Failed to create temporary directory: {e}"))?; + Ok(Self { path }) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +pub(crate) fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +pub(crate) fn strip_utf8_bom(input: &str) -> &str { + input.strip_prefix('\u{feff}').unwrap_or(input) +} + +pub(crate) fn object_string<'a>( + payload: &'a serde_json::Map, + key: &str, +) -> Option<&'a str> { + payload + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn object_usize(payload: &serde_json::Map, key: &str) -> Option { + payload + .get(key) + .and_then(Value::as_u64) + .and_then(|value| usize::try_from(value).ok()) +} diff --git a/crates/agent-gui/src-tauri/src/services/skills/validate.rs b/crates/agent-gui/src-tauri/src/services/skills/validate.rs new file mode 100644 index 00000000..85950abe --- /dev/null +++ b/crates/agent-gui/src-tauri/src/services/skills/validate.rs @@ -0,0 +1,335 @@ +//! Skill 校验:目录结构、元数据约束与英文文档检查。 + +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use walkdir::WalkDir; + +use super::*; + +const NON_ENGLISH_SCRIPT_RANGES: &[(u32, u32)] = &[ + (0x0370, 0x03FF), // Greek + (0x0400, 0x052F), // Cyrillic + (0x0590, 0x05FF), // Hebrew + (0x0600, 0x06FF), // Arabic + (0x0750, 0x077F), // Arabic Supplement + (0x08A0, 0x08FF), // Arabic Extended-A + (0x0900, 0x0DFF), // Indic scripts + (0x0E00, 0x0E7F), // Thai + (0x0E80, 0x0EFF), // Lao + (0x0F00, 0x0FFF), // Tibetan + (0x1000, 0x109F), // Myanmar + (0x10A0, 0x10FF), // Georgian + (0x1100, 0x11FF), // Hangul Jamo + (0x1780, 0x17FF), // Khmer + (0x3040, 0x30FF), // Hiragana and Katakana + (0x3130, 0x318F), // Hangul Compatibility Jamo + (0x31F0, 0x31FF), // Katakana Phonetic Extensions + (0x3400, 0x4DBF), // CJK Extension A + (0x4E00, 0x9FFF), // CJK Unified Ideographs + (0xAC00, 0xD7AF), // Hangul Syllables + (0xF900, 0xFAFF), // CJK Compatibility Ideographs + (0xFF00, 0xFFEF), // Halfwidth and Fullwidth Forms + (0x20000, 0x2FA1F), // CJK Extensions and compatibility supplements +]; + +pub(crate) fn is_non_english_script_char(ch: char) -> bool { + let codepoint = ch as u32; + NON_ENGLISH_SCRIPT_RANGES + .iter() + .any(|(start, end)| codepoint >= *start && codepoint <= *end) +} + +pub(crate) fn first_non_english_script_char(content: &str) -> Option { + content.chars().find(|ch| is_non_english_script_char(*ch)) +} + +pub(crate) fn is_markdown_document(rel: &Path) -> bool { + let is_markdown = rel + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("md")) + .unwrap_or(false); + if !is_markdown { + return false; + } + rel.components().count() == 1 || rel.starts_with("references") +} + +pub(crate) fn validate_english_markdown_document( + path: &Path, + rel: &Path, + errors: &mut Vec, +) { + let content = match fs::read_to_string(path) { + Ok(content) => content, + Err(error) => { + errors.push(format!( + "Failed to read Markdown documentation {}: {error}", + rel.to_string_lossy() + )); + return; + } + }; + if let Some(ch) = first_non_english_script_char(&content) { + errors.push(format!( + "Markdown documentation must be written in English only: {} contains non-English script character U+{:04X}", + rel.to_string_lossy(), + ch as u32 + )); + } +} + +pub(crate) fn validate_skill_dir(skill_dir: &Path) -> SkillValidationResult { + let mut errors = Vec::new(); + if !skill_dir.exists() { + return SkillValidationResult { + ok: false, + errors: vec![format!( + "Skill directory not found: {}", + skill_dir.display() + )], + metadata: None, + }; + } + if !skill_dir.is_dir() { + return SkillValidationResult { + ok: false, + errors: vec![format!("Path is not a directory: {}", skill_dir.display())], + metadata: None, + }; + } + + let skill_md = skill_dir.join("SKILL.md"); + let metadata_file = if skill_md.is_file() { + skill_md + } else { + match metadata_file_for(skill_dir) { + Some(path) => path, + None => { + return SkillValidationResult { + ok: false, + errors: vec![ + "SKILL.md, skill.md, skill.json, or README.md not found".to_string() + ], + metadata: None, + }; + } + } + }; + + let mut metadata = None; + let mut metadata_from_plain_readme = false; + if is_skill_json(&metadata_file) { + match fs::read_to_string(&metadata_file) + .map_err(|e| e.to_string()) + .and_then(|content| { + let parsed = parse_skill_json_metadata(&content); + let name = parsed + .name + .ok_or_else(|| "Missing 'name' in skill.json".to_string())?; + let description = parsed + .description + .ok_or_else(|| "Missing 'description' in skill.json".to_string())?; + Ok(SkillMetadata { + name, + description, + metadata_file: metadata_file.clone(), + }) + }) { + Ok(value) => metadata = Some(value), + Err(error) => errors.push(error), + } + } else { + match fs::read_to_string(&metadata_file) + .map_err(|e| format!("Failed to read {}: {e}", metadata_file.display())) + .and_then(|content| { + let frontmatter = split_frontmatter(&content); + let (yaml, has_frontmatter) = match frontmatter { + Ok((yaml, _body)) => (Some(yaml), true), + Err(error) + if is_readme_markdown(&metadata_file) + && is_missing_frontmatter_error(&error) => + { + (None, false) + } + Err(error) => return Err(error), + }; + + if let Some(yaml) = yaml { + let keys = frontmatter_keys(&yaml); + let allowed = [ + "name", + "description", + "license", + "allowed-tools", + "metadata", + ]; + let unexpected = keys + .iter() + .filter(|key| !allowed.contains(&key.as_str())) + .cloned() + .collect::>(); + if !unexpected.is_empty() { + errors.push(format!( + "Unexpected key(s) in Skill frontmatter: {}", + unexpected.join(", ") + )); + } + let parsed = parse_skill_frontmatter_yaml_metadata(&yaml); + if is_readme_markdown(&metadata_file) + && parsed.name.is_none() + && parsed.description.is_none() + { + metadata_from_plain_readme = true; + let name = fallback_readme_skill_name(skill_dir)?; + let description = first_readme_description_line(&content) + .unwrap_or_else(|| format!("README.md skill instructions for {name}")) + .chars() + .take(MAX_SKILL_DESCRIPTION_LENGTH) + .collect(); + return Ok(SkillMetadata { + name, + description, + metadata_file: metadata_file.clone(), + }); + } + let name = parsed + .name + .ok_or_else(|| "Missing 'name' in frontmatter".to_string())?; + let description = parsed + .description + .ok_or_else(|| "Missing 'description' in frontmatter".to_string())?; + return Ok(SkillMetadata { + name, + description, + metadata_file: metadata_file.clone(), + }); + } + + if !has_frontmatter && is_readme_markdown(&metadata_file) { + metadata_from_plain_readme = true; + let name = fallback_readme_skill_name(skill_dir)?; + let description = first_readme_description_line(&content) + .unwrap_or_else(|| format!("README.md skill instructions for {name}")) + .chars() + .take(MAX_SKILL_DESCRIPTION_LENGTH) + .collect(); + return Ok(SkillMetadata { + name, + description, + metadata_file: metadata_file.clone(), + }); + } + + Err("Missing Skill metadata".to_string()) + }) { + Ok(value) => metadata = Some(value), + Err(error) => errors.push(error), + } + } + + if let Some(metadata) = metadata.as_ref() { + if let Err(error) = sanitize_skill_name(&metadata.name) { + errors.push(error); + } + if metadata.description.contains('<') || metadata.description.contains('>') { + errors.push("Description cannot contain angle brackets (< or >)".to_string()); + } + if let Some(ch) = first_non_english_script_char(&metadata.description) { + errors.push(format!( + "Description must be written in English only; found non-English script character U+{:04X}", + ch as u32 + )); + } + if metadata.description.len() > MAX_SKILL_DESCRIPTION_LENGTH { + errors.push(format!( + "Description is too long; maximum is {MAX_SKILL_DESCRIPTION_LENGTH}" + )); + } + let dir_name = skill_dir + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(); + if !metadata_from_plain_readme && dir_name != metadata.name { + errors.push(format!( + "Directory name '{dir_name}' must match frontmatter name '{}'", + metadata.name + )); + } + } + + for entry in WalkDir::new(skill_dir).follow_links(false).min_depth(1) { + let Ok(entry) = entry else { + continue; + }; + if entry.file_type().is_symlink() { + errors.push(format!( + "Symlink is not allowed inside a Skill: {}", + entry.path().display() + )); + continue; + } + if !entry.file_type().is_file() { + continue; + } + let rel = entry.path().strip_prefix(skill_dir).unwrap_or(entry.path()); + if matches!( + entry.file_name().to_string_lossy().as_ref(), + "README.md" | "INSTALLATION_GUIDE.md" | "QUICK_REFERENCE.md" | "CHANGELOG.md" + ) && entry.path() != metadata_file + { + errors.push(format!( + "Forbidden documentation file found: {}", + rel.to_string_lossy() + )); + } + if is_markdown_document(rel) { + validate_english_markdown_document(entry.path(), rel, &mut errors); + } + let ext = entry + .path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()); + if matches!(ext.as_deref(), Some("py" | "sh" | "bash")) { + match fs::File::open(entry.path()) { + Ok(file) => { + let mut reader = BufReader::new(file); + let mut line = String::new(); + if reader.read_line(&mut line).is_ok() && !line.starts_with("#!") { + errors.push(format!( + "Script file lacks a shebang: {}", + rel.to_string_lossy() + )); + } + } + Err(error) => errors.push(format!( + "Failed to inspect script file {}: {error}", + rel.to_string_lossy() + )), + } + } + } + + SkillValidationResult { + ok: errors.is_empty(), + errors, + metadata, + } +} + +pub(crate) fn validate_installed_skill( + root: &Path, + name: &str, +) -> Result { + let name = sanitize_skill_name(name)?; + let target = root.join(&name); + let validation = validate_skill_dir(&target); + Ok(SystemSkillValidationResponse { + name, + target: display_path(&target), + ok: validation.ok, + errors: validation.errors, + }) +} From c483c0250750e2e5492d905dc6c41bcf104bd5df Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 10:33:56 +0800 Subject: [PATCH 37/64] fix(project-tools): keep new terminals active after creation --- .../project-tools/useRightDockSessions.ts | 38 ++++++++++++++++++- .../project-tools/useRightDockSessions.ts | 38 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/agent-gateway/web/src/components/project-tools/useRightDockSessions.ts b/crates/agent-gateway/web/src/components/project-tools/useRightDockSessions.ts index e4eb27b7..9b9936dc 100644 --- a/crates/agent-gateway/web/src/components/project-tools/useRightDockSessions.ts +++ b/crates/agent-gateway/web/src/components/project-tools/useRightDockSessions.ts @@ -19,6 +19,8 @@ import { terminalSessionBelongsToProject, } from "./rightDockModel"; +const PENDING_CREATE_ACTIVATION_TIMEOUT_MS = 15_000; + type UseRightDockSessionsOptions = { client: TerminalClient; cwd: string; @@ -55,6 +57,10 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { const [shellOptions, setShellOptions] = useState([]); const initialTerminalSnapshotsRef = useRef>(new Map()); const lastProjectPathKeyRef = useRef(projectPathKey); + const pendingCreateActivationRef = useRef<{ + knownSessionIds: Set; + requestedAt: number; + } | null>(null); const isControlled = externalSessions !== undefined; const localSessions = useMemo( () => @@ -112,12 +118,16 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { if (current.activeTabId === session.id && tabs === current.tabs && tabOrder === current.tabOrder) { return current; } + // +2, not +1: while this client activates, every other client's + // passive reconcile bumps its copy by 1 from the same base version. + // mergeSyncedRightDockSettings resolves ties toward the incoming + // side, so a tied stale echo would bounce the fresh activation back. return { ...current, activeTabId: session.id, tabOrder, tabs, - stateVersion: current.stateVersion + 1, + stateVersion: current.stateVersion + 2, }; }); }, @@ -270,6 +280,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { if (lastProjectPathKeyRef.current === projectPathKey) return; lastProjectPathKeyRef.current = projectPathKey; initialTerminalSnapshotsRef.current.clear(); + pendingCreateActivationRef.current = null; setPendingCloseSessionId(""); setClosingSessionId(""); }, [projectPathKey]); @@ -281,6 +292,25 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { } }, [localSessions, pendingCloseSessionId]); + useEffect(() => { + // A terminal created by this client can surface through the shared session + // list instead of the create response (created broadcast racing the RPC, or + // the response lost to socket recovery) — still switch the dock to it. + const pending = pendingCreateActivationRef.current; + if (!pending) return; + if (Date.now() - pending.requestedAt > PENDING_CREATE_ACTIVATION_TIMEOUT_MS) { + pendingCreateActivationRef.current = null; + return; + } + const createdSession = localSessions + .filter((session) => !pending.knownSessionIds.has(session.id)) + .sort((a, b) => b.createdAt - a.createdAt)[0]; + if (!createdSession) return; + pendingCreateActivationRef.current = null; + setError(null); + activateTerminalSession(createdSession); + }, [activateTerminalSession, localSessions]); + const rememberTerminalSnapshot = useCallback( (snapshot: TerminalSnapshot) => { initialTerminalSnapshotsRef.current.set(snapshot.session.id, snapshot); @@ -338,6 +368,10 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { if (!terminalReady || creating) return; setCreating(true); setError(null); + pendingCreateActivationRef.current = { + knownSessionIds: new Set(localSessions.map((session) => session.id)), + requestedAt: Date.now(), + }; void client .create({ cwd, @@ -347,6 +381,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { rows: DEFAULT_TERMINAL_ROWS, }) .then((snapshot) => { + pendingCreateActivationRef.current = null; rememberTerminalSnapshot(snapshot); activateTerminalSession(snapshot.session); }) @@ -358,6 +393,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { client, creating, cwd, + localSessions, projectPathKey, rememberTerminalSnapshot, terminalReady, diff --git a/crates/agent-gui/src/components/project-tools/useRightDockSessions.ts b/crates/agent-gui/src/components/project-tools/useRightDockSessions.ts index c012e1b0..04c16636 100644 --- a/crates/agent-gui/src/components/project-tools/useRightDockSessions.ts +++ b/crates/agent-gui/src/components/project-tools/useRightDockSessions.ts @@ -16,6 +16,8 @@ import { terminalSessionBelongsToProject, } from "./rightDockModel"; +const PENDING_CREATE_ACTIVATION_TIMEOUT_MS = 15_000; + type UseRightDockSessionsOptions = { client: TerminalClient; cwd: string; @@ -52,6 +54,10 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { const [shellOptions, setShellOptions] = useState([]); const initialTerminalSnapshotsRef = useRef>(new Map()); const lastProjectPathKeyRef = useRef(projectPathKey); + const pendingCreateActivationRef = useRef<{ + knownSessionIds: Set; + requestedAt: number; + } | null>(null); const isControlled = externalSessions !== undefined; const localSessions = useMemo( () => @@ -113,12 +119,16 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { ) { return current; } + // +2, not +1: while this client activates, every other client's + // passive reconcile bumps its copy by 1 from the same base version. + // mergeSyncedRightDockSettings resolves ties toward the incoming + // side, so a tied stale echo would bounce the fresh activation back. return { ...current, activeTabId: session.id, tabOrder, tabs, - stateVersion: current.stateVersion + 1, + stateVersion: current.stateVersion + 2, }; }); }, @@ -271,6 +281,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { if (lastProjectPathKeyRef.current === projectPathKey) return; lastProjectPathKeyRef.current = projectPathKey; initialTerminalSnapshotsRef.current.clear(); + pendingCreateActivationRef.current = null; setPendingCloseSessionId(""); setClosingSessionId(""); }, [projectPathKey]); @@ -282,6 +293,25 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { } }, [localSessions, pendingCloseSessionId]); + useEffect(() => { + // A terminal created by this client can surface through the shared session + // list instead of the create response (created broadcast racing the RPC, or + // the response lost to socket recovery) — still switch the dock to it. + const pending = pendingCreateActivationRef.current; + if (!pending) return; + if (Date.now() - pending.requestedAt > PENDING_CREATE_ACTIVATION_TIMEOUT_MS) { + pendingCreateActivationRef.current = null; + return; + } + const createdSession = localSessions + .filter((session) => !pending.knownSessionIds.has(session.id)) + .sort((a, b) => b.createdAt - a.createdAt)[0]; + if (!createdSession) return; + pendingCreateActivationRef.current = null; + setError(null); + activateTerminalSession(createdSession); + }, [activateTerminalSession, localSessions]); + const rememberTerminalSnapshot = useCallback( (snapshot: TerminalSnapshot) => { initialTerminalSnapshotsRef.current.set(snapshot.session.id, snapshot); @@ -339,6 +369,10 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { if (!terminalReady || creating) return; setCreating(true); setError(null); + pendingCreateActivationRef.current = { + knownSessionIds: new Set(localSessions.map((session) => session.id)), + requestedAt: Date.now(), + }; void client .create({ cwd, @@ -348,6 +382,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { rows: DEFAULT_TERMINAL_ROWS, }) .then((snapshot) => { + pendingCreateActivationRef.current = null; rememberTerminalSnapshot(snapshot); activateTerminalSession(snapshot.session); }) @@ -359,6 +394,7 @@ export function useRightDockSessions(options: UseRightDockSessionsOptions) { client, creating, cwd, + localSessions, projectPathKey, rememberTerminalSnapshot, terminalReady, From 94df186723cdc099c5c07dcfd9dffad6b621dc45 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sat, 4 Jul 2026 11:49:00 +0800 Subject: [PATCH 38/64] chore(mirror): establish shared lint/format baseline and mirror check - Add biome to gateway WebUI (same config as GUI) and format all web sources; fix the lint errors surfaced on both ends (unused imports, assignments in expressions, useless switch case, optional chain) - Align both biome configs: legacy a11y rules downgraded to warn, CSS progressive-enhancement fallbacks exempted from noDuplicateProperties - Add scripts/check-mirror.mjs + scripts/mirror-manifest.json to enforce byte-identical GUI/WebUI mirrored files - CI: lint both frontends, typecheck GUI build, run mirror check Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 25 + crates/agent-gateway/web/biome.json | 79 + crates/agent-gateway/web/package.json | 6 +- crates/agent-gateway/web/pnpm-lock.yaml | 95 + .../web/src/app/FileDropOverlay.tsx | 4 +- .../agent-gateway/web/src/app/GatewayApp.tsx | 303 ++- .../web/src/app/WorkspaceOverlayHost.tsx | 4 +- crates/agent-gateway/web/src/app/chatDraft.ts | 2 +- .../agent-gateway/web/src/app/historyUtils.ts | 27 +- .../src/app/hooks/useGatewaySettingsSync.ts | 11 +- .../web/src/app/hooks/usePendingUploads.ts | 8 +- crates/agent-gateway/web/src/app/types.ts | 2 +- .../web/src/components/GatewayTranscript.tsx | 164 +- .../web/src/components/Markdown.tsx | 27 +- .../components/chat/ChatHistorySidebar.tsx | 25 +- .../src/components/chat/HistoryShareModal.tsx | 22 +- .../src/components/chat/MentionComposer.tsx | 1391 +++++++------- .../chat/SharedHistoryManagerModal.tsx | 26 +- .../web/src/components/chat/fileTypeIcons.tsx | 1664 ++++++++--------- .../src/components/git/GitBranchSelector.tsx | 160 +- .../web/src/components/hub/HubChrome.tsx | 8 +- .../web/src/components/icons.tsx | 28 +- .../project-tools/GitReviewPanel.tsx | 591 +++--- .../project-tools/LocalTunnelPanel.tsx | 39 +- .../project-tools/ProjectFileTreePanel.tsx | 15 +- .../project-tools/RightDockContent.tsx | 24 +- .../project-tools/RightDockLauncher.tsx | 9 +- .../project-tools/RightDockTabStrip.tsx | 12 +- .../project-tools/SshTunnelPanel.tsx | 24 +- .../project-tools/XTermViewport.tsx | 17 +- .../project-tools/rightDockModel.ts | 11 +- .../project-tools/useRightDockProjectTabs.ts | 17 +- .../project-tools/useRightDockSessions.ts | 11 +- .../project-tools/useRightDockTabReorder.tsx | 53 +- .../web/src/components/ui/button.tsx | 17 +- .../components/ui/confirm-action-popover.tsx | 5 +- .../web/src/components/ui/dropdown-menu.tsx | 5 +- .../web/src/components/ui/label.tsx | 6 +- .../web/src/components/ui/scroll-area.tsx | 5 +- .../web/src/components/ui/select.tsx | 18 +- .../components/useGatewayScrollAffordance.ts | 47 +- .../WorkspaceCodeEditorOverlay.tsx | 18 +- .../WorkspaceFilePreviewOverlay.tsx | 4 +- .../workspace-editor/WorkspaceSftpPanel.tsx | 901 +++++---- .../WorkspaceSshTerminalOverlay.tsx | 17 +- .../web/src/i18n/LocaleContext.tsx | 2 +- crates/agent-gateway/web/src/i18n/config.ts | 36 +- crates/agent-gateway/web/src/index.css | 528 ++++-- .../web/src/lib/chat/chatPageHelpers.ts | 8 +- .../web/src/lib/chat/hostedSearch.ts | 37 +- .../lib/chat/stream/chatCommandPipeline.ts | 8 +- .../chat/stream/conversationStreamClient.ts | 6 +- .../lib/chat/stream/useConversationChat.ts | 4 +- .../lib/chat/transcript/historyAlignment.ts | 3 +- .../web/src/lib/chat/transcript/rows.ts | 25 +- .../lib/chat/transcript/transcriptStore.ts | 6 +- .../src/lib/chat/transcript/turnReducer.ts | 17 +- .../web/src/lib/chat/transcript/types.ts | 4 +- .../web/src/lib/chat/uiMessages.ts | 519 +++-- .../web/src/lib/chat/uploadedFiles.ts | 5 +- .../web/src/lib/chat/uploadedImagePreview.ts | 10 +- .../web/src/lib/chat/userMessageContent.tsx | 124 +- crates/agent-gateway/web/src/lib/chatUi.ts | 51 +- .../web/src/lib/clipboardFiles.ts | 21 +- .../agent-gateway/web/src/lib/gatewayAuth.ts | 4 +- .../web/src/lib/gatewaySocket.ts | 91 +- .../agent-gateway/web/src/lib/gatewayTypes.ts | 2 +- .../web/src/lib/git/gatewayGitClient.ts | 5 +- .../agent-gateway/web/src/lib/git/gitGraph.ts | 11 +- crates/agent-gateway/web/src/lib/git/types.ts | 8 +- .../web/src/lib/historyParser.ts | 5 +- .../web/src/lib/historyParser.worker.ts | 2 +- .../agent-gateway/web/src/lib/historyShare.ts | 5 +- .../agent-gateway/web/src/lib/historySync.ts | 22 +- .../web/src/lib/mcpRegistry/index.ts | 271 +-- .../agent-gateway/web/src/lib/memory/api.ts | 8 +- .../web/src/lib/memory/organizerProtocol.ts | 78 +- crates/agent-gateway/web/src/lib/monacoNls.ts | 4 +- .../web/src/lib/providers/proxy.ts | 4 +- .../web/src/lib/settings/index.ts | 47 +- .../web/src/lib/settings/sync.ts | 118 +- .../agent-gateway/web/src/lib/shared/utils.ts | 2 +- .../web/src/lib/skills/builtin.ts | 4 +- .../web/src/lib/skills/clawHub.ts | 14 +- .../agent-gateway/web/src/lib/skills/index.ts | 72 +- crates/agent-gateway/web/src/lib/ssh/scan.ts | 10 +- .../terminal/gatewayTerminalStreamClient.ts | 87 +- .../web/src/lib/terminal/sessionStore.ts | 5 +- .../web/src/lib/terminal/types.ts | 5 +- .../web/src/lib/tools/builtinTypes.ts | 11 +- .../web/src/lib/tools/customSystemTools.ts | 10 +- .../web/src/lib/tools/systemToolOptions.ts | 5 +- .../web/src/lib/uploadReadableFiles.ts | 10 +- .../agent-gateway/web/src/lib/webSettings.ts | 6 +- .../web/src/lib/workspaceProjects.ts | 24 +- .../agent-gateway/web/src/pages/LoginPage.tsx | 24 +- .../web/src/pages/SettingsPage.tsx | 21 +- .../web/src/pages/SharedHistoryPage.tsx | 9 +- .../web/src/pages/StatusDashboardPage.tsx | 230 ++- .../web/src/pages/chat/AssistantBubble.tsx | 490 +++-- .../web/src/pages/chat/ChatComposerBar.tsx | 37 +- .../web/src/pages/chat/ChatHeader.tsx | 27 +- .../web/src/pages/chat/useChatSkills.ts | 84 +- .../web/src/pages/mcp-hub/McpHubPage.tsx | 15 +- .../src/pages/mcp-hub/McpRegistryBrowser.tsx | 186 +- .../web/src/pages/mcp-hub/McpServersForm.tsx | 46 +- .../settings/AgentPromptTemplateModal.tsx | 2 +- .../web/src/pages/settings/AgentsSection.tsx | 22 +- .../web/src/pages/settings/CronSection.tsx | 39 +- .../web/src/pages/settings/CronTaskModal.tsx | 115 +- .../src/pages/settings/CronTaskViewModal.tsx | 36 +- .../web/src/pages/settings/HookModal.tsx | 28 +- .../web/src/pages/settings/HooksSection.tsx | 40 +- .../web/src/pages/settings/MemoryPanel.tsx | 1464 ++++++++------- .../src/pages/settings/ProvidersSection.tsx | 38 +- .../web/src/pages/settings/RemoteSection.tsx | 16 +- .../src/pages/settings/SkillsSettingsForm.tsx | 15 +- .../web/src/pages/settings/SshSection.tsx | 15 +- .../src/pages/settings/SystemSettingsForm.tsx | 23 +- .../src/pages/settings/WorkdirPickerModal.tsx | 89 +- .../web/src/pages/settings/hookUtils.ts | 14 +- .../web/src/pages/settings/shared.tsx | 5 +- .../web/src/pages/settings/taskConfigUtils.ts | 9 +- .../src/pages/skills-hub/SkillsHubPage.tsx | 661 +++---- .../agent-gateway/web/src/shims/tauriCore.ts | 23 +- .../agent-gateway/web/src/shims/tauriEvent.ts | 16 +- crates/agent-gateway/web/src/styles.css | 263 ++- crates/agent-gui/biome.json | 30 +- crates/agent-gui/src/App.tsx | 2 +- crates/agent-gui/src/components/Markdown.tsx | 5 +- .../project-tools/LocalTunnelPanel.tsx | 39 +- .../project-tools/XTermViewport.tsx | 10 +- .../WorkspaceFilePreviewOverlay.tsx | 2 +- .../WorkspaceSshTerminalOverlay.tsx | 9 +- crates/agent-gui/src/index.css | 1 - .../chat/conversation/conversationState.ts | 4 +- .../src/lib/terminal/tauriTerminalClient.ts | 22 +- crates/agent-gui/src/lib/terminal/types.ts | 5 +- .../src/lib/tools/sshManagerTools.ts | 4 +- crates/agent-gui/src/pages/ChatPage.tsx | 22 +- crates/agent-gui/src/pages/SettingsPage.tsx | 13 +- .../src/pages/chat/components/ChatHeader.tsx | 2 +- .../assistant-bubble/ToolResultDisplay.tsx | 4 +- .../chat/gateway/useGatewayBridgeListeners.ts | 184 +- .../src/pages/chat/queue/chatTurnQueue.ts | 2 +- .../src/pages/settings/AgentsSection.tsx | 1 - .../src/pages/settings/SystemSettingsForm.tsx | 2 +- scripts/check-mirror.mjs | 61 + scripts/mirror-manifest.json | 6 + 149 files changed, 6821 insertions(+), 6022 deletions(-) create mode 100644 crates/agent-gateway/web/biome.json create mode 100644 scripts/check-mirror.mjs create mode 100644 scripts/mirror-manifest.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d1d8d61..c5e527dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,10 @@ jobs: working-directory: crates/agent-gateway/web run: pnpm build + - name: Lint WebUI + working-directory: crates/agent-gateway/web + run: pnpm lint + - name: Test WebUI modules working-directory: crates/agent-gateway run: node --test test/webui/*.test.mjs web/test/*.test.mjs @@ -138,6 +142,14 @@ jobs: working-directory: crates/agent-gui run: pnpm install --frozen-lockfile + - name: Typecheck and build GUI frontend + working-directory: crates/agent-gui + run: pnpm build + + - name: Lint GUI frontend + working-directory: crates/agent-gui + run: pnpm lint + - name: Test frontend modules working-directory: crates/agent-gui run: pnpm test:frontend @@ -179,6 +191,19 @@ jobs: CARGO_TERM_COLOR: always run: cargo test --manifest-path crates/agent-gui/src-tauri/Cargo.toml chat_history --lib + mirror: + name: GUI/WebUI Mirror Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Check mirrored files are byte-identical + run: node scripts/check-mirror.mjs + whitespace: name: Diff Hygiene runs-on: ubuntu-latest diff --git a/crates/agent-gateway/web/biome.json b/crates/agent-gateway/web/biome.json new file mode 100644 index 00000000..6c1ea5e5 --- /dev/null +++ b/crates/agent-gateway/web/biome.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "includes": [ + "src/**", + "!!**/dist", + "!!**/node_modules" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { + "useKeyWithClickEvents": "warn", + "noStaticElementInteractions": "warn", + "noLabelWithoutControl": "warn", + "useSemanticElements": "warn", + "useAriaPropsSupportedByRole": "warn", + "useAriaPropsForRole": "warn", + "useFocusableInteractive": "warn", + "noAutofocus": "off", + "noSvgWithoutTitle": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn", + "noUnusedImports": "error" + }, + "suspicious": { + "noArrayIndexKey": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "overrides": [ + { + "includes": [ + "**/*.css" + ], + "linter": { + "rules": { + "suspicious": { + "noDuplicateProperties": "off" + } + } + } + } + ] +} diff --git a/crates/agent-gateway/web/package.json b/crates/agent-gateway/web/package.json index 5c216c3b..bfb18f6e 100644 --- a/crates/agent-gateway/web/package.json +++ b/crates/agent-gateway/web/package.json @@ -6,7 +6,10 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "biome check src/", + "format": "biome format --write src/", + "lint:fix": "biome check --write src/" }, "dependencies": { "@git-diff-view/file": "^0.1.3", @@ -39,6 +42,7 @@ "yet-another-react-lightbox": "^3.31.0" }, "devDependencies": { + "@biomejs/biome": "^2.4.15", "@iconify-json/logos": "^1.2.11", "@iconify-json/lucide": "^1.2.108", "@iconify-json/material-icon-theme": "1.2.67", diff --git a/crates/agent-gateway/web/pnpm-lock.yaml b/crates/agent-gateway/web/pnpm-lock.yaml index 5a0eb807..911216ba 100644 --- a/crates/agent-gateway/web/pnpm-lock.yaml +++ b/crates/agent-gateway/web/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: specifier: ^3.31.0 version: 3.31.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) devDependencies: + '@biomejs/biome': + specifier: ^2.4.15 + version: 2.5.2 '@iconify-json/logos': specifier: ^1.2.11 version: 1.2.11 @@ -215,6 +218,63 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@biomejs/biome@2.5.2': + resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.2': + resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.2': + resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.2': + resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.2': + resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.2': + resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.2': + resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.2': + resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.2': + resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} @@ -2597,6 +2657,41 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@biomejs/biome@2.5.2': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.2 + '@biomejs/cli-darwin-x64': 2.5.2 + '@biomejs/cli-linux-arm64': 2.5.2 + '@biomejs/cli-linux-arm64-musl': 2.5.2 + '@biomejs/cli-linux-x64': 2.5.2 + '@biomejs/cli-linux-x64-musl': 2.5.2 + '@biomejs/cli-win32-arm64': 2.5.2 + '@biomejs/cli-win32-x64': 2.5.2 + + '@biomejs/cli-darwin-arm64@2.5.2': + optional: true + + '@biomejs/cli-darwin-x64@2.5.2': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.2': + optional: true + + '@biomejs/cli-linux-arm64@2.5.2': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.2': + optional: true + + '@biomejs/cli-linux-x64@2.5.2': + optional: true + + '@biomejs/cli-win32-arm64@2.5.2': + optional: true + + '@biomejs/cli-win32-x64@2.5.2': + optional: true + '@braintree/sanitize-url@7.1.2': {} '@chevrotain/cst-dts-gen@12.0.0': diff --git a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx b/crates/agent-gateway/web/src/app/FileDropOverlay.tsx index 35839855..5b1681be 100644 --- a/crates/agent-gateway/web/src/app/FileDropOverlay.tsx +++ b/crates/agent-gateway/web/src/app/FileDropOverlay.tsx @@ -50,9 +50,7 @@ export function FileDropOverlay({
{title}
-
- {description} -
+
{description}
); } @@ -1916,15 +1961,29 @@ function ToolResultDisplay({ tags={[ { label: "action", value: details.action }, { label: "root", value: details.rootDir }, - ...(typeof details.skillsCount === "number" ? [{ label: "skills", value: String(details.skillsCount) }] : []), - ...(typeof details.installedCount === "number" ? [{ label: "installed", value: String(details.installedCount) }] : []), + ...(typeof details.skillsCount === "number" + ? [{ label: "skills", value: String(details.skillsCount) }] + : []), + ...(typeof details.installedCount === "number" + ? [{ label: "installed", value: String(details.installedCount) }] + : []), ...(details.createdName ? [{ label: "created", value: details.createdName }] : []), - ...(typeof details.clawhubResultCount === "number" ? [{ label: "clawhub", value: String(details.clawhubResultCount) }] : []), + ...(typeof details.clawhubResultCount === "number" + ? [{ label: "clawhub", value: String(details.clawhubResultCount) }] + : []), ...(details.clawhubSlug ? [{ label: "slug", value: details.clawhubSlug }] : []), - ...(typeof details.validationOk === "boolean" ? [{ label: "valid", value: details.validationOk ? "true" : "false" }] : []), - ...(details.packageArchive ? [{ label: "archive", value: details.packageArchive }] : []), - ...(details.clawhubNextCursor ? [{ label: "cursor", value: details.clawhubNextCursor }] : []), - ...(typeof details.invalidCount === "number" && details.invalidCount > 0 ? [{ label: "invalid", value: String(details.invalidCount) }] : []), + ...(typeof details.validationOk === "boolean" + ? [{ label: "valid", value: details.validationOk ? "true" : "false" }] + : []), + ...(details.packageArchive + ? [{ label: "archive", value: details.packageArchive }] + : []), + ...(details.clawhubNextCursor + ? [{ label: "cursor", value: details.clawhubNextCursor }] + : []), + ...(typeof details.invalidCount === "number" && details.invalidCount > 0 + ? [{ label: "invalid", value: String(details.invalidCount) }] + : []), ...(details.backup ? [{ label: "backup", value: details.backup }] : []), ]} /> @@ -1944,13 +2003,25 @@ function ToolResultDisplay({ { label: "action", value: details.action }, ...(details.serverId ? [{ label: "server", value: details.serverId }] : []), ...(details.transport ? [{ label: "transport", value: details.transport }] : []), - ...(typeof details.ok === "boolean" ? [{ label: "ok", value: details.ok ? "true" : "false" }] : []), + ...(typeof details.ok === "boolean" + ? [{ label: "ok", value: details.ok ? "true" : "false" }] + : []), ...(details.phase ? [{ label: "phase", value: details.phase }] : []), - ...(typeof details.serverCount === "number" ? [{ label: "servers", value: String(details.serverCount) }] : []), - ...(typeof details.enabledCount === "number" ? [{ label: "enabled", value: String(details.enabledCount) }] : []), - ...(typeof details.toolsCount === "number" ? [{ label: "tools", value: String(details.toolsCount) }] : []), - ...(typeof details.changed === "boolean" ? [{ label: "changed", value: details.changed ? "true" : "false" }] : []), - ...(typeof details.stopped === "boolean" ? [{ label: "stopped", value: details.stopped ? "true" : "false" }] : []), + ...(typeof details.serverCount === "number" + ? [{ label: "servers", value: String(details.serverCount) }] + : []), + ...(typeof details.enabledCount === "number" + ? [{ label: "enabled", value: String(details.enabledCount) }] + : []), + ...(typeof details.toolsCount === "number" + ? [{ label: "tools", value: String(details.toolsCount) }] + : []), + ...(typeof details.changed === "boolean" + ? [{ label: "changed", value: details.changed ? "true" : "false" }] + : []), + ...(typeof details.stopped === "boolean" + ? [{ label: "stopped", value: details.stopped ? "true" : "false" }] + : []), ]} /> @@ -2011,7 +2082,9 @@ function ToolResultDisplay({ ]} /> - {!details.reusedExisting ? : null} + {!details.reusedExisting ? ( + + ) : null}
); } @@ -2036,7 +2109,9 @@ function ToolResultDisplay({ ]} /> - {!details.reusedExisting ? : null} + {!details.reusedExisting ? ( + + ) : null}
); } @@ -2058,7 +2133,9 @@ function ToolResultDisplay({ ]} /> - {!details.reusedExisting ? : null} + {!details.reusedExisting ? ( + + ) : null}
); } @@ -2100,7 +2177,9 @@ function ToolResultDisplay({ const details = result.details as DeleteResultDetails; return ( - + ); } @@ -2127,8 +2206,13 @@ function ToolResultDisplay({ key={`${entry.kind}-${entry.path}`} className="flex items-start gap-2 rounded-[8px] px-1.5 py-1 text-[11px] leading-[1.5] even:bg-black/[0.02] dark:even:bg-white/[0.03]" > - {entry.kind} - + + {entry.kind} + +
))}
@@ -2191,7 +2275,10 @@ function ToolResultDisplay({ key={file.path} className="space-y-1 rounded-[8px] px-1.5 py-1 even:bg-black/[0.02] dark:even:bg-white/[0.03]" > - + {details.matches.map((match, index) => ( -
+
- + line {match.line}
- {match.before.length > 0 ? : null} + {match.before.length > 0 ? ( + + ) : null} - {match.after.length > 0 ? : null} + {match.after.length > 0 ? ( + + ) : null}
))} @@ -2276,14 +2373,16 @@ function ToolResultDisplay({ ) : null} {shouldShowDelegateWorktreeLocation(agent) ? (
- {agent.branchName ? `${agent.branchName} | ` : ""}{agent.worktreeRoot} + {agent.branchName ? `${agent.branchName} | ` : ""} + {agent.worktreeRoot}
) : null} - {agent.diffStat ? ( - - ) : null} + {agent.diffStat ? : null} {showUntrackedFiles ? ( - `- ${file}`).join("\n")}`} maxChars={1200} /> + `- ${file}`).join("\n")}`} + maxChars={1200} + /> ) : null} {agent.worktreeStatusError ? ( @@ -2297,21 +2396,39 @@ function ToolResultDisplay({ ) : null} {agent.applyCopiedFiles && agent.applyCopiedFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} maxChars={1200} /> + `- ${file}`).join("\n")}`} + maxChars={1200} + /> ) : null} {agent.applyDeletedFiles && agent.applyDeletedFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} maxChars={1200} /> + `- ${file}`).join("\n")}`} + maxChars={1200} + /> ) : null} {agent.applyConflictFiles && agent.applyConflictFiles.length > 0 ? ( - `- ${file}`).join("\n")}`} maxChars={1200} /> + `- ${file}`).join("\n")}`} + maxChars={1200} + /> ) : null} {agent.worktreeCleanupError ? ( - + ) : agent.worktreeCleanupReason && agent.worktreeCleanupStatus === "retained" ? ( - + ) : null} {showCandidateArtifacts ? ( - `- ${file}`).join("\n")}`} maxChars={1200} /> + `- ${file}`).join("\n")}`} + maxChars={1200} + /> ) : null} {agent.error ? ( @@ -2445,14 +2562,15 @@ function ToolCallItem({ ? item.toolCall.arguments.command.trim() : ""; const firstLine = bashCmd ? bashCmd.split("\n")[0] : ""; - const toolArgsSummary = isRedactedToolContent || isBash - ? "" - : isDelegateAgentCard - ? getDelegateAgentInlineSummary(item) - : summarizeToolCall(item.toolCall, { - includeName: false, - includeManagerAction: false, - }); + const toolArgsSummary = + isRedactedToolContent || isBash + ? "" + : isDelegateAgentCard + ? getDelegateAgentInlineSummary(item) + : summarizeToolCall(item.toolCall, { + includeName: false, + includeManagerAction: false, + }); const fileChangeStats = useMemo( () => (isRedactedToolContent ? undefined : deriveFileChangeStats(item.toolCall)), [isRedactedToolContent, item.toolCall], @@ -2526,11 +2644,14 @@ function ToolCallItem({ {isBash && firstLine ? ( - $ - {" "}{firstLine.length > 48 ? `${firstLine.slice(0, 48)}…` : firstLine} + ${" "} + {firstLine.length > 48 ? `${firstLine.slice(0, 48)}…` : firstLine} ) : toolArgsSummary ? ( - + {toolArgsSummary} ) : null} @@ -2713,10 +2834,7 @@ function ToolTraceGroup(props: { ); const composition = useMemo(() => getToolGroupComposition(items), [items]); const dominantToolName = useMemo(() => getDominantToolName(items), [items]); - const allBash = useMemo( - () => items.every((item) => item.toolCall.name === "Bash"), - [items], - ); + const allBash = useMemo(() => items.every((item) => item.toolCall.name === "Bash"), [items]); const meta = useMemo( () => (allBash ? getToolMeta("Bash") : getToolMeta(dominantToolName)), [allBash, dominantToolName], @@ -2825,9 +2943,7 @@ function ToolTraceGroup(props: { variant="grouped" readOnly={readOnly} redactToolContent={redactToolContent} - isRunning={Boolean( - item.toolCall.id && runningToolCallIds.includes(item.toolCall.id), - )} + isRunning={Boolean(item.toolCall.id && runningToolCallIds.includes(item.toolCall.id))} /> ))}
@@ -2867,8 +2983,7 @@ function RoundContent(props: { renderMode, readOnly = false, redactToolContent = false, - } = - props; + } = props; const groupedBlocks = useMemo(() => groupRoundBlocks(round.blocks), [round.blocks]); const hasContent = groupedBlocks.some((block) => { @@ -2881,7 +2996,8 @@ function RoundContent(props: { return true; } return block.text.trim().length > 0; - }) || (isActive && isLive); + }) || + (isActive && isLive); const normalizedToolStatus = isActive && isLive ? normalizeLiveToolStatus(toolStatus ?? null) : null; const isCompactionStatus = toolStatusVariant === "compaction"; @@ -2967,7 +3083,7 @@ function RoundContent(props: { @@ -2999,7 +3115,9 @@ function RoundContent(props: { ); })} - {showUsage ? : null} + {showUsage ? ( + + ) : null}
); } diff --git a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx index 774dce95..a18ea809 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatComposerBar.tsx @@ -1,14 +1,20 @@ import { + type MutableRefObject, memo, + type ReactNode, + type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState, - type MutableRefObject, - type PointerEvent as ReactPointerEvent, - type ReactNode, } from "react"; import { createPortal } from "react-dom"; +import { + MentionComposer, + type MentionComposerHandle, + type MentionComposerSkill, +} from "../../components/chat/MentionComposer"; +import { GitBranchSelector } from "../../components/git/GitBranchSelector"; import { Brain, ChevronDown, @@ -25,13 +31,6 @@ import { Trash2, X, } from "../../components/icons"; - -import { - MentionComposer, - type MentionComposerHandle, - type MentionComposerSkill, -} from "../../components/chat/MentionComposer"; -import { GitBranchSelector } from "../../components/git/GitBranchSelector"; import { Button } from "../../components/ui/button"; import { Select, @@ -41,17 +40,14 @@ import { SelectValue, } from "../../components/ui/select"; import { useLocale } from "../../i18n"; -import { - formatUploadedFileSize, - type PendingUploadedFile, -} from "../../lib/chat/uploadedFiles"; -import { cn } from "../../lib/shared/utils"; +import { formatUploadedFileSize, type PendingUploadedFile } from "../../lib/chat/uploadedFiles"; import type { GitClient } from "../../lib/git/types"; import { - DEFAULT_CHAT_RUNTIME_CONTROLS, type ChatRuntimeControls, + DEFAULT_CHAT_RUNTIME_CONTROLS, type ReasoningLevel, } from "../../lib/settings"; +import { cn } from "../../lib/shared/utils"; const REASONING_I18N_KEYS: Record = { off: "settings.reasoning.off", @@ -395,9 +391,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { updateQueueScrollbar(); list.addEventListener("scroll", updateQueueScrollbar, { passive: true }); const resizeObserver = - typeof ResizeObserver === "undefined" - ? null - : new ResizeObserver(updateQueueScrollbar); + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updateQueueScrollbar); resizeObserver?.observe(list); window.addEventListener("resize", updateQueueScrollbar); @@ -409,10 +403,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: { }, [updateQueueScrollbar]); useEffect(() => { - if ( - reasoningOptions.length > 0 && - reasoningOptions.includes(chatRuntimeControls.reasoning) - ) { + if (reasoningOptions.length > 0 && reasoningOptions.includes(chatRuntimeControls.reasoning)) { return; } if ( diff --git a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx index 32588cff..870187b8 100644 --- a/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx +++ b/crates/agent-gateway/web/src/pages/chat/ChatHeader.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useState, type ReactNode } from "react"; +import { memo, type ReactNode, useMemo, useState } from "react"; import { Check, ChevronDown, @@ -22,24 +22,18 @@ import { DropdownMenuTrigger, } from "../../components/ui/dropdown-menu"; import { useLocale } from "../../i18n"; -import { cn } from "../../lib/shared/utils"; -import { parseModelValue, type ModelOption } from "../../lib/providers/llm"; +import { type ModelOption, parseModelValue } from "../../lib/providers/llm"; import { - getNextTheme, - setSelectedModel, type AppSettings, + getNextTheme, type ProviderId, + setSelectedModel, type Theme, } from "../../lib/settings"; +import { cn } from "../../lib/shared/utils"; import type { SectionId } from "../settings/types"; -function ProviderBrandIcon({ - type, - className, -}: { - type: ProviderId; - className?: string; -}) { +function ProviderBrandIcon({ type, className }: { type: ProviderId; className?: string }) { const cls = cn("h-4 w-4 shrink-0", className); if (type === "claude_code") return ; if (type === "gemini") return ; @@ -137,10 +131,7 @@ export const ChatHeader = memo(function ChatHeader(props: { > {selectedOption ? ( - + ) : null} {currentModelLabel} @@ -203,9 +194,7 @@ export const ChatHeader = memo(function ChatHeader(props: { /> {option.model} - {isSelected ? ( - - ) : null} + {isSelected ? : null} ); })} diff --git a/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts b/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts index c3bb9a3f..b23cf829 100644 --- a/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts +++ b/crates/agent-gateway/web/src/pages/chat/useChatSkills.ts @@ -1,13 +1,12 @@ import { useCallback, useEffect, useRef, useState } from "react"; - +import { type AppSettings, updateSkills } from "../../lib/settings"; import { discoverSkills, isAlwaysEnabledSkillName, mergeAlwaysEnabledSkillNames, - subscribeSkillsDiscoveryUpdated, type SkillSummary, + subscribeSkillsDiscoveryUpdated, } from "../../lib/skills"; -import { updateSkills, type AppSettings } from "../../lib/settings"; type UseChatSkillsParams = { skillsEnabled: boolean; @@ -64,48 +63,51 @@ export function useChatSkills(params: UseChatSkillsParams) { setSkillsLoading(false); }, []); - const runDiscovery = useCallback(async (options?: { force?: boolean }) => { - if (!skillsEnabled) { - requestSequenceRef.current += 1; - applyDisabledState(); - return null; - } - - const requestId = requestSequenceRef.current + 1; - requestSequenceRef.current = requestId; - if (mountedRef.current) { - setSkillsLoading(true); - setSkillsLoadError(null); - } - - try { - const discovery = await discoverSkills({ force: options?.force }); - if (!mountedRef.current || requestSequenceRef.current !== requestId) { + const runDiscovery = useCallback( + async (options?: { force?: boolean }) => { + if (!skillsEnabled) { + requestSequenceRef.current += 1; + applyDisabledState(); return null; } - setSkillsRootDir(discovery.rootDir); - setAvailableSkills(discovery.skills); - reconcileSelectedSkills({ - skills: discovery.skills, - selectedSkillNames: selectedSkillNamesRef.current, - setSettings, - }); - return discovery; - } catch (err) { - if (!mountedRef.current || requestSequenceRef.current !== requestId) { - return null; + + const requestId = requestSequenceRef.current + 1; + requestSequenceRef.current = requestId; + if (mountedRef.current) { + setSkillsLoading(true); + setSkillsLoadError(null); } - const msg = err instanceof Error ? err.message : String(err); - setSkillsRootDir(""); - setAvailableSkills([]); - setSkillsLoadError(msg || "加载 skills 失败"); - return null; - } finally { - if (mountedRef.current && requestSequenceRef.current === requestId) { - setSkillsLoading(false); + + try { + const discovery = await discoverSkills({ force: options?.force }); + if (!mountedRef.current || requestSequenceRef.current !== requestId) { + return null; + } + setSkillsRootDir(discovery.rootDir); + setAvailableSkills(discovery.skills); + reconcileSelectedSkills({ + skills: discovery.skills, + selectedSkillNames: selectedSkillNamesRef.current, + setSettings, + }); + return discovery; + } catch (err) { + if (!mountedRef.current || requestSequenceRef.current !== requestId) { + return null; + } + const msg = err instanceof Error ? err.message : String(err); + setSkillsRootDir(""); + setAvailableSkills([]); + setSkillsLoadError(msg || "加载 skills 失败"); + return null; + } finally { + if (mountedRef.current && requestSequenceRef.current === requestId) { + setSkillsLoading(false); + } } - } - }, [applyDisabledState, setSettings, skillsEnabled]); + }, + [applyDisabledState, setSettings, skillsEnabled], + ); const refreshSkills = useCallback(async () => { return runDiscovery({ force: true }); diff --git a/crates/agent-gateway/web/src/pages/mcp-hub/McpHubPage.tsx b/crates/agent-gateway/web/src/pages/mcp-hub/McpHubPage.tsx index ecf48e35..47aa0464 100644 --- a/crates/agent-gateway/web/src/pages/mcp-hub/McpHubPage.tsx +++ b/crates/agent-gateway/web/src/pages/mcp-hub/McpHubPage.tsx @@ -1,13 +1,12 @@ import { useState } from "react"; - +import { HubBackdrop, HubHeader } from "../../components/hub/HubChrome"; import { Cloud, McpLogo, Plug, Plus, Server, Sparkles } from "../../components/icons"; import { Button } from "../../components/ui/button"; import { useLocale } from "../../i18n"; -import { McpRegistryBrowser } from "./McpRegistryBrowser"; -import { McpServerEditModal, McpServersForm } from "./McpServersForm"; import { type AppSettings, type McpServerConfig, updateMcp } from "../../lib/settings"; -import { HubBackdrop, HubHeader } from "../../components/hub/HubChrome"; import { cn } from "../../lib/shared/utils"; +import { McpRegistryBrowser } from "./McpRegistryBrowser"; +import { McpServerEditModal, McpServersForm } from "./McpServersForm"; type McpHubPageProps = { settings: AppSettings; @@ -19,9 +18,7 @@ type McpHubPageProps = { type McpHubView = "installed" | "store"; -type EditingState = - | { mode: "add" } - | { mode: "edit"; idx: number; server: McpServerConfig }; +type EditingState = { mode: "add" } | { mode: "edit"; idx: number; server: McpServerConfig }; export function McpHubPage(props: McpHubPageProps) { const { settings, setSettings, sidebarOpen, onOpenSidebar } = props; @@ -48,9 +45,7 @@ export function McpHubPage(props: McpHubPageProps) { if (editing?.mode === "edit") { const targetIdx = editing.idx; return updateMcp(prev, { - servers: prev.mcp.servers.map((item, index) => - index === targetIdx ? server : item, - ), + servers: prev.mcp.servers.map((item, index) => (index === targetIdx ? server : item)), }); } return updateMcp(prev, { diff --git a/crates/agent-gateway/web/src/pages/mcp-hub/McpRegistryBrowser.tsx b/crates/agent-gateway/web/src/pages/mcp-hub/McpRegistryBrowser.tsx index 238bc6d0..1d73fb5e 100644 --- a/crates/agent-gateway/web/src/pages/mcp-hub/McpRegistryBrowser.tsx +++ b/crates/agent-gateway/web/src/pages/mcp-hub/McpRegistryBrowser.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { @@ -33,14 +33,14 @@ import { applyMcpRegistryInstallConfig, createUniqueMcpServerId, MCP_REGISTRY_SOURCE_OPTIONS, - mcpRegistryConfigInputKey, - resolveMcpRegistryInstallDraft, - searchMcpRegistry, - withUniqueMcpServerId, type McpRegistryCard, type McpRegistryConfigInput, type McpRegistryInstallDraft, type McpRegistrySource, + mcpRegistryConfigInputKey, + resolveMcpRegistryInstallDraft, + searchMcpRegistry, + withUniqueMcpServerId, } from "../../lib/mcpRegistry"; import { type AppSettings, type McpServerConfig, updateMcp } from "../../lib/settings"; import { useModalMotion } from "../../lib/shared/modalMotion"; @@ -210,18 +210,22 @@ function valueFromServerConfig(input: McpRegistryConfigInput, server: McpServerC if (input.target === "config") { for (let index = 0; index < (server.args ?? []).length; index += 1) { const arg = server.args[index]; - const rawConfig = arg === "--config" - ? server.args[index + 1] - : arg.startsWith("--config=") - ? arg.slice("--config=".length) - : undefined; + const rawConfig = + arg === "--config" + ? server.args[index + 1] + : arg.startsWith("--config=") + ? arg.slice("--config=".length) + : undefined; if (!rawConfig) continue; try { const parsed = JSON.parse(rawConfig); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue; - const value = (parsed as Record)[targetName] - ?? (parsed as Record)[input.name]; - return cleanConfigValue(typeof value === "string" ? value : value === undefined ? undefined : String(value)); + const value = + (parsed as Record)[targetName] ?? + (parsed as Record)[input.name]; + return cleanConfigValue( + typeof value === "string" ? value : value === undefined ? undefined : String(value), + ); } catch { return ""; } @@ -389,9 +393,10 @@ function McpConfigureModal(props: { warnings: configureDraft?.warnings ?? [], commandPreview: "", }; - const finalDraft = requiredConfig.length > 0 - ? applyMcpRegistryInstallConfig(configuredDraft, draft.configValues) - : configuredDraft; + const finalDraft = + requiredConfig.length > 0 + ? applyMcpRegistryInstallConfig(configuredDraft, draft.configValues) + : configuredDraft; onSave(finalDraft.server); requestClose(); } catch (error) { @@ -450,14 +455,16 @@ function McpConfigureModal(props: { />
-