From 0157184b53bc4d16a715bd0d7a1b626e9be6523d Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 21 Sep 2026 05:37:28 +0800 Subject: [PATCH] emrg: the older-page cursor has a guard, so a dropped cursor fails the suite --- Agent.md | 2 +- .../renderer/src/components/Shell.test.tsx | 80 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index eb498979..63d8621f 100644 --- a/Agent.md +++ b/Agent.md @@ -57,7 +57,7 @@ One line per command: what it answers. The *why* behind each guard is in its doc - Python: `uv run pytest tests/ -v` — the number is **measured, never stored** (a stored count is a conflict magnet that goes stale silently): `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (137: 81 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 6 build-config + 4 boot-contract + 3 preload-api + 2 theme-guard) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (537: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 18 history + 15 historyReplay + 31 composer + 42 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 20 daemonBridge + 7 DaemonBridgeProvider + 32 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (539: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 21 transcript + 11 TranscriptView + 18 history + 15 historyReplay + 31 composer + 42 Composer + 6 LinkDialog + 16 sidebar + 17 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 10 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 20 daemonBridge + 7 DaemonBridgeProvider + 34 Shell + 15 DialogHost + 20 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each answers a question no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (what merging dirties) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (two PRs together; every step of an order) · `scripts/check-merge-tree-health.py` (the merged tree passes the guards) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Other tools: `uv run --no-sync python3 scripts/check-node-test-count.py --write` (syncs the Node totals above), `scripts/check-patch-files.py` (a rebuilt patch carries every file the previous one did), `scripts/cast-vote.py` (a vote the counter counts), `bump-version.py`, `classify-conflict.py`, `sync-master-from-api.py`, `push-branch-from-api.py` diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 2019d767..1c4d4d6f 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -208,6 +208,86 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.getByText("结论:一切正常。")).toBeInTheDocument(); }); + /** + * 两页记录(更新的一页 + 更早的一页),桥的替身照 `before_index` 回答:带游标 = 更早那一页, + * 不带 = 又一份最新页。于是「游标丢了」这个真实退化在断言里可辨,而不只是断言一个参数名。 + */ + function mockTwoHistoryPages(m: ReturnType) { + const newest = [ + { record_index: 4, kind: "message", role: "user", content: "newest-1" }, + { record_index: 5, kind: "message", role: "assistant", content: "newest-2" }, + { record_index: 6, kind: "message", role: "user", content: "newest-3" }, + ]; + const older = [ + { record_index: 1, kind: "message", role: "user", content: "older-1" }, + { record_index: 2, kind: "message", role: "assistant", content: "older-2" }, + { record_index: 3, kind: "message", role: "user", content: "older-3" }, + ]; + m.listHistory.mockImplementation((p: { beforeIndex?: number } = {}) => + Promise.resolve( + p.beforeIndex != null + ? { messages: older, hasMore: false } + : { messages: newest, hasMore: true }, + ), + ); + return { newest, older }; + } + + /** 打开一个会话,等第一页落定,再把滚动位置带到顶部(TranscriptView 的 capture 监听在 scrollTop<=2 时上翻)。 */ + async function openSessionAndScrollToTop( + m: ReturnType, + container: HTMLElement, + firstPageText: string, + ) { + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit(openSessionsFrame([{ sid: "s1", title: "Alpha" }])); + await waitFor(() => expect(m.listHistory).toHaveBeenCalledTimes(1)); + // 第一页真的渲染出来(此时 hasMore 已落定 → canLoadOlder 为真),再模拟上翻。 + await waitFor(() => expect(screen.getByText(firstPageText)).toBeInTheDocument()); + const viewport = container.querySelector('[data-testid="transcript-view"]'); + expect(viewport).not.toBeNull(); + Object.defineProperty(viewport, "scrollTop", { value: 0, configurable: true, writable: true }); + Object.defineProperty(viewport, "scrollHeight", { value: 1000, configurable: true }); + Object.defineProperty(viewport, "clientHeight", { value: 400, configurable: true }); + act(() => { + viewport!.dispatchEvent(new Event("scroll", { bubbles: false })); + }); + } + + it("asks for the records BEFORE the oldest loaded one when scrolled to the top (rant 2026-09-20T18:58:44)", async () => { + // 该 rant 的第三条缺陷是「分页会重复、会漏、会让更早内容不可达」,修法是游标改成 + // 最早已加载记录的绝对 record_index。游标一旦丢失,页 2 会再取一次最新页,而 + // `loaded` 会把整页滤空 —— 视图永不前进,且套件全绿(issue #1493 的变异臂实测)。 + const m = mockEmrg(); + const { older } = mockTwoHistoryPages(m); + const { container } = render(wrapper()); + await openSessionAndScrollToTop(m, container, "newest-1"); + + await waitFor(() => expect(m.listHistory).toHaveBeenCalledTimes(2)); + expect(m.listHistory).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sessionId: "s1", includeRecords: true, beforeIndex: 4 }), + ); + // 更早一页确实进了视图,而不是被 `loaded` 去重滤空。 + await waitFor(() => expect(screen.getByText(older[0].content)).toBeInTheDocument()); + }); + + it("prepends an older page as one block, keeping record order (rant 2026-09-20T18:58:44)", async () => { + const m = mockEmrg(); + const { newest, older } = mockTwoHistoryPages(m); + const { container } = render(wrapper()); + await openSessionAndScrollToTop(m, container, "newest-1"); + await waitFor(() => expect(screen.getByText(older[2].content)).toBeInTheDocument()); + + // 文本在容器里的先后 = DOM 顺序:更早一页整块在当前页之前,且页内仍是记录顺序 + // (倒序 unshift 会让页内反而颠倒,这一条同时钉住「前插」和「不翻转」)。 + const text = container.querySelector('[data-testid="transcript-view"]')?.textContent ?? ""; + const at = (s: string) => text.indexOf(s); + expect(at(older[0].content)).toBeGreaterThanOrEqual(0); + expect(at(older[2].content)).toBeLessThan(at(newest[0].content)); + expect(at(older[0].content)).toBeLessThan(at(older[2].content)); + }); + it("shows the connection status + model from the status broadcast", async () => { const m = mockEmrg(); render(wrapper());