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
4 changes: 2 additions & 2 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` (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` (764: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 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 + 60 daemonBridge + 9 DaemonBridgeProvider + 52 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 8 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog)
GUI: `cd emrg/gui && npm test` (175: 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 + 7 switch-session) — syntax: `node --check main.js preload.js daemon_client.js`
Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (765: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 21 markdown + 34 transcript + 32 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 + 60 daemonBridge + 9 DaemonBridgeProvider + 53 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 Down
26 changes: 26 additions & 0 deletions emrg/gui/renderer/src/components/Shell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,32 @@ describe("Shell (Batch 5 slice 3 chat wiring)", () => {
expect(screen.getByTestId("agent-session-row")).toHaveTextContent("会话一");
});

it("项目会话切换被拒(issue #2771):拒绝出现在人操作的项目面板上,人没有被搬走", async () => {
// 侧栏之外的第二条切换路径:项目面板的会话子视图(agent-session-row → onSelectAgentSession
// → selectProjectSession)。此前那条 catch 没有任何测试走过 —— 删掉它整套仍然 52 passed,
// 而「切换被拒时把拒绝显示给点的人」这句话就只剩另一半被证明。
const m = mockEmrg();
m.listAgents.mockResolvedValue([{ name: "p1", path: "/p/p1" }]);
(window as unknown as { emrg: { listAgentSessions?: unknown } }).emrg.listAgentSessions =
vi.fn().mockResolvedValue({ sessions: [{ session_id: "s1", title: "会话一" }] });
m.switchSession.mockRejectedValueOnce(new Error("boom-project"));
render(wrapper(<Shell />));
await waitFor(() => expect(screen.getByTestId("composer")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("nav-agents"));
await waitFor(() => expect(screen.getByTestId("agent-row")).toBeInTheDocument());
const row = screen.getByTestId("agent-row");
fireEvent.click(row.querySelector('button[title="Sessions"]') as HTMLButtonElement);
await waitFor(() => expect(screen.getByTestId("agent-session-row")).toBeInTheDocument());
// 点那一行 —— 这一击此前在整套测试里从未发生
fireEvent.click(screen.getByTestId("agent-session-row"));
// 拒绝可见:横幅带 daemon 交来的消息
const banner = await screen.findByTestId("switch-error");
expect(banner).toHaveTextContent("boom-project");
// 人没有被搬走:仍在项目面板,会话视图没有被切过去
expect(screen.getByTestId("panel-agents")).toBeInTheDocument();
expect(screen.queryByTestId("composer")).not.toBeInTheDocument();
});

// ── Batch 5 slice 8:任务表单 + Rant 对话框接线 ──

it("任务面板激活时每 5s 轮询 listTasks(vanilla startTaskPoll 语义)", async () => {
Expand Down
108 changes: 107 additions & 1 deletion emrg/gui/test/switch-session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,48 @@ function handlerRegion(src, name) {
return next === -1 ? rest : rest.slice(0, next);
}

/**
* 把 `//` 与块注释从一段源码里剥掉(issue #2771)。
*
* 守卫问的是「这段处理块里还有没有**代码**读这个名字」,而子串匹配回答的是「这一段正文里还
* 有没有这串字符」。两者差在注释上:只点名这个名字、一行代码都没读它的注释会被读成代码
* ⇒ 「写一句解释为什么删掉那条拒绝」这种正当改动变红,而红字指向的代码没坏。注释不是代码,
* 所以先剥再匹配。
*
* 两处细节是被测出来的,不是挑好看的:
* - **先剥块注释**:块注释里的 `//` 不是行注释的开头。
* - **行注释的 `//` 不能紧跟在 `:` 后面**:`"http://…"` 里的 `//` 是字符串的一部分,从这里
* 一直剥到行尾会把**同一行后面的代码**一起吃掉 —— 那会让真读字段的代码读成没读。假绿比
* 假红贵,所以这一步有自己的判别臂(见文件末尾那条测)。
*/
function stripComments(src) {
const withoutBlocks = src.replace(/\/\*[\s\S]*?\*\//g, "");
return withoutBlocks.replace(/(^|[^:])\/\/[^\n]*/g, "$1");
}

/**
* 这个字段名本身,按**整词**匹配。
*
* 子串匹配分不清 `ownStream` 与以它开头的更长的名字:`ownStreamRequestId` 是一次请求的 id,
* 读它与读流标记是两件事(issue #2771)。整词匹配把这两件事分开,而一条真拒绝不可能存在
* 却不出现这个名字的整词 —— 匹配变窄的代价因此为零。
*/
const OWN_STREAM_FIELD = /\bownStream\b/;

/**
* 一条源码是否**在代码里**读了那个字段:先剥注释,再按整词匹配。
*
* 谓词有名字是有理由的:数测试的仪器按行首形状认「这一行是不是在定义一条测试」,
* `OWN_STREAM_FIELD.test(...)` 单独起一行会被它读成一条定义(issue #2771 实测)。
* 一个名字把「读一个字段」写成一句话,顺带让它不再长得像一条测试。
*/
const readsOwnStream = (text) => OWN_STREAM_FIELD.test(stripComments(text));

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"),
!readsOwnStream(region),
"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",
);
Expand Down Expand Up @@ -79,3 +116,72 @@ test("区域取法本身会切分(换一个处理块取到的是另一块)",
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");
});

// ── issue #2771:上面那条断言问的对象是「代码」,所以它得在注释与更长的名字上都读对 ──

test("只点名 ownStream 的注释读作「没有代码读它」,真读它的一行仍然红", () => {
// 两向各一句,都在**字符串**上,不动 main.js:改真文件只能证明这一次,改不得下一次。
const commented = [
"async (_e, { sessionId } = {}) => {",
" // the refusal removed in issue #2637 read connManager.get(prevSid).ownStream before",
" // opening any connection: these lines name that field and read nothing from it.",
" const prevSid = currentSessionId;",
" return {};",
"}",
].join("\n");
assert.ok(
!readsOwnStream(commented),
"a comment explaining why the refusal was removed must not be read as the refusal itself",
);

const refused = [
"async (_e, { sessionId } = {}) => {",
" if (connManager.get(currentSessionId).ownStream) {",
' throw new Error("session busy");',
" }",
"}",
].join("\n");
assert.ok(
readsOwnStream(refused),
"a handler that really refuses on the field must still be caught — this is the arm the guard exists for",
);

// 块注释同样是注释:`/* … */` 里的点名不算读。
const blockCommented = " /* this used to read conn.ownStream — issue #2637 */\n return {};";
assert.ok(!readsOwnStream(blockCommented), "a block comment is not code either");
});

test("以 ownStream 开头的更长的名字不是读这个字段", () => {
// `ownStreamRequestId` 是一次请求的 id,`_releaseOwnStream()` 是释放锁;两者都与「本处理块
// 是否因流标记拒绝」无关,而子串匹配会把前者读成后者(issue #2771 实测 `includes` 为 true)。
const longer = [
" conn._releaseOwnStream();",
" this.ownStreamRequestId = rid;",
" if (frame.request_id === this.ownStreamRequestId) this._releaseOwnStream();",
].join("\n");
assert.ok(
!readsOwnStream(longer),
"names that merely begin with the field are a different subject, and the guard must not redden them",
);
// 对照:这一类名字确实存在于本程序里,所以上一条不是在与一个假想的形状搏斗。
assert.ok(
source.includes("_releaseOwnStream"),
"control: the release helper this test excludes does exist in main.js",
);
});

test("字符串里的 // 不是注释起点(剥除不能吃掉同一行后面的代码)", () => {
// 这一条是剥除本身的判别臂,方向是**假绿**:若行注释的 `//` 不排除前面是 `:` 的那种,
// 一次 URL 字面量之后的所有代码都会被剥掉,于是同行的真拒绝读成「没读」。
const withUrl = ' const u = "http://example.test/x"; if (conn.ownStream) throw new Error("busy");';
assert.ok(
readsOwnStream(withUrl),
"a `//` inside a string literal must not swallow the code that follows it on the same line",
);
// 对照:同一行里真的是注释时,行尾的 `//` 之后不再算代码。
const withComment = ' const u = "http://example.test/x"; return {}; // ownStream lives elsewhere';
assert.ok(
!readsOwnStream(withComment),
"a trailing line comment is still a comment",
);
});
Loading