diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go index 6a95e230..ebb77359 100644 --- a/crates/agent-gateway/internal/server/websocket.go +++ b/crates/agent-gateway/internal/server/websocket.go @@ -85,6 +85,8 @@ type websocketConnection struct { const recentActiveChatRetention = 5 * time.Second const maxHistoryListLimit = 200 +const defaultHistoryListPage = 1 +const defaultHistoryListPageSize = 80 func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { server := &websocket.Server{ @@ -500,13 +502,11 @@ func (c *websocketConnection) handleHistoryList(req websocketRequest) { } page := body.Page if page <= 0 { - _ = c.writeError(req.ID, "history.list page must be greater than 0") - return + page = defaultHistoryListPage } pageSize := body.PageSize if pageSize <= 0 { - _ = c.writeError(req.ID, "history.list page_size must be greater than 0") - return + pageSize = defaultHistoryListPageSize } else if pageSize > maxHistoryListLimit { pageSize = maxHistoryListLimit } @@ -561,13 +561,11 @@ func (c *websocketConnection) handleHistorySharedList(req websocketRequest) { } page := body.Page if page <= 0 { - _ = c.writeError(req.ID, "history.shared_list page must be greater than 0") - return + page = defaultHistoryListPage } pageSize := body.PageSize if pageSize <= 0 { - _ = c.writeError(req.ID, "history.shared_list page_size must be greater than 0") - return + pageSize = defaultHistoryListPageSize } else if pageSize > maxHistoryListLimit { pageSize = maxHistoryListLimit } diff --git a/crates/agent-gateway/test/websocket/chat_bridge_test.go b/crates/agent-gateway/test/websocket/chat_bridge_test.go index ae69bc11..194c9c0b 100644 --- a/crates/agent-gateway/test/websocket/chat_bridge_test.go +++ b/crates/agent-gateway/test/websocket/chat_bridge_test.go @@ -1267,3 +1267,72 @@ func TestWebSocketForwardsHistorySettingsAndFsRPCs(t *testing.T) { t.Fatalf("preview payload = %#v", previewPayload) } } + +func TestWebSocketDefaultsInvalidHistoryListPagination(t *testing.T) { + t.Parallel() + + 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) + defer cleanup() + + authWebSocket(t, conn, "ws-token") + + sendEnvelope(t, conn, "history-defaults", "history.list", map[string]any{ + "page": 0, + "page_size": 0, + }) + historyOutbound := readOutboundEnvelope(t, agentSession) + historyReq := historyOutbound.GetHistoryList() + if historyReq == nil { + t.Fatalf("history outbound payload = %T, want HistoryListRequest", historyOutbound.GetPayload()) + } + if historyReq.GetPage() != 1 || historyReq.GetPageSize() != 80 { + t.Fatalf("history list defaults = %#v", historyReq) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: historyOutbound.GetRequestId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_HistoryListResp{ + HistoryListResp: &gatewayv1.HistoryListResponse{}, + }, + }) + historyResponse := receiveEnvelope(t, conn) + if historyResponse.ID != "history-defaults" || historyResponse.Type != "response" { + t.Fatalf("history response = %#v", historyResponse) + } + + sendEnvelope(t, conn, "shared-history-defaults", "history.shared_list", map[string]any{}) + sharedHistoryOutbound := readOutboundEnvelope(t, agentSession) + sharedHistoryReq := sharedHistoryOutbound.GetMemoryManage() + if sharedHistoryReq == nil { + t.Fatalf("shared history outbound payload = %T, want MemoryManageRequest", sharedHistoryOutbound.GetPayload()) + } + var sharedHistoryArgs map[string]any + if err := json.Unmarshal([]byte(sharedHistoryReq.GetArgsJson()), &sharedHistoryArgs); err != nil { + t.Fatalf("decode shared history args: %v", err) + } + if sharedHistoryArgs["page"] != float64(1) || sharedHistoryArgs["page_size"] != float64(80) { + t.Fatalf("shared history defaults = %#v", sharedHistoryArgs) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: sharedHistoryOutbound.GetRequestId(), + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_MemoryManageResp{ + MemoryManageResp: &gatewayv1.MemoryManageResponse{ + ResultJson: `{"total_count":0,"conversations":[]}`, + }, + }, + }) + sharedHistoryResponse := receiveEnvelope(t, conn) + if sharedHistoryResponse.ID != "shared-history-defaults" || sharedHistoryResponse.Type != "response" { + t.Fatalf("shared history response = %#v", sharedHistoryResponse) + } +} 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 572d7178..b83c684d 100644 --- a/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs +++ b/crates/agent-gateway/test/webui/gateway-socket-client.test.mjs @@ -720,6 +720,43 @@ test("GatewayWebSocketClient sends history list requests", async () => { resetGatewayWebSocketClient(); }); +test("GatewayWebSocketClient defaults invalid history pagination", async () => { + installBrowser(); + const loader = createWebModuleLoader(); + const { getGatewayWebSocketClient, resetGatewayWebSocketClient } = loader.loadModule("src/lib/gatewaySocket.ts"); + resetGatewayWebSocketClient(); + + const client = getGatewayWebSocketClient("token"); + const listPromise = client.listHistory(0, 0); + const socket = await connectAndAuth(); + await waitFor(() => socket.sent.length >= 2, "history list envelope"); + assert.equal(socket.sent[1].type, "history.list"); + assert.deepEqual(socket.sent[1].payload, { page: 1, page_size: 80 }); + socket.receive({ + id: socket.sent[1].id, + type: "response", + payload: { conversations: [], total_count: 0, running_conversation_ids: [] }, + }); + assert.deepEqual(await listPromise, { + conversations: [], + total_count: 0, + running_conversation_ids: [], + }); + + const sharedListPromise = client.listSharedHistory(Number.NaN, 500); + await waitFor(() => socket.sent.length >= 3, "shared history list envelope"); + assert.equal(socket.sent[2].type, "history.shared_list"); + assert.deepEqual(socket.sent[2].payload, { page: 1, page_size: 200 }); + socket.receive({ + id: socket.sent[2].id, + type: "response", + payload: { conversations: [], total_count: 0 }, + }); + assert.deepEqual(await sharedListPromise, { conversations: [], total_count: 0 }); + + resetGatewayWebSocketClient(); +}); + test("GatewayWebSocketClient sends history share requests", async () => { installBrowser(); const loader = createWebModuleLoader(); diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 9cf663b2..cf7401a2 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -219,6 +219,10 @@ type RuntimeHost = { clearInterval: typeof clearInterval; }; +const DEFAULT_HISTORY_LIST_PAGE = 1; +const DEFAULT_HISTORY_LIST_PAGE_SIZE = 80; +const MAX_HISTORY_LIST_PAGE_SIZE = 200; + function getRuntimeHost(): RuntimeHost { if (typeof window !== "undefined") { return window as unknown as RuntimeHost; @@ -252,6 +256,25 @@ function normalizeAfterSeq(value: unknown) { : 0; } +function normalizePositiveInteger(value: number, fallback: number) { + if (!Number.isFinite(value)) { + return fallback; + } + const normalized = Math.trunc(value); + return normalized > 0 ? normalized : fallback; +} + +function normalizeHistoryListPage(page: number) { + return normalizePositiveInteger(page, DEFAULT_HISTORY_LIST_PAGE); +} + +function normalizeHistoryListPageSize(pageSize: number) { + return Math.min( + normalizePositiveInteger(pageSize, DEFAULT_HISTORY_LIST_PAGE_SIZE), + MAX_HISTORY_LIST_PAGE_SIZE, + ); +} + function isRecoverableGatewayTransportError(error: unknown) { const message = asErrorMessage(error, ""); return ( @@ -539,15 +562,15 @@ export class GatewayWebSocketClient { async listHistory(page: number, pageSize: number): Promise { return this.requestWithRecovery("history.list", { - page, - page_size: pageSize, + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), }); } async listSharedHistory(page: number, pageSize: number): Promise { return this.requestWithRecovery("history.shared_list", { - page, - page_size: pageSize, + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), }); } @@ -1805,11 +1828,17 @@ class SharedWorkerGatewayWebSocketClient implements GatewayWebSocketClientLike { } async listHistory(page: number, pageSize: number): Promise { - return this.request("history.list", { page, page_size: pageSize }); + return this.request("history.list", { + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), + }); } async listSharedHistory(page: number, pageSize: number): Promise { - return this.request("history.shared_list", { page, page_size: pageSize }); + return this.request("history.shared_list", { + page: normalizeHistoryListPage(page), + page_size: normalizeHistoryListPageSize(pageSize), + }); } async getHistory(conversationId: string, options?: HistoryGetOptions): Promise { 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 0ba0fc91..6f0a71cc 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -25,6 +25,8 @@ use crate::services::skills::system_manage_skill_sync; const DEFAULT_FS_LIST_DIRS_MAX_RESULTS: usize = 2000; const HARD_FS_LIST_DIRS_MAX_RESULTS: usize = 10000; +const DEFAULT_HISTORY_LIST_PAGE: i32 = 1; +const DEFAULT_HISTORY_LIST_PAGE_SIZE: i32 = 80; #[derive(Debug, Deserialize)] struct HistorySharedListArgs { @@ -91,9 +93,18 @@ pub async fn handle_cron_manage( pub async fn handle_history_list( request: proto::HistoryListRequest, ) -> Result { + let page_number = if request.page > 0 { + request.page + } else { + DEFAULT_HISTORY_LIST_PAGE + }; + let page_size = if request.page_size > 0 { + request.page_size + } else { + DEFAULT_HISTORY_LIST_PAGE_SIZE + }; let page = - chat_history::chat_history_list(i64::from(request.page), i64::from(request.page_size)) - .await?; + chat_history::chat_history_list(i64::from(page_number), i64::from(page_size)).await?; Ok(build_proto_history_list_response(page)) }