diff --git a/apps/backend/cmd/mock-agent/AGENTS.md b/apps/backend/cmd/mock-agent/AGENTS.md index 2cb0488596..7caff16a47 100644 --- a/apps/backend/cmd/mock-agent/AGENTS.md +++ b/apps/backend/cmd/mock-agent/AGENTS.md @@ -8,7 +8,9 @@ Scoped guidance for `apps/backend/cmd/mock-agent/`. The mock agent satisfies the `handler.go` routes any prompt starting with `/e2e:` to `emitPredefinedScenario(e, name)`, which looks `name` up in `scenarioRegistry` (`scenarios.go`). The registry is the single source of truth for scenario names — to add a scenario, add one map entry and one `scenario(e *emitter)` function. -Friendly aliases live in `handler.go` next to the dispatcher: `/ask-single`, `/ask-multiple`, `/crash`, `/todo`, `/mermaid`, `/markdown`, `/sleep [n]`, `/tool:`, `/subagent`, `/subtask`. +Friendly aliases live in `handler.go` next to the dispatcher: `/ask-single`, `/ask-multiple`, `/crash`, `/todo`, `/mermaid`, `/markdown`, `/sleep [n]`, `/tool:`, `/subagent`, `/subtask`, `/bulk[:N]`. + +`/bulk` (default 120, e.g. `/bulk:300`) emits N short agent messages, each followed by a tiny tool call. The tool-call boundary flushes the buffered text into its own message row — consecutive agent text chunks otherwise coalesce into a single message (`manager_streaming.flushMessageBuffer` only fires on a tool-call/turn boundary) — so the result is a long, paginated conversation. Use it to manually exercise chat scrollback and the "Load older messages" pagination in dev/preview. **2. Inline scripts — `e2e:(...)`** diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index 1b2fbd9f67..316bac7701 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -102,6 +102,65 @@ func nextOverloadedAttempt(sid acp.SessionId) int { return next } +// bulkCmdRe matches `/bulk` or `/e2e:bulk`, optionally followed by `:N` or ` N` +// — the number of agent messages to emit (default 120, capped at 1000). Used to +// populate a long chat history for manually testing scrollback and the "Load +// older messages" pagination in dev/preview. +var bulkCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?bulk(?:[: ]+(\d+))?$`) + +const ( + bulkDefaultCount = 120 + bulkMaxCount = 1000 + // Tool-input/output map keys, shared to avoid repeating the literals. + toolKeyFilePath = "file_path" + toolKeyContent = "content" +) + +// parseBulkCmd reports whether the prompt is the /bulk command and, if so, how +// many messages to emit (default 120, capped at 1000). Returns ok=false for any +// other prompt. +func parseBulkCmd(prompt string) (count int, ok bool) { + cmd := stripKandevSystem(strings.TrimSpace(prompt)) + m := bulkCmdRe.FindStringSubmatch(cmd) + if m == nil { + return 0, false + } + count = bulkDefaultCount + if m[1] != "" { + if n, err := strconv.Atoi(m[1]); err == nil && n > 0 { + count = n + } + } + if count > bulkMaxCount { + count = bulkMaxCount + } + return count, true +} + +// emitBulk emits `count` short agent messages, each followed by a lightweight +// completed tool call. The tool-call boundary flushes the buffered text above +// into its own message row — consecutive agent text chunks otherwise coalesce +// into a single message — so the result is a long, paginated conversation. Handy +// for manually exercising chat scrollback and the "Load older messages" button +// in dev/preview without a real agent. Usage: `/bulk`, `/bulk:300`, `/bulk 300`. +func emitBulk(e *emitter, count int) { + e.text(fmt.Sprintf("Generating %d messages to populate the chat history…", count)) + for i := 1; i <= count; i++ { + e.text(fmt.Sprintf( + "Bulk message %d of %d — filler content for testing chat pagination and the load-older button.", + i, count)) + // The tool-call start flushes the text above into its own message row. + toolID := nextToolID() + e.startTool(toolID, fmt.Sprintf("Read file-%d.txt", i), acp.ToolKindRead, + map[string]any{toolKeyFilePath: fmt.Sprintf("/tmp/bulk/file-%d.txt", i)}) + e.completeTool(toolID, map[string]any{toolKeyContent: fmt.Sprintf("contents of file %d", i)}) + fixedDelay(8) + } + e.text(fmt.Sprintf( + "Done — emitted %d messages. Scroll up and use “Load older messages” to page through them.", + count)) +} + // delayRange returns min/max delay in milliseconds based on model name. func delayRange(model string) (int, int) { switch model { @@ -149,6 +208,14 @@ func handlePrompt(e *emitter, prompt, model string) { return } + // Bulk mode: emit many messages to populate a long, paginated chat history. + // Handled before the switch so `/e2e:bulk` doesn't fall into the generic + // `/e2e:` branch. + if count, ok := parseBulkCmd(prompt); ok { + emitBulk(e, count) + return + } + switch { case strings.EqualFold(cmd, "all") || strings.EqualFold(cmd, "/all"): emitAllTypes(e, model) diff --git a/apps/backend/cmd/mock-agent/script_test.go b/apps/backend/cmd/mock-agent/script_test.go index 990414a8a5..0b3c02fe45 100644 --- a/apps/backend/cmd/mock-agent/script_test.go +++ b/apps/backend/cmd/mock-agent/script_test.go @@ -691,3 +691,58 @@ func hasMonitorMeta(meta map[string]any) bool { name, _ := cc["toolName"].(string) return name == "Monitor" } + +// --- /bulk command tests --- + +// TestParseBulkCmd verifies the /bulk command parsing and count defaulting/capping. +func TestParseBulkCmd(t *testing.T) { + tests := []struct { + prompt string + wantCount int + wantOK bool + }{ + {"/bulk", bulkDefaultCount, true}, + {"/bulk:5", 5, true}, + {"/bulk 5", 5, true}, + {"/e2e:bulk:42", 42, true}, + {"/BULK:7", 7, true}, + {"/bulk:99999", bulkMaxCount, true}, // capped + {"/bulk:0", bulkDefaultCount, true}, // non-positive falls back to default + {"/bulkish", 0, false}, + {"hello", 0, false}, + {"", 0, false}, + } + for _, tt := range tests { + t.Run(tt.prompt, func(t *testing.T) { + count, ok := parseBulkCmd(tt.prompt) + if ok != tt.wantOK || count != tt.wantCount { + t.Errorf("parseBulkCmd(%q) = (%d, %v), want (%d, %v)", + tt.prompt, count, ok, tt.wantCount, tt.wantOK) + } + }) + } +} + +// TestEmitBulk verifies emitBulk emits count+2 text messages (intro + N + outro) +// and one tool call per message (each flushing its text into a distinct row). +func TestEmitBulk(t *testing.T) { + e, mock := newTestEmitter() + const count = 4 + emitBulk(e, count) + + var texts, toolCalls int + for _, u := range mock.getUpdates() { + switch { + case isTextUpdate(u): + texts++ + case isToolCallUpdate(u): + toolCalls++ + } + } + if texts != count+2 { + t.Errorf("text updates = %d, want %d (intro + %d + outro)", texts, count+2, count) + } + if toolCalls != count { + t.Errorf("tool calls = %d, want %d", toolCalls, count) + } +} diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index e65bf46bcb..59eb9da6c6 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -228,6 +228,7 @@ export const NativeMessageList = memo(function NativeMessageList({ messagesLoading={messagesLoading} isInitialLoading={isInitialLoading} messagesCount={messages.length} + onLoadMore={loadMore} /> {items.map((item) => { diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx index acdff30b9d..6cf8a88a41 100644 --- a/apps/web/components/task/chat/message-list-shared.tsx +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -1,5 +1,6 @@ "use client"; +import { Button } from "@kandev/ui/button"; import { GridSpinner } from "@/components/grid-spinner"; import type { Message, TaskSessionState, TaskState } from "@/lib/types/http"; import type { RenderItem } from "@/hooks/use-processed-messages"; @@ -48,6 +49,7 @@ export function MessageListStatus({ messagesLoading, isInitialLoading, messagesCount, + onLoadMore, }: { isLoadingMore: boolean; hasMore: boolean; @@ -55,6 +57,12 @@ export function MessageListStatus({ messagesLoading: boolean; isInitialLoading: boolean; messagesCount: number; + /** + * Explicitly load the previous page of older messages. Rendered as a button so + * older history is always reachable even when the scroll-up IntersectionObserver + * fails to re-arm (e.g. pinned at the very top with the sentinel always in view). + */ + onLoadMore?: () => void; }) { return ( <> @@ -63,6 +71,20 @@ export function MessageListStatus({ Loading older messages... )} + {hasMore && !isLoadingMore && onLoadMore && ( +
+ +
+ )} {showLoadingState && (
diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx index da681d4baf..502d5d1851 100644 --- a/apps/web/components/task/chat/message-list-virtuoso.tsx +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { SessionPanelContent } from "@kandev/ui/pannel-session"; import type { RenderItem } from "@/hooks/use-processed-messages"; +import type { Message, TaskSessionState } from "@/lib/types/http"; import { AgentStatus } from "@/components/task/chat/messages/agent-status"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; @@ -388,6 +389,68 @@ function useVisibleScrollParent() { return { scrollParent, setScrollRef }; } +type HeaderFooterArgs = { + isLoadingMore: boolean; + hasMore: boolean; + showLoadingState: boolean; + messagesLoading: boolean; + isInitialLoading: boolean; + messages: Message[]; + loadMore: () => Promise; + sessionState?: TaskSessionState; + sessionId: string | null; + footerActionMessages?: Message[]; +}; + +/** Memoized Virtuoso Header (load-more status) and Footer (agent status + actions). */ +function useVirtuosoHeaderFooter(args: HeaderFooterArgs) { + const { isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading } = args; + const { messages, loadMore, sessionState, sessionId, footerActionMessages } = args; + const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]); + + const Header = useCallback( + () => ( + + ), + [ + isLoadingMore, + hasMore, + showLoadingState, + messagesLoading, + isInitialLoading, + messages.length, + loadMore, + ], + ); + + const Footer = useCallback( + () => ( + <> + + {footerActions.map((msg) => ( + + ))} + + ), + [sessionId, sessionState, messages, footerActions], + ); + + return { Header, Footer, footerActions }; +} + export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: MessageListProps) { const { items, @@ -421,38 +484,18 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess sessionState, }); - const Header = useCallback( - () => ( - - ), - [isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messages.length], - ); - - const footerActions = useMemo(() => footerActionMessages ?? [], [footerActionMessages]); - - const Footer = useCallback( - () => ( - <> - - {footerActions.map((msg) => ( - - ))} - - ), - [sessionId, sessionState, messages, footerActions], - ); + const { Header, Footer, footerActions } = useVirtuosoHeaderFooter({ + isLoadingMore, + hasMore, + showLoadingState, + messagesLoading, + isInitialLoading, + messages, + loadMore, + sessionState, + sessionId, + footerActionMessages, + }); if (isInitialLoading || items.length === 0) { return ( @@ -464,6 +507,7 @@ export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: Mess messagesLoading={messagesLoading} isInitialLoading={isInitialLoading} messagesCount={messages.length} + onLoadMore={loadMore} /> {footerActions.map((msg) => ( diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 688973baad..e765f1a3e5 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -771,6 +771,25 @@ export class ApiClient { } } + /** + * Seed `count` agent text messages (type "message"), oldest-to-newest. + * Unlike `seedToolCallMessages`, these are `message`-type rows so the chat's + * newest-window fetch contains a user/agent message and does not trigger the + * auto-backfill that would otherwise pull older pages on its own. + */ + async seedAgentMessages( + sessionId: string, + count: number, + prefix = "filler message", + ): Promise { + for (let i = 0; i < count; i++) { + await this.seedSessionMessage(sessionId, { + type: "message", + content: `${prefix} ${i + 1}`, + }); + } + } + async testHarnessHealth(): Promise<{ ok: boolean }> { return this.request("GET", "/api/v1/_test/health"); } diff --git a/apps/web/e2e/tests/chat/message-pagination.spec.ts b/apps/web/e2e/tests/chat/message-pagination.spec.ts new file mode 100644 index 0000000000..c20272a5f7 --- /dev/null +++ b/apps/web/e2e/tests/chat/message-pagination.spec.ts @@ -0,0 +1,112 @@ +// Chat message pagination — "Load older messages" button reliably walks a long +// conversation all the way back to the very first message (the initial prompt). +// +// Regression for the scroll-up lazy-loader wedging at the top: the +// IntersectionObserver only fires on intersection *transitions*, so once the +// user is pinned at scrollTop 0 with the sentinel permanently in view it never +// re-arms and older messages stop loading. The explicit button does not depend +// on scroll/intersection state, so it always makes progress. +// +// Covers the native renderer (the production default — `STRATEGY = "native"`). +// The virtuoso renderer shares the same MessageListStatus button, but its +// windowed rendering keeps off-screen items out of the DOM, so asserting the +// oldest message is *visible* there is a virtualization concern, not a +// pagination one — out of scope for this regression. +import { test, expect } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import { seedMessagesDescription } from "../search/shared"; + +// Stands in for the user's "initial prompt": an early chat message seeded near +// the start of the conversation, then buried under a large turn so it falls +// outside the initial newest-100 fetch window. Kept OUT of the task description +// (which the chat renders verbatim) so the only on-screen occurrence is the +// paginated message itself. +const INITIAL_PROMPT = "INITIAL-PROMPT-MARKER-7Q2X"; +// Enough filler to require multiple lazy-load pages beyond the initial 100, while +// keeping sequential seeding (one round-trip per message) off the test budget: +// 120 + the marker leaves ~20+ messages older than the initial window. +const FILLER_COUNT = 120; + +/** Boot an idle session, seed the marker, then bury it under FILLER_COUNT messages. */ +async function seedBigConversation(apiClient: ApiClient, seedData: SeedData): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "message-pagination-load-older", + seedData.agentProfileId, + { + description: seedMessagesDescription(["chat ready"]), + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const sessionId = task.session_id!; + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some((m) => m.content.includes("chat ready")); + }, + { timeout: 60_000, message: "Waiting for the boot turn to persist" }, + ) + .toBe(true); + await apiClient.seedSessionMessage(sessionId, { type: "message", content: INITIAL_PROMPT }); + await apiClient.seedAgentMessages(sessionId, FILLER_COUNT); + return task.id; +} + +test.describe("@chat message pagination", () => { + test("load-older button reaches the initial prompt in a big conversation", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(180_000); + + const taskId = await seedBigConversation(apiClient, seedData); + + // Open the session fresh (SSR + initial fetch loads only the newest ~100). + await testPage.goto(`/t/${taskId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + + // Filler renders; the initial prompt does NOT yet (older than the window). + await expect(session.chat.getByText("filler message", { exact: false }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(session.chat.getByText(INITIAL_PROMPT)).toHaveCount(0); + + // The explicit load-older button is offered because more messages exist. + const loadOlder = session.chat.getByTestId("load-older-messages"); + await expect(loadOlder).toBeVisible({ timeout: 10_000 }); + + // Click it repeatedly until the initial prompt is reached. The button is the + // reliable path — no scrolling, no intersection timing. + await expect + .poll( + async () => { + if ( + await session.chat + .getByText(INITIAL_PROMPT) + .isVisible() + .catch(() => false) + ) { + return "reached"; + } + if (await loadOlder.isVisible().catch(() => false)) { + await loadOlder.click().catch(() => undefined); + } + return "loading"; + }, + { timeout: 60_000, intervals: [300], message: "Loading older pages until initial prompt" }, + ) + .toBe("reached"); + + // The initial prompt is visible, and there's nothing left to load. + await expect(session.chat.getByText(INITIAL_PROMPT)).toBeVisible(); + await expect(loadOlder).toBeHidden(); + }); +});