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
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ 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` (161: 98 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 6 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js`
GUI: `cd emrg/gui && npm test` (166: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 9 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` (716: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 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 + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 56 daemonBridge + 9 DaemonBridgeProvider + 48 Shell + 24 DialogHost + 26 SettingsPanel + 6 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 14 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`
Expand Down
25 changes: 24 additions & 1 deletion emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ const RESPONSE_TYPES = {
list_rants: "rants_list",
list_memories: "memories_list",
resume_session: "resume_result",
// issue #2484:`resolve_session_cwd` 由 daemon 以 `session_cwd_result` 回答
// (daemon.py `_process_message`)。缺这一行,GUI 连"会话到底在哪个项目"都问不出去。
resolve_session_cwd: "session_cwd_result",
delete_session: "session_deleted",
set_model: "model_set",
clear_session: "clear_result", // 修正:daemon 命令名是 clear_session(原 clear 匹配不上)
Expand All @@ -157,6 +160,26 @@ const RESPONSE_TYPES = {
task_template_delete: "template_result",
};

/** The cwd a `session_cwd_result` frame names, or the caller's own candidate.
*
* The daemon answers where a session lives so that a client does not have to
* guess (issue #2484). Two answers are deliberately not the same value: `index`
* carries the project the global session index holds, and `unknown` carries
* `null` — the daemon cannot vouch for that session's project, so the requested
* cwd comes back only as a candidate and must not be relabelled as a fact.
*
* So everything except a non-empty `index` cwd answers the caller's fallback,
* a frame this cannot read included: a client that treats a guess as a fact is
* the defect the command exists to remove.
*/
function pickSessionCwd(frame, fallbackCwd = null) {
if (!frame || typeof frame !== "object") return fallbackCwd;
if (frame.source !== "index") return fallbackCwd;
const cwd = frame.cwd;
if (typeof cwd !== "string" || !cwd.trim()) return fallbackCwd;
return cwd;
}

class DaemonClient {
constructor({ logger = console, authTimeoutMs = AUTH_TIMEOUT_MS, isPackaged = false, deltaBatchMs = 0 } = {}) {
this.logger = logger;
Expand Down Expand Up @@ -1297,4 +1320,4 @@ function generateSessionId(seed) {
return sid;
}

module.exports = { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR };
module.exports = { DaemonClient, generateSessionId, isSessionId, pickSessionCwd, TOKEN_FILE, MAX_PAYLOAD, EMRGD_PORT, EMRGD_START_ERR };
48 changes: 44 additions & 4 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { pathToFileURL } = require("url");
const { spawn } = require("child_process");
const crypto = require("crypto");
const { parse: parseToml, stringify: stringifyToml } = require("smol-toml");
const { generateSessionId, isSessionId } = require("./daemon_client");
const { generateSessionId, isSessionId, pickSessionCwd } = require("./daemon_client");
const { ConnManager } = require("./conn-manager");
const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state");
const { externalNavPolicy } = require("./nav-policy");
Expand Down Expand Up @@ -80,6 +80,34 @@ function main() {
} catch { /* index 缺失/损坏 → 未知(null) */ }
return null;
}

// 会话的真实项目目录:先问 daemon,问不到才退回上面那套本地推断(issue #2484)。
//
// 此前 GUI 只读本地 `sessions_index.json`,读不到就由调用方 `|| DEFAULT_CWD` 兜到
// homedir —— daemon 的 `_handle_resume_session` 早把这条写进注释:cwd 落错的客户端
// 会在 `~/.emrg/sessions/<sid>/` 误建 0 消息幽灵会话,真会话反而打不开。而 daemon
// 本来就持有这个事实(全局会话索引),并且有专门的 `resolve_session_cwd` 命令:
// `index` 带回项目路径,`unknown` 只把请求值当候选回显(不冒充事实)。TUI 每次打开
// 都先问(app.py 的 /resume 与会话选择器两处),GUI 此前一处都没有。
//
// 返回 null 表示「daemon 也说不准」,候选值由调用方决定 —— 这里绝不兜 homedir。
async function askSessionCwd(sessionId, candidateCwd) {
const conn = connManager?.daemonConn();
if (!conn || !conn.connected) return null;
try {
const frame = await conn.sendCommandAndWait(
"resolve_session_cwd",
{ session_id: sessionId, cwd: candidateCwd || DEFAULT_CWD },
3000,
);
return pickSessionCwd(frame, null);
} catch (e) {
// 超时或错误帧:与 daemon 的 unknown 同义 —— 客户端保留自己的候选
logger.warn(`[gui] resolve_session_cwd failed sid=${sessionId}: ${e.message}`);
return null;
}
}

// P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。
// renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。
let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建)
Expand Down Expand Up @@ -382,9 +410,16 @@ vision = false
}
// P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅)
// P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir)
const sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD;
// issue #2484:候选 cwd 先交给 daemon 判定 —— 它说 index 就以它为准(那是事实),
// 说 unknown 或问不到才用调用方给的 projectPath,最后才退回本地索引推断。
let sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD;
let conn = connManager?.get(sessionId);
if (!conn || !conn.connected) {
// issue #2484:这次打开带 resume:false,daemon 的幽灵会话校验
// (_handle_resume_session:meta 缺失/0 消息时按全局索引核对 cwd)没有机会跑,
// 随后的 sendTask 就把这里的 cwd 交给了 daemon 的取/建路径。所以打开前先问准。
const asked = await askSessionCwd(sessionId, DEFAULT_CWD);
if (asked) sessionCwd = asked;
conn = await openSession(sessionId, sessionCwd, { resume: false });
}
markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化
Expand Down Expand Up @@ -562,8 +597,13 @@ vision = false
// Rant 2026-08-25T17:38:56 根因 2:fallback 链去掉 DEFAULT_CWD——未知会话
// 交 daemon not_found 处理(下方已有 fallback 到最近会话),绝不隐式回退
// homedir 误建幽灵会话。
const targetPath = projectPath || resolveSessionCwd(sessionId);
logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath}`);
// issue #2484:先问 daemon「这个会话在哪个项目」,再退回本地推断。
// 顺序即优先级:daemon 的 index 答案是事实,projectPath 是调用方的候选,
// 本地索引读不到才轮到它 —— 三者都没有就交 null 给 daemon 回 not_found,
// 绝不隐式回退 homedir(那正是幽灵会话的来源)。
const asked = await askSessionCwd(sessionId, projectPath || DEFAULT_CWD);
const targetPath = asked || projectPath || resolveSessionCwd(sessionId);
logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath} asked=${asked}`);
try {
await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅
logger.info(`[gui:switch] openSession OK sid=${sessionId} targetPath=${targetPath}`);
Expand Down
66 changes: 65 additions & 1 deletion emrg/gui/test/boot-contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ const path = require("node:path");
const GUI_ROOT = path.join(__dirname, "..");

function read(rel) {
return fs.readFileSync(path.join(GUI_ROOT, rel), "utf8");
// Normalise the line ending here, once, so every assertion in this file reads
// text whose newlines are "\n" whatever the checkout did. A sentinel written as
// "\n }\n" is a property of the file's content, not of the platform, but a CRLF
// checkout makes indexOf answer -1 and the slice collapse to "" — measured
// 2026-10-22: the same assertion passed on Linux and macOS and failed on Windows
// with `actual: ''`. The assertions themselves are unchanged.
return fs.readFileSync(path.join(GUI_ROOT, rel), "utf8").replace(/\r\n/g, "\n");
}

test("boot chain: main.js emrg:init handler must call ensureConnected()", () => {
Expand Down Expand Up @@ -229,3 +235,61 @@ test("device flow: the emrg:githubConnectWeb handler forwards the frame's user",
);
assert.strictEqual(already.code, null, "a short-circuit carries no device code");
});

// ── issue #2484:GUI 打开会话前,必须先问 daemon「它在哪个项目」 ──
//
// main.js 这一层没有运行时测试(renderer 测试全 mock window.emrg,integration 测试
// 直接打 daemon_client),所以用与本文件同款的静态钉子。被钉住的是一条因果链:
// 本地 sessions_index.json 读不到 → 调用方 `|| os.homedir()` → 带 resume:false 的
// 预打开绕过 daemon 的幽灵会话校验(_handle_resume_session 只在 resume 时核对 cwd)
// → daemon 照单创建 `~/.emrg/sessions/<sid>/`,真会话(另一个项目下的同名 id)再也打不开。

function handlerRegion(src, name) {
const start = src.indexOf(`ipcMain.handle("${name}"`);
assert.ok(start >= 0, `${name} handler 必须存在于 main.js`);
const next = src.indexOf("ipcMain.handle(", start + 10);
return src.slice(start, next === -1 ? undefined : next);
}

test("issue #2484: emrg:switchSession asks the daemon before it opens a session", () => {
const region = handlerRegion(read("main.js"), "emrg:switchSession");
assert.match(
region,
/await askSessionCwd\(/,
"switchSession must ask the daemon where the session lives: its own index lookup " +
"answers null for a session it cannot place, and the caller then opens at the home directory.",
);
assert.match(
region,
/asked \|\| projectPath \|\| resolveSessionCwd\(sessionId\)/,
"precedence must be daemon answer > the caller's project > the local index guess, so a " +
"session from another project opens where it really lives rather than where we guessed.",
);
});

test("issue #2484: the send path's pre-open asks the daemon first, and does so before opening", () => {
const region = handlerRegion(read("main.js"), "emrg:sendMessage");
const openAt = region.indexOf("openSession(sessionId, sessionCwd, { resume: false })");
assert.ok(openAt > 0, "send path still pre-opens with resume:false (the window this guards)");
const beforeOpen = region.slice(0, openAt);
assert.match(
beforeOpen,
/await askSessionCwd\(/,
"the ask must happen BEFORE the resume:false open: that open skips the daemon's ghost " +
"check, so the cwd it carries is the cwd the session gets created at.",
);
});

test("issue #2484: askSessionCwd asks, judges, and never falls back to the home directory", () => {
const src = read("main.js");
const start = src.indexOf("async function askSessionCwd(");
assert.ok(start > 0, "askSessionCwd must exist");
const body = src.slice(start, src.indexOf("\n }\n", start) + 4);
assert.match(body, /sendCommandAndWait\(\s*"resolve_session_cwd"/, "it must send the daemon's own command");
assert.match(body, /pickSessionCwd\(frame, null\)/, "an answer that is not `index` must not become a fact");
assert.match(body, /return null;/, "an unanswerable question returns null for the caller to decide");
assert.ok(
!/DEFAULT_CWD/.test(body.split("candidateCwd || DEFAULT_CWD")[1] || ""),
"DEFAULT_CWD may be the candidate it sends, never the value it returns",
);
});
57 changes: 56 additions & 1 deletion emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Module._load = function (request, parent, isMain) {
if (request === "ws") return MockWs;
return origLoad.apply(this, arguments);
};
const { DaemonClient, generateSessionId, isSessionId, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js");
const { DaemonClient, generateSessionId, isSessionId, pickSessionCwd, TOKEN_FILE, EMRGD_PORT, EMRGD_START_ERR } = require("../daemon_client.js");
let tmpHome = null;
let origHome = null;
let origUserProfile = null;
Expand Down Expand Up @@ -2136,3 +2136,58 @@ test("#1283 startDaemon:标记在 spawn 之前取,失败信息只含本次
require.cache[cacheKey] = originalModule;
}
});

// ── issue #2484:先问 daemon「这个会话在哪个项目」 ──
//
// GUI 此前只读本地 sessions_index.json,读不到就用 `|| os.homedir()` 兜底,
// 而 daemon 的幽灵会话校验只挂在 resume_session 上 —— 发送路径的预打开带
// resume:false,于是 cwd 直接进了 daemon 的取/建路径。这两条测试钉住 GUI 侧
// 问得出去、答得回来,以及「答得不准时不冒充事实」。

test("resolve_session_cwd 一问一答(issue #2484:GUI 侧此前无此命令)", async () => {
const client = new DaemonClient();
await connectClient(client);
const p = client.sendCommandAndWait(
"resolve_session_cwd",
{ session_id: "feishu-inbox", cwd: "/Users/argszero" },
2000,
);
await new Promise((r) => setTimeout(r, 10));
const sent = JSON.parse(currentMockWs.sent.at(-1));
assert.strictEqual(sent.type, "resolve_session_cwd", "命令名即帧 type");
assert.strictEqual(sent.session_id, "feishu-inbox");
assert.strictEqual(sent.cwd, "/Users/argszero", "客户端自己的 cwd 只作候选发出");
currentMockWs.emit("message", Buffer.from(JSON.stringify({
type: "session_cwd_result",
session_id: "feishu-inbox",
cwd: "/Users/argszero/.emrg",
source: "index",
})));
const res = await p;
assert.strictEqual(res.type, "session_cwd_result", "映射表认得出响应帧(缺映射会超时)");
assert.strictEqual(pickSessionCwd(res, null), "/Users/argszero/.emrg", "index 答案即事实");
});

test("pickSessionCwd:只有 index 答案是事实,其余一律退回调用方候选(issue #2484)", () => {
const fallback = "/proj/caller";
// index + 非空 cwd → daemon 说了算(跨项目会话的唯一正解)
assert.strictEqual(pickSessionCwd({ source: "index", cwd: "/proj/real" }, fallback), "/proj/real");
// unknown → daemon 无法保证;回显的 requested_cwd 只是候选,绝不采信
assert.strictEqual(
pickSessionCwd({ source: "unknown", cwd: null, requested_cwd: "/proj/caller" }, fallback),
fallback,
);
// 声称 index 却是空/非字符串 → 不冒充事实
assert.strictEqual(pickSessionCwd({ source: "index", cwd: "" }, fallback), fallback);
assert.strictEqual(pickSessionCwd({ source: "index", cwd: " " }, fallback), fallback);
assert.strictEqual(pickSessionCwd({ source: "index", cwd: null }, fallback), fallback);
assert.strictEqual(pickSessionCwd({ source: "index", cwd: 42 }, fallback), fallback);
// 缺 source、不可读的帧 → 候选
assert.strictEqual(pickSessionCwd({ cwd: "/proj/x" }, fallback), fallback);
assert.strictEqual(pickSessionCwd(null, fallback), fallback);
assert.strictEqual(pickSessionCwd(undefined, fallback), fallback);
assert.strictEqual(pickSessionCwd("session_cwd_result", fallback), fallback);
// 默认候选是 null —— 兜底由调用方决定,这里绝不自己变出 homedir
assert.strictEqual(pickSessionCwd({ source: "unknown", cwd: null }), null);
assert.strictEqual(pickSessionCwd({ source: "index", cwd: "/proj/real" }), "/proj/real");
});
Loading