Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>`, 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 <N>` (did the merge land a tree its reviews named)
Expand All @@ -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

Expand Down
9 changes: 4 additions & 5 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),防广播"幽灵"残留
Expand Down
31 changes: 31 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Shell />));
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(<Shell />));
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(<Shell />));
Expand Down
75 changes: 48 additions & 27 deletions emrg/gui/renderer/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export function Shell() {
const [rants, setRants] = useState<RantRec[]>([]);
const [projectSessions, setAgentSessions] = useState<SessionRow[] | null>(null);
const [projectSessionsError, setAgentSessionsError] = useState<string | null>(null);
// 切会话被拒时的可见提示(issue #2637):拒绝必须落在人操作的那个面板上,
// 而不是一个不在屏幕上的转录或一个未处理的 Promise。
const [switchError, setSwitchError] = useState<string | null>(null);
const [dialogState, dispatch] = useReducer(dialogReducer, initialDialogState);
// Batch 5 slice 8:任务表单 + Rant 对话框(vanilla openTaskForm/openRantForm 语义)
const [taskForm, setTaskForm] = useState<{ task: TaskRec | null } | null>(null);
Expand Down Expand Up @@ -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);
Expand All @@ -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));
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -795,28 +809,35 @@ export function Shell() {
</aside>
<main className="react-shell-main" data-testid="react-shell-main">
{isPanelView ? (
<WorkspaceView
activeView={activeView}
onSwitch={switchView}
agents={agents}
tasks={tasks}
rants={rants}
projectSessions={projectSessions}
projectSessionsError={projectSessionsError}
currentSid={activeSid}
version={appState.currentVersion}
evolutionCount={appState.evolutionCount}
onViewAgentSessions={(p) => 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 ? (
<div className="switch-banner" role="alert" data-testid="switch-error">
{t("app.switchFailed", { msg: switchError })}
</div>
) : null}
<WorkspaceView
activeView={activeView}
onSwitch={switchView}
agents={agents}
tasks={tasks}
rants={rants}
projectSessions={projectSessions}
projectSessionsError={projectSessionsError}
currentSid={activeSid}
version={appState.currentVersion}
evolutionCount={appState.evolutionCount}
onViewAgentSessions={(p) => 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 ? (
Expand Down
3 changes: 2 additions & 1 deletion emrg/gui/renderer/src/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
81 changes: 81 additions & 0 deletions emrg/gui/test/switch-session.test.js
Original file line number Diff line number Diff line change
@@ -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:<name>"` 起,到**下一个** `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");
});
Loading