diff --git a/Agent.md b/Agent.md index 421c9e19..cf0bd0bf 100644 --- a/Agent.md +++ b/Agent.md @@ -54,8 +54,8 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `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` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (752: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) +GUI: `cd emrg/gui && npm test` (172: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash + 4 switch-session) — syntax: `node --check main.js preload.js daemon_client.js` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (754: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 52 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - 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 asks what 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` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` - Post-merge audit: `uv run --no-sync python3 scripts/check-merge-landed.py ` (did the merge land a tree its reviews named) @@ -73,7 +73,7 @@ Every guard that reads a working tree names it before its verdict; a question it ## Packaging -Only `icon.svg` is committed; the generated icon products are gitignored and CI builds them. A local build runs `bash packaging/gen-assets.sh` first — idempotent, and `.icns` needs macOS `iconutil`. +Only `icon.svg` is committed; generated icon products are gitignored and CI builds them (`bash packaging/gen-assets.sh` locally first; `.icns` needs macOS `iconutil`). ## Configuration diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 516fae1e..823311d3 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -584,11 +584,10 @@ vision = false logger.warn(`[gui:switch] REJECT too many open sessions (${openSessions.size}/${DEFAULT_CAP}) sid=${sessionId}`); throw new Error(`too many open sessions (${DEFAULT_CAP}) — close some first`); } - // G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接) - if (connManager?.get(currentSessionId)?.ownStream) { - logger.warn(`[gui:switch] REJECT own stream in progress (sid=${currentSessionId})`); - throw new Error("stream in progress — cannot switch"); - } + // 切会话不再因「当前会话仍在流式」而拒绝(issue #2637):切走只打开/复用**目标** + // 会话的连接,旧连接原样保留(见下方 openSession 之后的 P4 注释),所以一个正在 + // 生成的回合不受切换影响。真正会切断回合的是主动关闭——`ConnManager.close()` + // 先发 cancel 再断 ws,那条路径不变。 const prevSid = currentSessionId; logger.info(`[gui:switch] prevSid=${prevSid} → target=${sessionId}, openSessions.size=${openSessions.size}`); // G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留 diff --git a/emrg/gui/renderer/src/components/Shell.test.tsx b/emrg/gui/renderer/src/components/Shell.test.tsx index 0b659aea..991f17b7 100644 --- a/emrg/gui/renderer/src/components/Shell.test.tsx +++ b/emrg/gui/renderer/src/components/Shell.test.tsx @@ -685,6 +685,37 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => { expect(screen.queryByTestId("panel-tasks")).not.toBeInTheDocument(); }); + it("切换被拒(issue #2637):任务面板原地不动,拒绝出现在人操作的面板上,不写进没人看的转录", async () => { + const m = mockEmrg(); + m.switchSession.mockRejectedValueOnce(new Error("boom-guard")); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-tasks")); + await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument()); + fireEvent.click(screen.getByTitle("Open session")); + // 拒绝可见:横幅就在任务面板上方 + const banner = await screen.findByTestId("switch-error"); + expect(banner).toHaveTextContent("boom-guard"); + // 人没有被搬走:仍在任务面板,会话视图没有被切换过去 + expect(screen.getByTestId("panel-tasks")).toBeInTheDocument(); + expect(screen.queryByTestId("composer")).not.toBeInTheDocument(); + }); + + it("切换被拒后再次成功 → 横幅退场并进入会话视图(issue #2637)", async () => { + const m = mockEmrg(); + m.switchSession.mockRejectedValueOnce(new Error("boom-guard")); + render(wrapper()); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("nav-tasks")); + await waitFor(() => expect(screen.getByTestId("task-row")).toBeInTheDocument()); + fireEvent.click(screen.getByTitle("Open session")); + await screen.findByTestId("switch-error"); + // 第二次点击:mock 已回落到默认 resolve + fireEvent.click(screen.getByTitle("Open session")); + await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument()); + expect(screen.queryByTestId("switch-error")).not.toBeInTheDocument(); + }); + it("进入 Rant 面板 → 加载 listRants 并渲染 rant 行", async () => { const m = mockEmrg(); render(wrapper()); diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 2daa880d..a9a8d828 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -130,6 +130,9 @@ export function Shell() { const [rants, setRants] = useState([]); const [projectSessions, setAgentSessions] = useState(null); const [projectSessionsError, setAgentSessionsError] = useState(null); + // 切会话被拒时的可见提示(issue #2637):拒绝必须落在人操作的那个面板上, + // 而不是一个不在屏幕上的转录或一个未处理的 Promise。 + const [switchError, setSwitchError] = useState(null); const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState); // Batch 5 slice 8:任务表单 + Rant 对话框(vanilla openTaskForm/openRantForm 语义) const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null); @@ -322,7 +325,16 @@ export function Shell() { async function selectProjectSession(p: AgentRec, sid: string) { const b = wsBridge(); - if (b?.switchSession) await b.switchSession({ sessionId: sid }); + if (!b?.switchSession) return; + try { + await b.switchSession({ sessionId: sid }); + } catch (e) { + // 切换失败:人没有离开当前视图,所以拒绝要出现在**这里**(issue #2637)—— + // 以前这一行没有 try,拒绝变成未处理的 Promise rejection,点一下像没反应。 + setSwitchError((e as Error)?.message ?? String(e)); + return; + } + setSwitchError(null); setActiveSid(sid); setActiveView("sessions"); void loadHistory(sid); @@ -335,14 +347,14 @@ export function Shell() { if (!b?.switchSession) return; try { await b.switchSession({ sessionId: task.session_id, projectPath: task.agent_path }); + setSwitchError(null); setActiveSid(task.session_id); setActiveView("sessions"); void loadHistory(task.session_id); } catch (e) { - transcript.addSystemMessage( - t("openSession.loadFailed", { msg: (e as Error)?.message ?? String(e) }), - activeSid, - ); + // 以前这条拒绝被写进 `activeSid` 的会话记录,而人正看着任务面板 —— 消息落在 + // 一个不在屏幕上的转录里。面板视图上方的横幅才是人操作的地方(issue #2637)。 + setSwitchError((e as Error)?.message ?? String(e)); } } @@ -452,6 +464,8 @@ export function Shell() { /** 工作区视图切换(vanilla app.js switchView 语义): * 点当前面板 → toggle 回会话视图;点其他面板 → 切换;点 sessions → 回会话视图 */ function switchView(view: SidebarViewId): void { + // 换视图即离开那次失败的操作现场,提示随之退场(issue #2637) + setSwitchError(null); if (view === "sessions") { setActiveView("sessions"); return; @@ -795,28 +809,35 @@ export function Shell() {
{isPanelView ? ( - void loadProjectSessions(p)} - onSelectAgentSession={(p, sid) => void selectProjectSession(p, sid)} - onAddAgent={() => void addProject()} - onDeleteAgent={deleteAgent} - onTriggerTask={(task) => void triggerTask(task)} - onOpenSessionTask={(task) => void openTaskSession(task)} - onEditTask={(task) => void openTaskForm(task)} - onDeleteTask={(task) => deleteTask(task)} - onNewRant={() => void newRant()} - renderer={mdRenderer} - /> + <> + {switchError ? ( +
+ {t("app.switchFailed", { msg: switchError })} +
+ ) : null} + void loadProjectSessions(p)} + onSelectAgentSession={(p, sid) => void selectProjectSession(p, sid)} + onAddAgent={() => void addProject()} + onDeleteAgent={deleteAgent} + onTriggerTask={(task) => void triggerTask(task)} + onOpenSessionTask={(task) => void openTaskSession(task)} + onEditTask={(task) => void openTaskForm(task)} + onDeleteTask={(task) => deleteTask(task)} + onNewRant={() => void newRant()} + renderer={mdRenderer} + /> + ) : ( <> {disconnected ? ( diff --git a/emrg/gui/renderer/src/shell.css b/emrg/gui/renderer/src/shell.css index 8805db59..c572eea4 100644 --- a/emrg/gui/renderer/src/shell.css +++ b/emrg/gui/renderer/src/shell.css @@ -168,7 +168,8 @@ color: var(--text-1); } -.react-shell-main .conn-banner { +.react-shell-main .conn-banner, +.react-shell-main .switch-banner { padding: 8px 16px; background: var(--amber-soft); color: var(--amber); diff --git a/emrg/gui/test/switch-session.test.js b/emrg/gui/test/switch-session.test.js new file mode 100644 index 00000000..e87408a7 --- /dev/null +++ b/emrg/gui/test/switch-session.test.js @@ -0,0 +1,81 @@ +"use strict"; +/** + * switch-session.test.js — 切会话永不因「当前会话仍在流式」被拒(issue #2637)。 + * + * 守卫的对象是 `main.js` 里 `emrg:switchSession` 的处理块:它以前在打开任何连接之前, + * 用 `connManager.get(currentSessionId).ownStream` 拒绝切换。那条拒绝来自「切会话会关掉 + * 旧连接」的年代;切走早已不再关闭旧连接(打开目标会话的连接,旧连接原样保留),所以它 + * 只拒绝、不保护 —— 一个在会话 A 上生成的回合,与把窗口移到 B 无关。 + * + * 断言两块,一正一反: + * - 该处理块**不再**读 `ownStream`; + * - 该处理块**仍然**带另外三条拒绝(非法 session_id / 非法 agent path / 20 条上限), + * 所以「区域取空 ⇒ 断言通过」这种空过法在这里读作失败。 + * + * 另一半(`ConnManager.close()` 对在忙连接先 cancel 再 close)不在这里断言:它是行为, + * 不是源码形状,`conn-manager.test.js` 的「P6 close: 在忙连接(ownStream)先 cancel 再 + * close」用真实帧把它测住了。一处规则一处断言。 + */ +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const path = require("node:path"); + +const MAIN_JS = path.join(__dirname, "..", "main.js"); +const source = fs.readFileSync(MAIN_JS, "utf8"); + +/** + * 从 `ipcMain.handle("emrg:"` 起,到**下一个** `ipcMain.handle(` 为止的处理块。 + * + * 上界用下一个处理块而不是「文件末尾」:`split(marker)[0]` 会一路铺到 EOF,于是区域外的 + * 任何一处命中都被读成区域内命中,反之区域内的改动也可能被更大的区域掩盖。 + */ +function handlerRegion(src, name) { + const marker = `ipcMain.handle("emrg:${name}"`; + const start = src.indexOf(marker); + assert.notStrictEqual(start, -1, `main.js must register ${marker}`); + const rest = src.slice(start + marker.length); + const next = rest.indexOf("ipcMain.handle("); + return next === -1 ? rest : rest.slice(0, next); +} + +test("emrg:switchSession 的处理块不再读 ownStream(issue #2637)", () => { + const region = handlerRegion(source, "switchSession"); + assert.ok(region.length > 500, `the switch region must be the real handler, got ${region.length} chars`); + assert.ok( + !region.includes("ownStream"), + "the switch handler must not refuse on ownStream any more: switching leaves the previous " + + "connection open, so a running turn is unaffected by moving the window to another session", + ); +}); + +test("switchSession 的其余三条拒绝仍在(区域是真的处理块,不是空片)", () => { + const region = handlerRegion(source, "switchSession"); + assert.ok(region.includes('throw new Error("invalid session_id")'), "the session_id refusal must stay"); + assert.ok(region.includes('throw new Error("invalid agent path")'), "the agent-path refusal must stay"); + assert.ok(region.includes("too many open sessions"), "the open-session cap refusal must stay"); +}); + +test("switchSession 不关闭它离开的那个连接(去掉拒绝的那一步所依赖的事实)", () => { + // 上一条断言(不再因 ownStream 拒绝)只有在「切走不关闭旧连接」成立时才是安全的: + // 旧连接若被关,`ConnManager.close()` 会先给在忙的连接发 cancel(见 conn-manager.test.js), + // 那个正在生成的回合就真的被切换掐断了。所以这里把那条事实也钉住。 + // 允许的 close 只有一处:会话不存在时关掉**目标**那条失败连接(catch 里的防御), + // 所以断言的对象是「离开的那个 sid」——`close(prevSid` 一律不许出现。 + const region = handlerRegion(source, "switchSession"); + assert.ok(!/close\(\s*prevSid/.test(region), "switching must not close the connection it leaves"); + assert.ok( + region.includes("clearGroups()"), + "the previous session's group cache is cleared (a cache, not the connection) — if this call " + + "moved, re-read which of the two the switch path still does", + ); +}); + +test("区域取法本身会切分(换一个处理块取到的是另一块)", () => { + // 仪器的控制:同一份源码取两个不同处理块,必须得到不同文本,且各自带自己的标记。 + const switchRegion = handlerRegion(source, "switchSession"); + const closeRegion = handlerRegion(source, "closeSession"); + assert.notStrictEqual(switchRegion, closeRegion); + assert.ok(closeRegion.includes("closeSession("), "the closeSession region must carry its own body"); + assert.ok(!switchRegion.includes("closeSession("), "the switch region must not run into the next handler"); +});